"""Shared error-formatting helpers. These keep error messages uniform across modules: same "what failed, where, and what to try next" structure regardless of which layer raises. Public CLIs / GUIs can rely on the message format being consistent enough to surface to end users without further wrapping. Usage patterns: raise DataToolsError( "Could not read input file", path=path, suggestion="Check that the file exists and is readable.", ) # Wrapping a library error: try: wb = load_workbook(path) except (BadZipFile, InvalidFileException) as e: raise FileFormatError( "Excel file is corrupted or not a valid .xlsx", path=path, cause=e, ) from e """ from __future__ import annotations from pathlib import Path from typing import Any, Iterable, Optional class DataToolsError(Exception): """Base class for all DataTools-raised errors. Carries optional structured fields so GUIs / logs can render them consistently rather than re-parsing free-form messages. """ def __init__( self, message: str, *, path: Optional[Path | str] = None, column: Optional[str] = None, operation: Optional[str] = None, suggestion: Optional[str] = None, cause: Optional[BaseException] = None, ): self.message = message self.path = Path(path) if path is not None else None self.column = column self.operation = operation self.suggestion = suggestion self.cause = cause super().__init__(self.format()) def format(self) -> str: """Render a human-friendly multi-line message.""" lines = [self.message] if self.operation: lines.append(f" while: {self.operation}") if self.path: lines.append(f" file: {self.path}") if self.column: lines.append(f" column: {self.column!r}") if self.cause: lines.append(f" underlying: {type(self.cause).__name__}: {self.cause}") if self.suggestion: lines.append(f" suggestion: {self.suggestion}") return "\n".join(lines) class InputValidationError(DataToolsError, ValueError): """Caller passed a bad argument — e.g., non-DataFrame, bad enum value.""" class ConfigError(DataToolsError, ValueError): """Configuration file or options object is invalid.""" class FileFormatError(DataToolsError, ValueError): """File exists but is not in the expected format (corrupted, wrong schema).""" class FileAccessError(DataToolsError, OSError): """File could not be read or written — permissions, missing parent, full disk.""" # --------------------------------------------------------------------------- # Convenience constructors # --------------------------------------------------------------------------- def ensure_dataframe(value: Any, *, function: str, parameter: str = "df") -> None: """Raise InputValidationError if *value* isn't a pandas DataFrame. Centralizes the repetitive guard so every public entry point gives the same message shape. """ import pandas as pd # lazy — keeps this module dependency-light if not isinstance(value, pd.DataFrame): raise InputValidationError( f"{function}() requires a pandas DataFrame for {parameter!r}", operation=function, suggestion=( f"Got {type(value).__name__}. " "Pass a DataFrame loaded via src.core.io.read_file() " "or constructed with pd.DataFrame(...)." ), ) def ensure_choice( value: Any, *, name: str, choices: Iterable[Any], function: Optional[str] = None, ) -> None: """Raise InputValidationError if *value* isn't in *choices*.""" choices = list(choices) if value in choices: return raise InputValidationError( f"Invalid {name}={value!r}", operation=function, suggestion=f"Valid: {sorted(map(str, choices))}", ) def wrap_file_read(path: Path | str, operation: str, exc: BaseException) -> FileAccessError: """Build a FileAccessError describing a read failure with helpful context.""" return FileAccessError( f"Could not read file ({type(exc).__name__})", path=path, operation=operation, cause=exc, suggestion=( "Check that the file exists, you have read permission, and the " "path isn't on a network mount that may have disconnected." ), ) def wrap_file_write(path: Path | str, operation: str, exc: BaseException) -> FileAccessError: """Build a FileAccessError describing a write failure with helpful context.""" suggestion = ( "Check that the parent directory exists, you have write permission, " "and there is enough free disk space." ) if isinstance(exc, PermissionError): suggestion = ( "Check write permissions on the parent directory. " "On Windows, also ensure the file is not open in another program." ) return FileAccessError( f"Could not write file ({type(exc).__name__})", path=path, operation=operation, cause=exc, suggestion=suggestion, ) # --------------------------------------------------------------------------- # Friendly formatter for end-user surfaces (CLI stderr, GUI st.error) # --------------------------------------------------------------------------- def format_for_user(exc: BaseException, *, context: Optional[str] = None) -> str: """Render an exception for end-user display. Recognizes :class:`DataToolsError` and uses its structured fields; falls back to a generic message + class name for unrecognized exceptions. ``context`` is an optional one-line prefix describing what the user was trying to do (e.g., ``"Failed to read upload"``). """ if isinstance(exc, DataToolsError): body = exc.format() else: body = f"{type(exc).__name__}: {exc}" if context: return f"{context}\n\n{body}" return body