Skip to content

Commit cb55b68

Browse files
authored
Dev (#67)
* workflows/update-gitignore-and-create-mls-ci #34 (#38) * workflows/update-gitignore #34 Updated .gitignore to allow .log files in the repository for test data. * workflow/add-gh-actions-workflow #34 Introduces a CI workflow that runs linting and tests on code changes to the main and dev branch, while skipping these steps for documentation-only changes. This setup uses flake8 for linting and pytest for testing, and optimizes CI runs by detecting code vs. docs changes. * docs: Add sample email report and log files to dataset #31 (#39) Added sample_email_report_output.txt, sample_mail.log, and sample_sasl.log to docs/dataset for documentation and testing purposes. These files provide example outputs and logs for MailLogSentinel. * chore: add standardized PR templates for all contribution types (#35) (#42) * Add PR templates Introduces standardized pull request templates for bugfixes, code changes, documentation, CI/CD, and features in the .github/PR_TEMPLATES directory. These templates help ensure consistent and thorough PR descriptions, validation steps, and project checklists across different types of contributions. * Delete PULL_REQUEST_TEMPLATE.md #35 Deleted the .github/PULL_REQUEST_TEMPLATE.md file. This change removes the default template for new pull requests. * Revamp README with clearer setup and feature guide #32 (#43) * Revamp README with clearer setup and feature guide #32 The README has been rewritten for clarity and conciseness, featuring a new quick start guide, clearer prerequisites, simplified command references, and improved documentation links. The overview, installation, and usage instructions are now more accessible, and advanced features are summarized with direct links to the Wiki. The new format is more user-friendly for first-time users and contributors. * Update README links and formatting #32 Corrected documentation and sample output links, updated the contributing guide URL, and improved formatting for the closing quote in the README. * Fix relative link to sample email report in README #32 Updated the link to the daily email report example to use the correct relative path, ensuring the documentation points to the right file location. * ci: fix path filter for docs-only changes #44 (#45) Enhanced the GitHub Actions workflow to better distinguish between code and documentation changes using separate path filters for pull requests and pushes. Updated the lint job to use Python 3.11 and ruff instead of flake8, and improved dependency installation for both lint and test jobs. The workflow now supports a fast path for documentation-only changes, skipping unnecessary jobs. * Revise and expand contributing guidelines #33 (#46) * Revise and expand contributing guidelines #33 Updated CONTRIBUTING.md with clearer, more structured quick-start instructions and recommendations. Added a new CONTRIBUTING_DETAILED.md file providing comprehensive workflow, commit signing, quality standards, and contribution requirements to help contributors follow best practices. * Fix relative links in contributing docs #33 Updated relative paths in CONTRIBUTING.md and CONTRIBUTING_DETAILED.md to ensure links to detailed guidelines and discussions work correctly. Closes #33 * Revise and condense maillogsentinel man page #47 (#49) The man page for maillogsentinel was rewritten for clarity, brevity, and improved structure. Redundant and verbose sections were condensed, option descriptions were clarified, and auxiliary tool documentation was streamlined. The new version emphasizes practical usage, configuration, diagnostics, and security best practices, while removing excessive detail and outdated formatting. * Add manpages for ipinfo and log_anonymizer #48 (#50) Introduces manual pages for the ipinfo and log_anonymizer command-line tools, providing usage instructions, options, examples, and related information for system administrators. * Add comprehensive FAQ documentation (#51) Introduces a detailed FAQ (docs/wiki/FAQ.md) covering installation, configuration, usage, maintenance, integrations, troubleshooting, data analysis, security, and development for MailLogSentinel. This resource aims to assist users and contributors with common questions and operational guidance. * Update documentation links and add manual pages #52 (#53) Adjusted wiki links to use correct relative paths, added FAQ link, and included references to manual pages for maillogsentinel, ipinfo, and log_anonymizer in the README. * Create readable markdown versions of manpages #54 (#55) Introduces manual pages in Markdown format for the ipinfo, log_anonymizer, and maillogsentinel utilities. These documents provide usage instructions, options, configuration details, examples, and security considerations for each tool as part of the MailLogSentinel project. * Add Debian install guide for MailLogSentinel #21 (#56) Introduces a comprehensive installation and configuration guide for MailLogSentinel on Debian 12/13. The guide covers prerequisites, system preparation, installation steps, configuration, verification, service and timer setup, advanced options, troubleshooting, security, and additional resources. * Fix linting errors in CI workflow #58 (#59) * Fix linting errors in CI workflow #58 This commit fixes a number of linting errors that were causing the CI workflow to fail. The errors were primarily related to unused imports, f-strings without placeholders, and unused variables. * Refactor and clean up test code #58 Removed unused imports and variables in test_maillogsentinel_setup.py and test_sql_exporter.py. Updated test_run_sql_export_basic_flow to use context manager for patching datetime and simplified the mocking of the logger. These changes improve test clarity and maintainability. * Remove duplicate unittest.mock import #58 Consolidated the import of patch and MagicMock from unittest.mock to avoid redundancy in the test file. * Remove unused MagicMock import #58 Cleaned up the import statements by removing MagicMock, which was not used in the test file. * Fix Python 3.13 compatibility with pathlib #61 (#62) Refactored the SQL import/export functionality to use `importlib.resources.as_file` instead of the deprecated `pathlib.Path` context manager. This resolves a crash on Python 3.13, where `pathlib.Path` objects no longer support the context manager protocol. close #61 * Fix: SQL export reports success on failure #63 (#64) * Fix: SQL export reports success on failure #63 The --sql-export command was displaying a misleading success message even when data conversion errors occurred. This was because the `format_sql_value` function would log a warning and return NULL on conversion failure, but it did not propagate the error. This commit makes the data conversion stricter by raising an `SQLExportError` when a conversion for a NOT NULL column fails. The `run_sql_export` function now handles this exception, counts the errors, and returns `False` if any errors occurred. It also deletes the incomplete SQL file to avoid leaving invalid artifacts. This ensures that the SQL export process provides accurate feedback and only reports success when the export is actually successful. * Update SQL export tests #63 Tests now expect SQLExportError when None is provided for NOT NULL columns. Updated assertions to match new SQL statement formatting with quoted column names. This improves test accuracy and enforces stricter validation in SQL export logic. Closes #63 * Improve NULL handling for NOT NULL integer columns Fix #65 (#66) Refines the logic in format_sql_value to treat columns as nullable only if 'NOT NULL' is absent from the SQL type definition. Adds stricter validation to prevent empty strings from being converted to integers for NOT NULL columns, and introduces a new test case to verify data conversion failure for NOT NULL integer fields.
1 parent 96c45ec commit cb55b68

3 files changed

Lines changed: 152 additions & 159 deletions

File tree

lib/maillogsentinel/sql_exporter.py

Lines changed: 134 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import json
1212
import logging
1313
from pathlib import Path
14-
from typing import Optional, List, Dict, Any
14+
from typing import List, Dict, Any
1515
import importlib.resources # Added for loading bundled data
1616

1717
import tempfile
@@ -212,58 +212,54 @@ def format_sql_value(value: Any, sql_type_def: str) -> str:
212212
Returns:
213213
SQL-formatted string representation of the value.
214214
"""
215-
if (
216-
value is None
217-
or str(value).strip().lower() == "null"
218-
or str(value).strip() == ""
219-
):
220-
# Allow 'DEFAULT NULL' columns to receive NULL
221-
if (
222-
"DEFAULT NULL" in sql_type_def.upper()
223-
or "PRIMARY KEY" not in sql_type_def.upper()
224-
): # Quick check
215+
# A column is considered nullable if "NOT NULL" is absent from its definition.
216+
# The presence of "DEFAULT NULL" implies nullability, but the absence of "NOT NULL" is the key check.
217+
is_nullable = "NOT NULL" not in sql_type_def.upper()
218+
219+
if value is None or str(value).strip().lower() in ["null", "na", "n/a", ""]:
220+
if is_nullable:
225221
return "NULL"
226-
# If it's a NOT NULL column without default and value is empty/None, this is an issue
227-
# The calling code should ideally handle this by providing a default or raising error
228-
# For now, if it's a string type, represent as empty string, otherwise problem.
229-
if "CHAR" in sql_type_def.upper() or "TEXT" in sql_type_def.upper():
230-
return "''"
231-
# This will likely cause an SQL error if the column is NOT NULL
232-
# Consider raising an error here if value is None and column is NOT NULL without default
233-
logger.warning(
234-
f"{LOG_PREFIX}: Null/empty value encountered for a potentially NOT NULL column: {sql_type_def} (value: {value})"
235-
)
236-
return "NULL" # Or raise error
222+
else:
223+
# This is a critical issue: a null-like value for a NOT NULL column.
224+
raise SQLExportError(
225+
f"Null or empty value provided for a NOT NULL column. Column Def: '{sql_type_def}', Value: '{value}'"
226+
)
237227

238228
sql_type_lower = sql_type_def.lower()
239229

240230
if "int" in sql_type_lower or "serial" in sql_type_lower:
241231
try:
232+
# Ensure that empty strings or other non-numeric values are not converted to NULL for NOT NULL columns
233+
if str(value).strip() == "":
234+
raise ValueError("Empty string cannot be converted to integer.")
242235
return str(int(value))
243-
except ValueError:
244-
logger.warning(
245-
f"{LOG_PREFIX}: Could not convert '{value}' to int for SQL; using NULL. Column: {sql_type_def}"
246-
)
247-
return "NULL" # Or raise error
236+
except (ValueError, TypeError):
237+
if is_nullable:
238+
logger.warning(
239+
f"{LOG_PREFIX}: Could not convert '{value}' to int for SQL; using NULL. Column: {sql_type_def}"
240+
)
241+
return "NULL"
242+
else:
243+
raise SQLExportError(
244+
f"Failed to convert value '{value}' to integer for a NOT NULL column. Column Def: '{sql_type_def}'"
245+
)
248246
elif "datetime" in sql_type_lower or "timestamp" in sql_type_lower:
249-
# Assuming value is already in 'YYYY-MM-DD HH:MM:SS' format or a datetime object
250-
# For SQLite, it's typically a string.
251247
if isinstance(value, datetime.datetime):
252248
return f"'{value.strftime('%Y-%m-%d %H:%M:%S')}'"
253-
return escape_sql_string(str(value)) # Assuming pre-formatted string
249+
return escape_sql_string(str(value))
254250
elif (
255251
"char" in sql_type_lower or "text" in sql_type_lower or "enum" in sql_type_lower
256252
):
257253
return escape_sql_string(str(value))
258-
elif "bool" in sql_type_lower: # SQLite stores booleans as integers 0 or 1
254+
elif "bool" in sql_type_lower:
259255
return "1" if str(value).lower() in ["true", "1", "yes", "on"] else "0"
260-
else: # Default to string escaping for unknown types (e.g. IP, custom types)
256+
else:
261257
return escape_sql_string(str(value))
262258

263259

264260
def generate_insert_statement(
265261
row_dict: Dict[str, Any], table_name: str, column_mapping: Dict[str, Dict[str, str]]
266-
) -> Optional[str]:
262+
) -> str:
267263
"""
268264
Generates an SQL INSERT statement from a CSV row dictionary.
269265
@@ -273,21 +269,19 @@ def generate_insert_statement(
273269
column_mapping: The column mapping dictionary.
274270
275271
Returns:
276-
A string containing the SQL INSERT statement, or None if a row is skipped.
272+
A string containing the SQL INSERT statement.
273+
274+
Raises:
275+
SQLExportError: If data conversion fails for a required column.
277276
"""
278-
# Determine target SQL columns and their corresponding values from the row_dict
279277
sql_columns = []
280278
sql_values = []
281279

282-
valid_row = True
283280
for sql_col_name, mapping_info in column_mapping.items():
284281
csv_col_name = mapping_info.get("csv_column_name")
285282
sql_col_def = mapping_info.get("sql_column_def", "")
286283

287-
if sql_col_name == "id" and "AUTO_INCREMENT" in sql_col_def.upper():
288-
# Skip ID column if it's auto-incrementing; DB will handle it.
289-
# Alternatively, if CSV provides an ID, it should be used, and AUTO_INCREMENT removed from SQL def.
290-
# For now, assuming DB generates ID.
284+
if "AUTO_INCREMENT" in sql_col_def.upper() or "SERIAL" in sql_col_def.upper():
291285
continue
292286

293287
if not csv_col_name:
@@ -298,29 +292,20 @@ def generate_insert_statement(
298292

299293
raw_value = row_dict.get(csv_col_name)
300294

301-
# Basic validation: if column is NOT NULL and has no DEFAULT, raw_value must exist
302-
# This is a simplified check; actual NOT NULL check depends on precise SQL definition
303-
if (
304-
raw_value is None
305-
and "NOT NULL" in sql_col_def.upper()
306-
and "DEFAULT" not in sql_col_def.upper()
307-
and "AUTO_INCREMENT" not in sql_col_def.upper()
308-
):
309-
logger.error(
310-
f"{LOG_PREFIX}: Missing value for NOT NULL column '{sql_col_name}' (mapped from CSV '{csv_col_name}'). Row: {row_dict}. Skipping row."
311-
)
312-
valid_row = False
313-
break # Skip this row
314-
315-
formatted_value = format_sql_value(raw_value, sql_col_def)
316-
317-
sql_columns.append(sql_col_name)
318-
sql_values.append(formatted_value)
319-
320-
if not valid_row or not sql_columns:
321-
return None
322-
323-
columns_str = ", ".join(sql_columns)
295+
try:
296+
formatted_value = format_sql_value(raw_value, sql_col_def)
297+
sql_columns.append(sql_col_name)
298+
sql_values.append(formatted_value)
299+
except SQLExportError as e:
300+
# Re-raise with more context about the row being processed.
301+
raise SQLExportError(f"Error in row {row_dict}: {e}")
302+
303+
if not sql_columns:
304+
# This can happen if all columns are auto-incrementing, which is unlikely but possible.
305+
# Or if the mapping is empty.
306+
raise SQLExportError("No columns to insert for the given row.")
307+
308+
columns_str = ", ".join(f'"{c}"' for c in sql_columns)
324309
values_str = ", ".join(sql_values)
325310

326311
return f"INSERT INTO {table_name} ({columns_str}) VALUES ({values_str});"
@@ -396,9 +381,17 @@ def run_sql_export(config: AppConfig, output_log_level: str = "INFO") -> bool:
396381
f"{LOG_PREFIX}: No user-specific column mapping file configured, attempting to load bundled default."
397382
)
398383
try:
399-
with importlib.resources.files("lib.maillogsentinel.data").joinpath(
400-
"maillogsentinel_sql_column_mapping.json"
401-
) as bundled_path:
384+
# Correctly handle importlib.resources for Python 3.9+
385+
# The 'with' statement for pathlib.Path is removed in Python 3.13
386+
# We get a Traversable object, which we can convert to a Path
387+
bundled_path_traversable = importlib.resources.files(
388+
"lib.maillogsentinel.data"
389+
).joinpath("maillogsentinel_sql_column_mapping.json")
390+
391+
# For older importlib_resources, we might need to use 'as_file' context manager
392+
# but for modern Python, this direct conversion to Path is often sufficient
393+
# if the resource is a file on the filesystem.
394+
with importlib.resources.as_file(bundled_path_traversable) as bundled_path:
402395
if (
403396
not bundled_path.is_file()
404397
): # Should not happen if packaged correctly
@@ -557,38 +550,35 @@ def run_sql_export(config: AppConfig, output_log_level: str = "INFO") -> bool:
557550

558551
outfile.write("BEGIN TRANSACTION;\n")
559552

560-
for row in reader:
553+
conversion_errors = 0
554+
for row_num, row in enumerate(reader, start=1):
561555
records_processed += 1
562-
# Check for empty rows (e.g. just delimiters ;;;;)
563556
if not any(row.values()):
564557
logger.debug(
565-
f"{LOG_PREFIX}: Skipping empty or malformed row: {row}"
558+
f"{LOG_PREFIX}: Skipping empty or malformed row at line number (approx) {row_num}."
566559
)
567-
# Still need to update offset for this line
568-
# The DictReader handles line ending consumption.
569-
# To get the byte length of the line for offset:
570-
# This is tricky with DictReader. Simplest is to read line by line first,
571-
# then parse. For now, this part of offset update is imprecise with DictReader.
572-
# A more robust offset: re-open and read line-by-line to count bytes.
573-
# For now, new_offset will be updated at the end based on infile.tell().
574560
continue
575561

576562
try:
577-
# Ensure all expected keys are present in row, map to None if not
578-
# This is important if CSV is sparse or has missing optional fields
579-
# Handled by row_dict.get(csv_col_name) in generate_insert_statement
580563
insert_stmt = generate_insert_statement(
581564
row, table_name, column_mapping
582565
)
583-
if insert_stmt:
584-
outfile.write(insert_stmt + "\n")
585-
records_exported += 1
586-
except Exception as e:
566+
outfile.write(insert_stmt + "\n")
567+
records_exported += 1
568+
except SQLExportError as e:
587569
logger.error(
588-
f"{LOG_PREFIX}: Error processing row: {row}. Error: {e}",
570+
f"{LOG_PREFIX}: Failed to process row (approx line {row_num}). Reason: {e}"
571+
)
572+
conversion_errors += 1
573+
except Exception as e:
574+
logger.critical(
575+
f"{LOG_PREFIX}: A critical unexpected error occurred at row (approx line {row_num}): {row}. Aborting export. Error: {e}",
589576
exc_info=True,
590577
)
591-
# Decide if we skip this row or abort. For now, skip.
578+
# This is a more serious error than a simple conversion issue. We should abort.
579+
outfile.close()
580+
sql_file_path.unlink(missing_ok=True)
581+
return False
592582

593583
# After processing all available lines from the current offset
594584
new_offset = infile.tell() # Get the end position
@@ -628,35 +618,28 @@ def run_sql_export(config: AppConfig, output_log_level: str = "INFO") -> bool:
628618
if "outfile" in locals() and not outfile.closed:
629619
outfile.close()
630620

621+
if conversion_errors > 0:
622+
logger.error(
623+
f"{LOG_PREFIX}: SQL export completed with {conversion_errors} errors. "
624+
f"The generated SQL file '{sql_file_path}' is incomplete and will be deleted."
625+
)
626+
sql_file_path.unlink(missing_ok=True)
627+
# We still update the offset to avoid reprocessing failed rows,
628+
# but the overall operation is a failure.
629+
update_offset(offset_file_path, new_offset)
630+
return False
631+
631632
if records_exported == 0:
632-
if records_processed > 0:
633-
# Processed lines but exported nothing (e.g., all rows skipped due to errors/filters)
634-
logger.warning(
635-
f"{LOG_PREFIX}: Processed {records_processed} lines, but no records were actually exported. SQL file {sql_file_path} will contain only BEGIN/COMMIT. This might indicate data or mapping issues."
636-
)
637-
# Keep the file for inspection in this specific case.
638-
else:
639-
# No records exported AND no records processed (beyond header if it was the first run)
640-
# This includes:
641-
# 1. Truly empty CSV (already handled by returning after unlink if first_line is empty)
642-
# 2. Header-only CSV on first run (current_offset=0 initially, records_processed=0)
643-
# 3. Resume run with no new data lines (current_offset>0 initially, records_processed=0)
644-
logger.info(
645-
f"{LOG_PREFIX}: No records exported and no new data lines processed. Removing SQL export file: {sql_file_path}"
646-
)
647-
sql_file_path.unlink(missing_ok=True)
648-
else: # records_exported > 0
633+
logger.info(
634+
f"{LOG_PREFIX}: No new valid records to export. Removing empty SQL file."
635+
)
636+
sql_file_path.unlink(missing_ok=True)
637+
else:
649638
logger.info(
650639
f"{LOG_PREFIX}: Successfully created SQL export file: {sql_file_path} with {records_exported} records."
651640
)
652641

653-
# Update offset only if processing was generally successful or partially successful
654-
# If a critical error happened early (e.g. cant load mapping), offset should not change.
655-
# Current logic updates offset if we reach here.
656-
# If CSV was not found, we return False before this.
657-
# If mapping failed, we return False before this.
658642
update_offset(offset_file_path, new_offset)
659-
660643
logger.info(
661644
f"{LOG_PREFIX}: SQL export process complete. Final offset: {new_offset}"
662645
)
@@ -711,9 +694,10 @@ def _create_dummy_csv(
711694

712695
def _get_bundled_mapping_headers_for_test():
713696
try:
714-
with importlib.resources.files("lib.maillogsentinel.data").joinpath(
715-
"maillogsentinel_sql_column_mapping.json"
716-
) as bundled_path_ref:
697+
bundled_path_traversable = importlib.resources.files(
698+
"lib.maillogsentinel.data"
699+
).joinpath("maillogsentinel_sql_column_mapping.json")
700+
with importlib.resources.as_file(bundled_path_traversable) as bundled_path_ref:
717701
# The object returned by importlib.resources.files() is a Traversable
718702
# We need to ensure it's treated as a Path object for load_column_mapping
719703
mapping = load_column_mapping(Path(bundled_path_ref))
@@ -731,40 +715,37 @@ def _get_bundled_mapping_headers_for_test():
731715
return []
732716

733717

734-
DUMMY_CSV_HEADERS = _get_bundled_mapping_headers_for_test()
735-
if not DUMMY_CSV_HEADERS:
736-
DUMMY_CSV_HEADERS = [
737-
"server",
738-
"event_time",
739-
"ip",
740-
"username",
741-
"hostname",
742-
"reverse_dns_status",
743-
"country_code",
744-
"asn_number_placeholder",
745-
"asn_org_placeholder",
746-
]
747-
logger.warning(f"Using fallback DUMMY_CSV_HEADERS for testing: {DUMMY_CSV_HEADERS}")
718+
DUMMY_CSV_HEADERS = [
719+
"server",
720+
"date",
721+
"ip",
722+
"user",
723+
"hostname",
724+
"reverse_dns_status",
725+
"country_code",
726+
"asn",
727+
"aso",
728+
]
748729

749730

750731
def _make_dummy_csv_data_row(custom_headers_order, values_dict):
751732
"""Helper to create a CSV data row based on DUMMY_CSV_HEADERS global order."""
752733
row = []
753-
for header in DUMMY_CSV_HEADERS: # Ensure consistent order
734+
for header in custom_headers_order: # Use the provided header order
754735
row.append(values_dict.get(header, f"dummy_{header}"))
755736
return row
756737

757738

758739
DUMMY_CSV_DATA_ROW_1_VALS = {
759740
"server": "mail.example.com",
760-
"event_time": "2023-10-26 10:00:00",
741+
"date": "2023-10-26 10:00:00",
761742
"ip": "192.168.1.100",
762-
"username": "testuser",
743+
"user": "testuser",
763744
"hostname": "client.example.org",
764745
"reverse_dns_status": "OK",
765746
"country_code": "US",
766-
"asn_number_placeholder": "12345",
767-
"asn_org_placeholder": "AS-EXAMPLE Example ISP",
747+
"asn": "12345",
748+
"aso": "AS-EXAMPLE Example ISP",
768749
}
769750
DUMMY_CSV_DATA_ROW_1 = _make_dummy_csv_data_row(
770751
DUMMY_CSV_HEADERS, DUMMY_CSV_DATA_ROW_1_VALS
@@ -916,6 +897,29 @@ def _reset_offset_file(config_obj: DummyTestConfig):
916897
)
917898
all_tests_passed = False
918899

900+
# --- Test Case 5: Data conversion failure for NOT NULL integer ---
901+
test_runner_logger.info(
902+
"\n--- Test Case 5: Data conversion failure for NOT NULL integer ---"
903+
)
904+
config_case5 = DummyTestConfig(base_dir_name="maillog_test_case5_")
905+
test_configs_to_clean.append(config_case5)
906+
# Create a CSV with an empty string for 'asn', which maps to 'asn_int' (NOT NULL)
907+
invalid_row_vals = DUMMY_CSV_DATA_ROW_1_VALS.copy()
908+
invalid_row_vals["asn"] = "" # This should fail conversion for a NOT NULL int
909+
invalid_row_data = [_make_dummy_csv_data_row(DUMMY_CSV_HEADERS, invalid_row_vals)]
910+
_create_dummy_csv(config_case5, DUMMY_CSV_HEADERS, invalid_row_data)
911+
_reset_offset_file(config_case5)
912+
success_case5 = run_sql_export(config_case5)
913+
if not success_case5:
914+
test_runner_logger.info(
915+
"Test Case 5 Result (Bad Data for NOT NULL Int): SUCCESS (aborted as expected)"
916+
)
917+
else:
918+
test_runner_logger.error(
919+
"Test Case 5 Result (Bad Data for NOT NULL Int): FAIL (should have aborted)"
920+
)
921+
all_tests_passed = False
922+
919923
except Exception as e:
920924
test_runner_logger.error(
921925
f"An unexpected error occurred during testing: {e}", exc_info=True

0 commit comments

Comments
 (0)