From 6ca54b49049de7f52bf2c8741a180a50878ed936 Mon Sep 17 00:00:00 2001 From: AliiiBenn Date: Fri, 23 Jan 2026 14:57:48 +0100 Subject: [PATCH 1/4] feat(exceptions): add custom exception hierarchy for better error handling Implement structured exception hierarchy for excel-to-sql: - ExcelToSqlError (base) - All custom exceptions inherit from this - ExcelFileError - Excel file operation failures - ConfigurationError - Configuration issues - ValidationError - Data validation failures - DatabaseError - Database operation failures Features: - Context dictionary for additional error information - to_dict() method for serialization - Rich string representation with context details This enables better error handling, debugging, and user-friendly error messages throughout the application. Co-Authored-By: Claude Sonnet 4.5 --- excel_to_sql/exceptions.py | 253 +++++++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 excel_to_sql/exceptions.py diff --git a/excel_to_sql/exceptions.py b/excel_to_sql/exceptions.py new file mode 100644 index 0000000..d0efb16 --- /dev/null +++ b/excel_to_sql/exceptions.py @@ -0,0 +1,253 @@ +""" +Custom exception hierarchy for excel-to-sql. + +This module defines a structured exception hierarchy for better error handling +and user-friendly error messages throughout the excel-to-sql application. + +Exception Hierarchy: + ExcelToSqlError (base) + ├── ExcelFileError (Excel file operations) + ├── ConfigurationError (Configuration issues) + ├── ValidationError (Data validation failures) + └── DatabaseError (Database operation failures) +""" + +from __future__ import annotations + + +class ExcelToSqlError(Exception): + """ + Base exception for all excel-to-sql errors. + + All custom exceptions inherit from this class, allowing for easy + catching of any excel-to-sql specific error. + + Example: + >>> try: + ... # some excel-to-sql operation + ... except ExcelToSqlError as e: + ... print(f"excel-to-sql error: {e}") + """ + + def __init__(self, message: str, *, context: dict[str, str] | None = None) -> None: + """ + Initialize an excel-to-sql error. + + Args: + message: Human-readable error message + context: Optional dictionary with additional context (file_name, operation, etc.) + """ + super().__init__(message) + self.context = context or {} + self.message = message + + def __str__(self) -> str: + """Return string representation with context if available.""" + if self.context: + context_str = ", ".join(f"{k}={v}" for k, v in self.context.items()) + return f"{self.message} ({context_str})" + return self.message + + def to_dict(self) -> dict[str, str]: + """Convert exception to dictionary for serialization.""" + return { + "type": self.__class__.__name__, + "message": self.message, + "context": self.context, + } + + +class ExcelFileError(ExcelToSqlError): + """ + Raised when Excel file operations fail. + + This exception is used for errors related to reading, writing, or + processing Excel files. + + Attributes: + file_path: Path to the Excel file that caused the error + operation: The operation being performed (read, write, validate, etc.) + + Example: + >>> raise ExcelFileError("Failed to read Excel file", file_path="data.xlsx", operation="read") + """ + + def __init__( + self, + message: str, + *, + file_path: str | None = None, + operation: str | None = None, + **kwargs + ) -> None: + """ + Initialize an Excel file error. + + Args: + message: Human-readable error message + file_path: Path to the Excel file + operation: The operation being performed + **kwargs: Additional context + """ + context = {"file_path": str(file_path)} if file_path else {} + if operation: + context["operation"] = operation + context.update(kwargs) + + super().__init__(message, context=context) + self.file_path = file_path + self.operation = operation + + +class ConfigurationError(ExcelToSqlError): + """ + Raised when configuration is invalid, missing, or malformed. + + This exception covers errors related to project configuration, mapping files, + and other configuration-related issues. + + Attributes: + config_file: Path to the configuration file (if applicable) + config_key: The configuration key that caused the error (if applicable) + + Example: + >>> raise ConfigurationError("Missing required field: primary_key", config_key="primary_key") + """ + + def __init__( + self, + message: str, + *, + config_file: str | None = None, + config_key: str | None = None, + **kwargs + ) -> None: + """ + Initialize a configuration error. + + Args: + message: Human-readable error message + config_file: Path to the configuration file + config_key: The configuration key that caused the error + **kwargs: Additional context + """ + context = {} + if config_file: + context["config_file"] = config_file + if config_key: + context["config_key"] = config_key + context.update(kwargs) + + super().__init__(message, context=context) + self.config_file = config_file + self.config_key = config_key + + +class ValidationError(ExcelToSqlError): + """ + Raised when data validation fails. + + This exception is used when data fails validation checks, such as + required field validation, type validation, or custom validation rules. + + Attributes: + field: The field that failed validation + value: The value that failed validation + rule: The validation rule that was violated + + Example: + >>> raise ValidationError( + ... "Email is required", + ... field="email", + ... value=None, + ... rule="required" + ... ) + """ + + def __init__( + self, + message: str, + *, + field: str | None = None, + value: str | None = None, + rule: str | None = None, + **kwargs + ) -> None: + """ + Initialize a validation error. + + Args: + message: Human-readable error message + field: The field that failed validation + value: The value that failed validation + rule: The validation rule that was violated + **kwargs: Additional context + """ + context = {} + if field: + context["field"] = field + if value is not None: + context["value"] = str(value) + if rule: + context["rule"] = rule + context.update(kwargs) + + super().__init__(message, context=context) + self.field = field + self.value = value + self.rule = rule + + +class DatabaseError(ExcelToSqlError): + """ + Raised when database operations fail. + + This exception covers errors related to database connections, queries, + transactions, and other database-related issues. + + Attributes: + table: The database table involved (if applicable) + operation: The database operation being performed + sql_error: The underlying database error message + + Example: + >>> raise DatabaseError( + ... "Failed to insert row", + ... table="products", + ... operation="insert", + ... sql_error="UNIQUE constraint failed" + ... ) + """ + + def __init__( + self, + message: str, + *, + table: str | None = None, + operation: str | None = None, + sql_error: str | None = None, + **kwargs + ) -> None: + """ + Initialize a database error. + + Args: + message: Human-readable error message + table: The database table involved + operation: The database operation being performed + sql_error: The underlying database error message + **kwargs: Additional context + """ + context = {} + if table: + context["table"] = table + if operation: + context["operation"] = operation + if sql_error: + context["sql_error"] = sql_error + context.update(kwargs) + + super().__init__(message, context=context) + self.table = table + self.operation = operation + self.sql_error = sql_error From 8beaeefbda06ee2f6b1ffdfebffdfaf9a9a06ae1 Mon Sep 17 00:00:00 2001 From: AliiiBenn Date: Fri, 23 Jan 2026 14:57:57 +0100 Subject: [PATCH 2/4] feat(entities): use ExcelFileError in ExcelFile entity Update ExcelFile class to throw custom ExcelFileError instead of generic ValueError for better error handling: - read() - Throws ExcelFileError with file_path and operation context - read_all_sheets() - Specific error handling for empty/invalid files - read_sheets() - Wraps errors with ExcelFileError Improvements: - Distinguish between EmptyDataError (empty file) and ParserError (invalid format) - Include context (file_path, operation) for debugging - Preserve FileNotFoundError and PermissionError as-is - Chain original exceptions for full traceback This allows CLI to provide specific error messages and tips for common Excel file errors. Co-Authored-By: Claude Sonnet 4.5 --- excel_to_sql/entities/excel_file.py | 52 +++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/excel_to_sql/entities/excel_file.py b/excel_to_sql/entities/excel_file.py index 850f9d5..eace7f6 100644 --- a/excel_to_sql/entities/excel_file.py +++ b/excel_to_sql/entities/excel_file.py @@ -10,6 +10,8 @@ import pandas as pd import hashlib +from excel_to_sql.exceptions import ExcelFileError + class ExcelFile: """ @@ -103,8 +105,27 @@ def read( return pd.read_excel(self._path, sheet_name=actual_sheet, header=header_row, engine="openpyxl") return pd.read_excel(self._path, sheet_name=actual_sheet, header=header, engine="openpyxl") + except (FileNotFoundError, PermissionError): + # Re-raise filesystem errors as-is + raise + except pd.errors.EmptyDataError as e: + raise ExcelFileError( + f"Excel file is empty: {self._path.name}", + file_path=str(self._path), + operation="read" + ) from e + except pd.errors.ParserError as e: + raise ExcelFileError( + f"Invalid Excel file format: {self._path.name}", + file_path=str(self._path), + operation="read" + ) from e except Exception as e: - raise ValueError(f"Failed to read Excel file: {e}") from e + raise ExcelFileError( + f"Failed to read Excel file: {self._path.name}", + file_path=str(self._path), + operation="read" + ) from e def read_all_sheets(self) -> Dict[str, pd.DataFrame]: """ @@ -124,8 +145,26 @@ def read_all_sheets(self) -> Dict[str, pd.DataFrame]: try: return pd.read_excel(self._path, sheet_name=None, engine="openpyxl") + except (FileNotFoundError, PermissionError): + raise + except pd.errors.EmptyDataError as e: + raise ExcelFileError( + f"Excel file is empty: {self._path.name}", + file_path=str(self._path), + operation="read_all_sheets" + ) from e + except pd.errors.ParserError as e: + raise ExcelFileError( + f"Invalid Excel file format: {self._path.name}", + file_path=str(self._path), + operation="read_all_sheets" + ) from e except Exception as e: - raise ValueError(f"Failed to read Excel file: {e}") from e + raise ExcelFileError( + f"Failed to read Excel file: {self._path.name}", + file_path=str(self._path), + operation="read_all_sheets" + ) from e def read_sheets(self, sheet_names: List[str]) -> Dict[str, pd.DataFrame]: """ @@ -146,8 +185,15 @@ def read_sheets(self, sheet_names: List[str]) -> Dict[str, pd.DataFrame]: for sheet_name in sheet_names: try: result[sheet_name] = self.read(sheet_name) + except ExcelFileError: + # Re-raise ExcelFileError as-is + raise except Exception as e: - raise ValueError(f"Failed to read sheet '{sheet_name}': {e}") from e + raise ExcelFileError( + f"Failed to read sheet: {sheet_name}", + file_path=str(self._path), + operation="read_sheets" + ) from e return result From 878fb2640f56030b23ec5e5072291144fe935b37 Mon Sep 17 00:00:00 2001 From: AliiiBenn Date: Fri, 23 Jan 2026 14:58:06 +0100 Subject: [PATCH 3/4] feat(cli): improve error handling with specific exceptions and actionable tips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace generic exception handlers with specific exception types throughout the CLI: Import Command: - FileNotFoundError → "File not found" + tip to check path - PermissionError → "Permission denied" + tip to check permissions - EmptyDataError → "Empty Excel file" + tip to add data - ParserError → "Invalid Excel format" + tip to check file type - ConfigurationError → Config error + tip to check config files - ValidationError → Validation error with details - DatabaseError → Database error with context Export Command: - FileNotFoundError → "Table not found" + tip to import first - PermissionError → "Permission denied" + tip to check write access - DatabaseError → Database error with context Magic Command: - Improved error messages for file/sheet processing - Better exception handling in interactive mode quality reports - Replaced bare except: block with specific (AttributeError, TypeError) Status Command: - ConfigurationError for config-related failures Additional: - Added logger for unexpected errors - All error messages follow consistent format with tips - Debug mode shows full traceback on unexpected errors Fixes #35 Co-Authored-By: Claude Sonnet 4.5 --- excel_to_sql/cli.py | 126 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 118 insertions(+), 8 deletions(-) diff --git a/excel_to_sql/cli.py b/excel_to_sql/cli.py index 94970e4..78c866f 100644 --- a/excel_to_sql/cli.py +++ b/excel_to_sql/cli.py @@ -7,11 +7,19 @@ from rich.console import Console from rich.table import Table import pandas as pd +import logging from excel_to_sql.entities.project import Project from excel_to_sql.entities.excel_file import ExcelFile from excel_to_sql.entities.dataframe import DataFrame from excel_to_sql.__version__ import __version__ +from excel_to_sql.exceptions import ( + ExcelToSqlError, + ExcelFileError, + ConfigurationError, + ValidationError, + DatabaseError, +) app = Typer( name="excel-to-sql", @@ -21,6 +29,7 @@ ) console = Console() +logger = logging.getLogger(__name__) # ────────────────────────────────────────────────────────────── @@ -187,10 +196,42 @@ def import_cmd( except FileNotFoundError: console.print(f"[red]Error:[/red] File not found: {excel_path}") + console.print("[dim]Tip: Check the file path and try again[/dim]") raise Exit(1) - except ValueError as e: - console.print(f"[red]Error:[/red] {e}") + except PermissionError: + console.print(f"[red]Error:[/red] Permission denied: {excel_path}") + console.print("[dim]Tip: Check file permissions or run with appropriate access[/dim]") + raise Exit(1) + + except pd.errors.EmptyDataError: + console.print(f"[red]Error:[/red] Excel file is empty: {excel_path}") + console.print("[dim]Tip: Ensure the file contains data in the first sheet[/dim]") + raise Exit(1) + + except pd.errors.ParserError as e: + console.print(f"[red]Error:[/red] Invalid Excel file format: {excel_path}") + console.print(f"[dim]Details: {e}[/dim]") + console.print("[dim]Tip: Ensure the file is a valid .xlsx or .xls file[/dim]") + raise Exit(1) + + except ConfigurationError as e: + console.print(f"[red]Error:[/red] Configuration error: {e.message}") + if e.context: + console.print(f"[dim]Context: {e.context}[/dim]") + console.print("[dim]Tip: Check your configuration files or run 'excel-to-sql init'[/dim]") + raise Exit(1) + + except ValidationError as e: + console.print(f"[red]Error:[/red] Validation error: {e.message}") + if e.context: + console.print(f"[dim]Details: {e.context}[/dim]") + raise Exit(1) + + except DatabaseError as e: + console.print(f"[red]Error:[/red] Database error: {e.message}") + if e.context: + console.print(f"[dim]Context: {e.context}[/dim]") raise Exit(1) except Exit: @@ -198,10 +239,14 @@ def import_cmd( raise except Exception as e: - console.print(f"[red]Error:[/red] Import failed") - console.print(f" {e}") + # Log unexpected errors + logger.exception(f"Unexpected error importing {excel_path}") + console.print("[red]Error:[/red] An unexpected error occurred during import") + console.print(f"[dim]Details: {e}[/dim]") if "--debug" in sys.argv: console.print(traceback.format_exc()) + else: + console.print("[dim]Use --debug for more information[/dim]") raise Exit(1) @@ -292,8 +337,9 @@ def export_cmd( try: if len(str(cell.value)) > max_length: max_length = len(str(cell.value)) - except: - pass + except (AttributeError, TypeError): + # Cell value is None or has unexpected type, skip it + continue adjusted_width = min(max_length + 2, 50) # Cap at 50 worksheet.column_dimensions[column_letter].width = adjusted_width @@ -335,11 +381,32 @@ def export_cmd( console.print(summary_table) + except FileNotFoundError: + console.print(f"[red]Error:[/red] Table not found in database") + if table: + console.print(f"[dim]Table: {table}[/dim]") + console.print("[dim]Tip: Check the table name or import data first[/dim]") + raise Exit(1) + + except PermissionError: + console.print(f"[red]Error:[/red] Permission denied: {output}") + console.print("[dim]Tip: Check write permissions for the output directory[/dim]") + raise Exit(1) + + except DatabaseError as e: + console.print(f"[red]Error:[/red] Database error: {e.message}") + if e.context: + console.print(f"[dim]Context: {e.context}[/dim]") + raise Exit(1) + except Exit: raise + except Exception as e: - console.print(f"[red]Error:[/red] Export failed") - console.print(f"[dim]{e}[/dim]") + logger.exception(f"Unexpected error during export to {output}") + console.print("[red]Error:[/red] An unexpected error occurred during export") + console.print(f"[dim]Details: {e}[/dim]") + console.print("[dim]Use --debug for more information[/dim]") raise Exit(1) @@ -354,6 +421,10 @@ def status() -> None: try: # Load project project = Project.from_current_directory() + except ConfigurationError as e: + console.print(f"[red]Error:[/red] Configuration error: {e.message}") + console.print("[dim]Tip: Run 'excel-to-sql init' to initialize[/dim]") + raise Exit(1) except Exception: console.print("[red]Error:[/red] Not an excel-to-sql project") console.print("[dim]Run 'excel-to-sql init' to initialize[/dim]") @@ -552,10 +623,28 @@ def magic( "column_count": len(df.columns), } + except FileNotFoundError: + console.print(f" [red]Error:[/red] File not found: {sheet_name}") + except PermissionError: + console.print(f" [red]Error:[/red] Permission denied: {sheet_name}") + except pd.errors.EmptyDataError: + console.print(f" [yellow]Warning:[/yellow] Empty sheet: {sheet_name}") + except pd.errors.ParserError as e: + console.print(f" [red]Error analyzing {sheet_name}:[/red] Invalid Excel format") + except ExcelFileError as e: + console.print(f" [red]Error analyzing {sheet_name}:[/red] {e.message}") except Exception as e: + logger.warning(f"Unexpected error analyzing {sheet_name}: {e}") console.print(f" [red]Error analyzing {sheet_name}:[/red] {e}") + except FileNotFoundError: + console.print(f"[red]Error:[/red] File not found: {excel_file.name}") + except PermissionError: + console.print(f"[red]Error:[/red] Permission denied: {excel_file.name}") + except ExcelFileError as e: + console.print(f"[red]Error processing {excel_file.name}:[/red] {e.message}") except Exception as e: + logger.warning(f"Unexpected error processing {excel_file.name}: {e}") console.print(f"[red]Error processing {excel_file.name}:[/red] {e}") # Interactive mode @@ -580,6 +669,27 @@ def magic( df = header_detector.read_excel_with_header_detection(result["file"], result["sheet"]) quality_report = scorer.generate_quality_report(df, table_name) quality_dict[table_name] = quality_report + except FileNotFoundError: + # Default quality report if file not found + quality_dict[table_name] = { + "score": 0, + "grade": "F", + "issues": ["File not found"] + } + except PermissionError: + # Default quality report if permission denied + quality_dict[table_name] = { + "score": 0, + "grade": "F", + "issues": ["Permission denied"] + } + except ExcelFileError: + # Default quality report if analysis fails + quality_dict[table_name] = { + "score": 50, + "grade": "C", + "issues": ["Excel file error"] + } except Exception: # Default quality report if analysis fails quality_dict[table_name] = { From cc24e7d955d4f79921db97af53623905a36aff4a Mon Sep 17 00:00:00 2001 From: AliiiBenn Date: Fri, 23 Jan 2026 14:58:14 +0100 Subject: [PATCH 4/4] test(exceptions): add comprehensive test suite for custom exceptions Add 25 tests covering the custom exception hierarchy: ExcelToSqlError (base): - Base exception creation with and without context - to_dict() serialization ExcelFileError: - Creation with file_path and operation - Context dictionary inclusion - to_dict() serialization ConfigurationError: - Creation with config_file and config_key - Full context handling ValidationError: - Creation with field, value, and rule - Full context handling DatabaseError: - Creation with table, operation, and sql_error - Full context handling Exception Hierarchy: - All exceptions inherit from ExcelToSqlError - Base exception catches all custom types - Specific exception types can be caught individually - Exception chaining preserves original traceback All tests pass (25/25). Co-Authored-By: Claude Sonnet 4.5 --- tests/test_exceptions.py | 289 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 tests/test_exceptions.py diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..5eacfb4 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,289 @@ +""" +Tests for custom exception classes. +""" + +import pytest + +from excel_to_sql.exceptions import ( + ExcelToSqlError, + ExcelFileError, + ConfigurationError, + ValidationError, + DatabaseError, +) + + +class TestExcelToSqlError: + """Tests for base ExcelToSqlError exception.""" + + def test_base_exception_creation(self): + """Test creating base exception.""" + error = ExcelToSqlError("Test error") + assert str(error) == "Test error" + assert error.message == "Test error" + assert error.context == {} + + def test_base_exception_with_context(self): + """Test creating base exception with context.""" + error = ExcelToSqlError("Test error", context={"key": "value"}) + assert "key=value" in str(error) + assert error.context == {"key": "value"} + + def test_base_exception_to_dict(self): + """Test converting exception to dictionary.""" + error = ExcelToSqlError("Test error", context={"key": "value"}) + result = error.to_dict() + assert result["type"] == "ExcelToSqlError" + assert result["message"] == "Test error" + assert result["context"] == {"key": "value"} + + +class TestExcelFileError: + """Tests for ExcelFileError exception.""" + + def test_file_error_creation(self): + """Test creating Excel file error.""" + error = ExcelFileError("Failed to read") + assert "Failed to read" in str(error) + + def test_file_error_with_file_path(self): + """Test Excel file error with file path.""" + error = ExcelFileError( + "Read failed", + file_path="test.xlsx", + operation="read" + ) + assert error.file_path == "test.xlsx" + assert error.operation == "read" + assert "file_path=test.xlsx" in str(error) + assert "operation=read" in str(error) + + def test_file_error_context(self): + """Test Excel file error includes context.""" + error = ExcelFileError("Read failed", file_path="data.xlsx") + assert error.context == {"file_path": "data.xlsx"} + + def test_file_error_to_dict(self): + """Test converting ExcelFileError to dictionary.""" + error = ExcelFileError( + "Read failed", + file_path="test.xlsx", + operation="read" + ) + result = error.to_dict() + assert result["type"] == "ExcelFileError" + assert result["message"] == "Read failed" + assert result["context"]["file_path"] == "test.xlsx" + + +class TestConfigurationError: + """Tests for ConfigurationError exception.""" + + def test_config_error_creation(self): + """Test creating configuration error.""" + error = ConfigurationError("Invalid config") + assert "Invalid config" in str(error) + + def test_config_error_with_config_file(self): + """Test configuration error with config file.""" + error = ConfigurationError( + "Config not found", + config_file="mappings.json" + ) + assert error.config_file == "mappings.json" + assert "config_file=mappings.json" in str(error) + + def test_config_error_with_config_key(self): + """Test configuration error with config key.""" + error = ConfigurationError( + "Missing field", + config_key="primary_key" + ) + assert error.config_key == "primary_key" + assert "config_key=primary_key" in str(error) + + def test_config_error_full_context(self): + """Test configuration error with both file and key.""" + error = ConfigurationError( + "Missing field", + config_file="mappings.json", + config_key="primary_key" + ) + assert error.config_file == "mappings.json" + assert error.config_key == "primary_key" + assert "config_file=mappings.json" in str(error) + assert "config_key=primary_key" in str(error) + + +class TestValidationError: + """Tests for ValidationError exception.""" + + def test_validation_error_creation(self): + """Test creating validation error.""" + error = ValidationError("Validation failed") + assert "Validation failed" in str(error) + + def test_validation_error_with_field(self): + """Test validation error with field name.""" + error = ValidationError( + "Required field", + field="email" + ) + assert error.field == "email" + assert "field=email" in str(error) + + def test_validation_error_with_value(self): + """Test validation error with value.""" + error = ValidationError( + "Invalid value", + field="age", + value="invalid" + ) + assert error.field == "age" + assert error.value == "invalid" + assert "value=invalid" in str(error) + + def test_validation_error_with_rule(self): + """Test validation error with rule.""" + error = ValidationError( + "Rule violated", + field="email", + rule="required" + ) + assert error.rule == "required" + assert "rule=required" in str(error) + + def test_validation_error_full_context(self): + """Test validation error with all context.""" + error = ValidationError( + "Email is required", + field="email", + value=None, + rule="required" + ) + assert error.field == "email" + assert error.value is None + assert error.rule == "required" + + +class TestDatabaseError: + """Tests for DatabaseError exception.""" + + def test_database_error_creation(self): + """Test creating database error.""" + error = DatabaseError("Query failed") + assert "Query failed" in str(error) + + def test_database_error_with_table(self): + """Test database error with table name.""" + error = DatabaseError( + "Table not found", + table="products" + ) + assert error.table == "products" + assert "table=products" in str(error) + + def test_database_error_with_operation(self): + """Test database error with operation.""" + error = DatabaseError( + "Insert failed", + table="products", + operation="insert" + ) + assert error.table == "products" + assert error.operation == "insert" + assert "operation=insert" in str(error) + + def test_database_error_with_sql_error(self): + """Test database error with SQL error.""" + error = DatabaseError( + "Query failed", + table="products", + sql_error="UNIQUE constraint failed" + ) + assert error.sql_error == "UNIQUE constraint failed" + assert "sql_error=UNIQUE constraint failed" in str(error) + + def test_database_error_full_context(self): + """Test database error with all context.""" + error = DatabaseError( + "Insert failed", + table="products", + operation="insert", + sql_error="UNIQUE constraint failed: products.id" + ) + assert error.table == "products" + assert error.operation == "insert" + assert error.sql_error == "UNIQUE constraint failed: products.id" + assert "table=products" in str(error) + assert "operation=insert" in str(error) + + +class TestExceptionHierarchy: + """Tests for exception inheritance.""" + + def test_all_exceptions_inherit_from_base(self): + """Test that all custom exceptions inherit from ExcelToSqlError.""" + errors = [ + ExcelFileError("test"), + ConfigurationError("test"), + ValidationError("test"), + DatabaseError("test"), + ] + + for error in errors: + assert isinstance(error, ExcelToSqlError) + assert isinstance(error, Exception) + + def test_catch_base_exception(self): + """Test catching base exception catches all custom exceptions.""" + caught = [] + + try: + raise ExcelFileError("File error") + except ExcelToSqlError as e: + caught.append("file_error") + + try: + raise ConfigurationError("Config error") + except ExcelToSqlError as e: + caught.append("config_error") + + try: + raise ValidationError("Validation error") + except ExcelToSqlError as e: + caught.append("validation_error") + + try: + raise DatabaseError("Database error") + except ExcelToSqlError as e: + caught.append("database_error") + + assert len(caught) == 4 + + def test_specific_exception_catch(self): + """Test catching specific exception types.""" + caught = [] + + try: + raise ExcelFileError("File error") + except ExcelFileError: + caught.append("file") + + try: + raise ConfigurationError("Config error") + except ConfigurationError: + caught.append("config") + + assert len(caught) == 2 + + def test_exception_chaining(self): + """Test exception chaining preserves original traceback.""" + try: + try: + raise ValueError("Original error") + except ValueError as e: + raise ExcelFileError("Wrapped error") from e + except ExcelFileError as exc: + assert exc.__cause__ is not None + assert str(exc.__cause__) == "Original error"