420 lines
15 KiB
Markdown
420 lines
15 KiB
Markdown
# Exception Handling in Python CLI Applications with Typer
|
|
|
|
## The Problem: Exception Chain Explosion
|
|
|
|
AI-generated code commonly creates a catastrophic anti-pattern where every function catches and re-wraps exceptions, creating massive exception chains (200+ lines of output) for simple errors like "file not found".
|
|
|
|
**Example of the problem:**
|
|
|
|
**Full example:** [nested-typer-exception-explosion.py](./nested-typer-exceptions/nested-typer-exception-explosion.py)
|
|
|
|
```python
|
|
# From: nested-typer-exception-explosion.py (simplified - see full file for all 7 layers)
|
|
# Layer 1
|
|
def read_file(path):
|
|
try:
|
|
return path.read_text()
|
|
except FileNotFoundError as e:
|
|
raise ConfigError(f"File not found: {path}") from e
|
|
except Exception as e:
|
|
raise ConfigError(f"Failed to read: {e}") from e
|
|
|
|
# Layer 2
|
|
def load_config(path):
|
|
try:
|
|
contents = read_file(path)
|
|
return json.loads(contents)
|
|
except ConfigError as e:
|
|
raise ConfigError(f"Config load failed: {e}") from e
|
|
except Exception as e:
|
|
raise ConfigError(f"Unexpected error: {e}") from e
|
|
|
|
# Layer 3... Layer 4... Layer 5... Layer 6... Layer 7...
|
|
# Each layer wraps the exception again
|
|
```
|
|
|
|
**Result:** Single `FileNotFoundError` becomes a 6-layer exception chain with 220 lines of output.
|
|
|
|
## The Correct Solution: Typer's Exit Pattern
|
|
|
|
Based on Typer's official documentation and best practices:
|
|
|
|
### Pattern 1: Custom Exit Exception with typer.echo
|
|
|
|
**Full example:** [nested-typer-exception-explosion_corrected_typer_echo.py](./nested-typer-exceptions/nested-typer-exception-explosion_corrected_typer_echo.py)
|
|
|
|
Create a custom exception class that handles user-friendly output:
|
|
|
|
```python
|
|
# From: nested-typer-exception-explosion_corrected_typer_echo.py
|
|
import typer
|
|
|
|
class AppExit(typer.Exit):
|
|
"""Custom exception for graceful application exits."""
|
|
|
|
def __init__(self, code: int | None = None, message: str | None = None):
|
|
self.code = code
|
|
self.message = message
|
|
if message is not None:
|
|
if code is None or code == 0:
|
|
typer.echo(self.message)
|
|
else:
|
|
typer.echo(self.message, err=True)
|
|
super().__init__(code=code)
|
|
```
|
|
|
|
**Usage in helper functions:**
|
|
|
|
```python
|
|
# From: nested-typer-exception-explosion_corrected_typer_echo.py
|
|
def load_json_file(file_path: Path) -> dict:
|
|
"""Load JSON from file.
|
|
|
|
Raises:
|
|
AppExit: If file cannot be loaded or parsed
|
|
"""
|
|
contents = file_path.read_text(encoding="utf-8") # Let FileNotFoundError bubble
|
|
|
|
try:
|
|
return json.loads(contents)
|
|
except json.JSONDecodeError as e:
|
|
# Only catch where we can add meaningful context
|
|
raise AppExit(
|
|
code=1,
|
|
message=f"Invalid JSON in {file_path} at line {e.lineno}, column {e.colno}: {e.msg}"
|
|
) from e
|
|
```
|
|
|
|
**Key principles:**
|
|
|
|
- Helper functions let exceptions bubble naturally
|
|
- Only catch at points where you have enough context for a good error message
|
|
- Immediately raise `AppExit` - don't re-wrap multiple times
|
|
- Use `from e` to preserve the chain for debugging
|
|
|
|
### Pattern 2: Custom Exit Exception with Rich Console
|
|
|
|
**Full example:** [nested-typer-exception-explosion_corrected_rich_console.py](./nested-typer-exceptions/nested-typer-exception-explosion_corrected_rich_console.py)
|
|
|
|
For applications using Rich for output:
|
|
|
|
```python
|
|
# From: nested-typer-exception-explosion_corrected_rich_console.py
|
|
from rich.console import Console
|
|
import typer
|
|
|
|
normal_console = Console()
|
|
err_console = Console(stderr=True)
|
|
|
|
class AppExitRich(typer.Exit):
|
|
"""Custom exception using Rich console for consistent formatting."""
|
|
|
|
def __init__(
|
|
self,
|
|
code: int | None = None,
|
|
message: str | None = None,
|
|
console: Console = normal_console
|
|
):
|
|
self.code = code
|
|
self.message = message
|
|
if message is not None:
|
|
console.print(self.message)
|
|
super().__init__(code=code)
|
|
```
|
|
|
|
**Usage:**
|
|
|
|
```python
|
|
# From: nested-typer-exception-explosion_corrected_rich_console.py
|
|
def validate_config(data: dict) -> dict:
|
|
"""Validate config structure.
|
|
|
|
Raises:
|
|
AppExitRich: If validation fails
|
|
"""
|
|
if not data:
|
|
raise AppExitRich(code=1, message="Config cannot be empty", console=err_console)
|
|
if not isinstance(data, dict):
|
|
raise AppExitRich(
|
|
code=1,
|
|
message=f"Config must be a JSON object, got {type(data)}",
|
|
console=err_console
|
|
)
|
|
return data
|
|
```
|
|
|
|
## Complete Example: Correct Pattern
|
|
|
|
**Full example:** [nested-typer-exception-explosion_corrected_typer_echo.py](./nested-typer-exceptions/nested-typer-exception-explosion_corrected_typer_echo.py)
|
|
|
|
```python
|
|
# From: nested-typer-exception-explosion_corrected_typer_echo.py
|
|
#!/usr/bin/env -S uv run --quiet --script
|
|
# /// script
|
|
# requires-python = ">=3.11"
|
|
# dependencies = ["typer>=0.19.2"]
|
|
# ///
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Annotated
|
|
import typer
|
|
|
|
app = typer.Typer()
|
|
|
|
class AppExit(typer.Exit):
|
|
"""Custom exception for graceful exits with user-friendly messages."""
|
|
|
|
def __init__(self, code: int | None = None, message: str | None = None):
|
|
if message is not None:
|
|
if code is None or code == 0:
|
|
typer.echo(message)
|
|
else:
|
|
typer.echo(message, err=True)
|
|
super().__init__(code=code)
|
|
|
|
# Helper functions - let exceptions bubble naturally
|
|
def read_file_contents(file_path: Path) -> str:
|
|
"""Read file contents.
|
|
|
|
Raises:
|
|
FileNotFoundError: If file doesn't exist
|
|
PermissionError: If file isn't readable
|
|
"""
|
|
return file_path.read_text(encoding="utf-8")
|
|
|
|
def parse_json_string(content: str) -> dict:
|
|
"""Parse JSON string.
|
|
|
|
Raises:
|
|
json.JSONDecodeError: If JSON is invalid
|
|
"""
|
|
return json.loads(content)
|
|
|
|
# Only catch where we add meaningful context
|
|
def load_json_file(file_path: Path) -> dict:
|
|
"""Load and parse JSON file.
|
|
|
|
Raises:
|
|
AppExit: If file cannot be loaded or parsed
|
|
"""
|
|
contents = read_file_contents(file_path)
|
|
try:
|
|
return parse_json_string(contents)
|
|
except json.JSONDecodeError as e:
|
|
raise AppExit(
|
|
code=1,
|
|
message=f"Invalid JSON in {file_path} at line {e.lineno}, column {e.colno}: {e.msg}"
|
|
) from e
|
|
|
|
def validate_config(data: dict, source: str) -> dict:
|
|
"""Validate config structure.
|
|
|
|
Raises:
|
|
AppExit: If validation fails
|
|
"""
|
|
if not data:
|
|
raise AppExit(code=1, message="Config cannot be empty")
|
|
if not isinstance(data, dict):
|
|
raise AppExit(code=1, message=f"Config must be a JSON object, got {type(data)}")
|
|
return data
|
|
|
|
def load_config(file_path: Path) -> dict:
|
|
"""Load and validate configuration.
|
|
|
|
Raises:
|
|
AppExit: If config cannot be loaded or is invalid
|
|
"""
|
|
try:
|
|
data = load_json_file(file_path)
|
|
except (FileNotFoundError, PermissionError):
|
|
raise AppExit(code=1, message=f"Failed to load config from {file_path}")
|
|
return validate_config(data, str(file_path))
|
|
|
|
@app.command()
|
|
def main(config_file: Annotated[Path, typer.Argument()]) -> None:
|
|
"""Load and process configuration file."""
|
|
config = load_config(config_file)
|
|
typer.echo(f"Config loaded successfully: {config}")
|
|
|
|
if __name__ == "__main__":
|
|
app()
|
|
```
|
|
|
|
## Output Comparison
|
|
|
|
### Anti-Pattern Output (220 lines)
|
|
|
|
```text
|
|
╭───────────────────── Traceback (most recent call last) ──────────────────────╮
|
|
│ ... json.loads() ... │
|
|
│ ... 40 lines of traceback ... │
|
|
╰──────────────────────────────────────────────────────────────────────────────╯
|
|
JSONDecodeError: Expecting value: line 1 column 1 (char 0)
|
|
|
|
The above exception was the direct cause of the following exception:
|
|
|
|
╭───────────────────── Traceback (most recent call last) ──────────────────────╮
|
|
│ ... parse_json_string() ... │
|
|
│ ... 40 lines of traceback ... │
|
|
╰──────────────────────────────────────────────────────────────────────────────╯
|
|
ConfigError: Invalid JSON in broken.json at line 1, column 1: Expecting value
|
|
|
|
The above exception was the direct cause of the following exception:
|
|
|
|
[... 4 more layers of this ...]
|
|
```
|
|
|
|
### Correct Pattern Output (1 line)
|
|
|
|
```text
|
|
Invalid JSON in broken.json at line 1, column 1: Expecting value
|
|
```
|
|
|
|
## Rules for Exception Handling in Typer CLIs
|
|
|
|
### ✅ DO
|
|
|
|
1. **Let exceptions propagate in helper functions** - Most functions should not have try/except
|
|
2. **Catch only where you add meaningful context** - JSON parsing, validation, etc.
|
|
3. **Immediately raise AppExit** - Don't re-wrap multiple times
|
|
4. **Use custom exception classes** - Inherit from `typer.Exit` and handle output in `__init__`
|
|
5. **Document what exceptions bubble up** - Use docstring "Raises:" sections
|
|
6. **Use `from e` when wrapping** - Preserves exception chain for debugging
|
|
|
|
### ❌ DON'T
|
|
|
|
1. **NEVER catch and re-wrap at every layer** - This creates exception chain explosion
|
|
2. **NEVER use `except Exception as e:` as a safety net** - Too broad, catches things you can't handle
|
|
3. **NEVER check `isinstance` to avoid double-wrapping** - This is a symptom you're doing it wrong
|
|
4. **NEVER convert exceptions to return values** - Use exceptions, not `{"success": False, "error": "..."}` patterns
|
|
5. **NEVER catch exceptions you can't handle** - Let them propagate
|
|
|
|
## When to Catch Exceptions
|
|
|
|
**Catch when:**
|
|
|
|
- You can add meaningful context (filename, line number, etc.)
|
|
- You're at a validation boundary and can provide specific feedback
|
|
- You need to convert a technical error to user-friendly message
|
|
|
|
**Don't catch when:**
|
|
|
|
- You're just going to re-raise it
|
|
- You can't add any useful information
|
|
- You're in a helper function that just transforms data
|
|
|
|
## Fail Fast by Default
|
|
|
|
**What you DON'T want:**
|
|
|
|
- ❌ Nested try/except that re-raise with redundant messages
|
|
- ❌ Bare exception catching (`except Exception:`)
|
|
- ❌ Graceful degradation without requirements
|
|
- ❌ Failover/fallback logic without explicit need
|
|
- ❌ "Defensive" catch-all handlers that mask problems
|
|
|
|
**What IS fine:**
|
|
|
|
- ✅ Let exceptions propagate naturally
|
|
- ✅ Add try/except only where recovery is actually needed
|
|
- ✅ Validation at boundaries (user input, external APIs)
|
|
- ✅ Clear, specific exception types
|
|
|
|
## Reference: Typer Documentation
|
|
|
|
Official Typer guidance on exits and exceptions:
|
|
|
|
- [Terminating](https://github.com/fastapi/typer/blob/master/docs/tutorial/terminating.md)
|
|
- [Exceptions](https://github.com/fastapi/typer/blob/master/docs/tutorial/exceptions.md)
|
|
- [Printing](https://github.com/fastapi/typer/blob/master/docs/tutorial/printing.md)
|
|
|
|
## Demonstration Scripts
|
|
|
|
See [assets/nested-typer-exceptions/](./nested-typer-exceptions/) for complete working examples.
|
|
|
|
**Quick start:** See [README.md](./nested-typer-exceptions/README.md) for script overview and running instructions.
|
|
|
|
### [nested-typer-exception-explosion.py](./nested-typer-exceptions/nested-typer-exception-explosion.py) - The Anti-Pattern
|
|
|
|
**What you'll find:**
|
|
|
|
- Complete executable script demonstrating 7 layers of exception wrapping
|
|
- Every function catches exceptions and re-wraps with `from e`
|
|
- Creates ConfigError custom exception at each layer
|
|
- No isinstance checks - pure exception chain explosion
|
|
|
|
**What happens when you run it:**
|
|
|
|
- Single JSON parsing error generates ~220 lines of output
|
|
- 7 separate Rich-formatted traceback blocks
|
|
- "The above exception was the direct cause of the following exception" repeated 6 times
|
|
- Obscures the actual error (invalid JSON) in pages of traceback
|
|
|
|
**Run it:** `./nested-typer-exception-explosion.py broken.json`
|
|
|
|
### [nested-typer-exception-explosion_naive_workaround.py](./nested-typer-exceptions/nested-typer-exception-explosion_naive_workaround.py) - The isinstance Band-Aid
|
|
|
|
**What you'll find:**
|
|
|
|
- Same 7-layer structure as the explosion example
|
|
- Each `except Exception as e:` block has `if isinstance(e, ConfigError): raise` checks
|
|
- Shows how AI attempts to avoid double-wrapping by checking exception type
|
|
- Treats the symptom (double-wrapping) instead of the cause (catching everywhere)
|
|
|
|
**What happens when you run it:**
|
|
|
|
- Still shows nested tracebacks but slightly reduced output (~80 lines)
|
|
- Demonstrates why isinstance checks appear in AI-generated code
|
|
- Shows this is a workaround, not a solution
|
|
|
|
**Run it:** `./nested-typer-exception-explosion_naive_workaround.py broken.json`
|
|
|
|
### [nested-typer-exception-explosion_corrected_typer_echo.py](./nested-typer-exceptions/nested-typer-exception-explosion_corrected_typer_echo.py) - Correct Pattern with typer.echo
|
|
|
|
**What you'll find:**
|
|
|
|
- Custom `AppExit` class extending `typer.Exit` that calls `typer.echo()` in `__init__`
|
|
- Helper functions that let exceptions bubble naturally (no try/except)
|
|
- Only catches at specific points where meaningful context can be added
|
|
- Immediately raises `AppExit` - no re-wrapping through multiple layers
|
|
- Complete executable example with PEP 723 metadata
|
|
|
|
**What happens when you run it:**
|
|
|
|
- Clean 1-line error message: `Invalid JSON in broken.json at line 1, column 1: Expecting value`
|
|
- No traceback explosion
|
|
- User-friendly output using typer.echo for stderr
|
|
|
|
**Run it:** `./nested-typer-exception-explosion_corrected_typer_echo.py broken.json`
|
|
|
|
### [nested-typer-exception-explosion_corrected_rich_console.py](./nested-typer-exceptions/nested-typer-exception-explosion_corrected_rich_console.py) - Correct Pattern with Rich Console
|
|
|
|
**What you'll find:**
|
|
|
|
- Custom `AppExitRich` class extending `typer.Exit` that calls `console.print()` in `__init__`
|
|
- Same exception bubbling principles as typer.echo version
|
|
- Uses Rich Console for consistent formatting with rest of CLI
|
|
- Allows passing different console instances (normal_console vs err_console)
|
|
|
|
**What happens when you run it:**
|
|
|
|
- Same clean 1-line output as typer.echo version
|
|
- Uses Rich console for output instead of typer.echo
|
|
- Demonstrates pattern for apps already using Rich for terminal output
|
|
|
|
**Run it:** `./nested-typer-exception-explosion_corrected_rich_console.py broken.json`
|
|
|
|
### Running the Examples
|
|
|
|
All scripts use PEP 723 inline script metadata and can be run directly:
|
|
|
|
```bash
|
|
# Run any script directly (uv handles dependencies automatically)
|
|
./script-name.py broken.json
|
|
|
|
# Or explicitly with uv
|
|
uv run script-name.py broken.json
|
|
```
|
|
|
|
The scripts will create `broken.json` if it doesn't exist.
|