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] = { 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 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 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"