Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:49:58 +08:00
commit 5007abf04b
89 changed files with 44129 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
# EditorConfig: https://editorconfig.org/
# top-most EditorConfig file
root = true
# All (Defaults)
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
max_line_length = 120
# Markdown
[*.md]
indent_style = space
indent_size = 4
trim_trailing_whitespace = false
# Python
[*.py]
indent_style = space
indent_size = 4
# YAML
[*.{yml,yaml}]
indent_style = space
indent_size = 2
# Shell Script
[*.sh]
indent_style = space
indent_size = 4
# TOML
[*.toml]
indent_style = space
indent_size = 2
# JSON
[*.json]
indent_style = space
indent_size = 2
# Git commit messages
[COMMIT_EDITMSG]
max_line_length = 72

View File

@@ -0,0 +1,38 @@
{
"MD003": false,
"MD007": { "indent": 2 },
"MD001": false,
"MD022": false,
"MD024": false,
"MD013": false,
"MD036": false,
"MD025": false,
"MD031": false,
"MD041": false,
"MD029": false,
"MD033": false,
"MD046": false,
"blanks-around-fences": false,
"blanks-around-headings": false,
"blanks-around-lists": false,
"code-fence-style": false,
"emphasis-style": false,
"heading-start-left": false,
"heading-style": false,
"hr-style": false,
"line-length": false,
"list-indent": false,
"list-marker-space": false,
"no-blanks-blockquote": false,
"no-hard-tabs": false,
"no-missing-space-atx": false,
"no-missing-space-closed-atx": false,
"no-multiple-blanks": false,
"no-multiple-space-atx": false,
"no-multiple-space-blockquote": false,
"no-multiple-space-closed-atx": false,
"no-trailing-spaces": false,
"ol-prefix": false,
"strong-style": false,
"ul-indent": false
}

View File

@@ -0,0 +1,109 @@
# Pre-commit configuration for python_picotool repository
repos:
- repo: https://github.com/mxr/sync-pre-commit-deps
rev: v0.0.3
hooks:
- id: sync-pre-commit-deps
# Standard pre-commit hooks for general file maintenance
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
exclude: \.lock$
- id: end-of-file-fixer
exclude: \.lock$
- id: check-yaml
- id: check-json
- id: check-toml
- id: check-added-large-files
args: ["--maxkb=10000"] # 10MB limit
- id: check-case-conflict
- id: check-merge-conflict
- id: check-symlinks
- id: mixed-line-ending
args: ["--fix=lf"]
- id: check-executables-have-shebangs
- id: check-shebang-scripts-are-executable
# Python formatting and linting
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.13.3
hooks:
- id: ruff
name: Lint Python with ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
name: Format Python with ruff
# Shell script linting
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.11.0.1
hooks:
- id: shellcheck
name: Check shell scripts with shellcheck
files: \.(sh|bash)$
args: [-x, --severity=warning]
# YAML/JSON formatting
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
hooks:
- id: prettier
name: Format YAML, JSON, and Markdown files
types_or: [yaml, json, markdown]
exclude: \.lock$
# Shell formatting
- repo: https://github.com/pecigonzalo/pre-commit-shfmt
rev: v2.2.0
hooks:
- id: shell-fmt-go
args:
- "--apply-ignore"
- -w
- -i
- "4"
- -ci
# Local hooks for type checking
- repo: local
hooks:
- id: install-pep723-deps
name: Install PEP 723 script dependencies
entry: bash -c 'for file in "$@"; do if head -20 "$file" | grep -q "# /// script"; then uv export --script "$file" | uv pip install --quiet -r -; fi; done' --
language: system
types: [python]
pass_filenames: true
- id: mypy
name: mypy
entry: uv run mypy
language: system
types: [python]
pass_filenames: true
- id: pyright
name: basedpyright
entry: uv run basedpyright
language: system
types: [python]
pass_filenames: true
require_serial: true
# Configuration for specific hooks
default_language_version:
python: python3
# Exclude patterns
exclude: |
(?x)^(
\.git/|
\.venv/|
__pycache__/|
\.mypy_cache/|
\.cache/|
\.pytest_cache/|
\.lock$|
typings/
)

View File

@@ -0,0 +1,119 @@
"""Custom hatchling build hook for binary compilation.
This hook runs before the build process to compile platform-specific binaries
if build scripts are present in the project.
"""
from __future__ import annotations
import shutil
import subprocess # nosec B404 - subprocess required for build script execution, all calls use list form (not shell=True)
from pathlib import Path
from typing import Any
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class BinaryBuildHook(BuildHookInterface[Any]):
"""Build hook that runs binary compilation scripts before packaging.
This hook checks for the following scripts in order:
1. scripts/build-binaries.sh
2. scripts/build-binaries.py
If either script exists, it is executed before the build process.
If neither exists, the hook silently continues without error.
"""
PLUGIN_NAME = "binary-build"
def initialize(self, version: str, build_data: dict[str, Any]) -> None:
"""Run binary build scripts if they exist.
This method is called immediately before each build. It checks for
build scripts and executes them if found.
Args:
version: The version string for this build
build_data: Build configuration dictionary that will be passed to the build target
"""
# Check for shell script first
shell_script = Path(self.root) / "scripts" / "build-binaries.sh"
if shell_script.exists() and shell_script.is_file():
self._run_shell_script(shell_script)
return
# Fallback to Python script
python_script = Path(self.root) / "scripts" / "build-binaries.py"
if python_script.exists() and python_script.is_file():
self._run_python_script(python_script)
return
# No scripts found - silently continue
self.app.display_info("No binary build scripts found, skipping binary compilation")
def _run_shell_script(self, script_path: Path) -> None:
"""Execute a shell script for binary building.
Args:
script_path: Path to the shell script to execute
Raises:
subprocess.CalledProcessError: If the script exits with non-zero status
"""
self.app.display_info(f"Running binary build script: {script_path}")
# Get full path to bash executable for security (B607)
bash_path = shutil.which("bash")
if not bash_path:
msg = "bash executable not found in PATH"
raise RuntimeError(msg)
try:
result = subprocess.run( # nosec B603 - using command list with full path, not shell=True
[bash_path, str(script_path)], cwd=self.root, capture_output=True, text=True, check=True
)
if result.stdout:
self.app.display_info(result.stdout)
if result.stderr:
self.app.display_warning(result.stderr)
except subprocess.CalledProcessError as e:
self.app.display_error(f"Binary build script failed with exit code {e.returncode}")
if e.stdout:
self.app.display_info(f"stdout: {e.stdout}")
if e.stderr:
self.app.display_error(f"stderr: {e.stderr}")
raise
def _run_python_script(self, script_path: Path) -> None:
"""Execute a Python script for binary building.
Args:
script_path: Path to the Python script to execute
Raises:
subprocess.CalledProcessError: If the script exits with non-zero status
"""
self.app.display_info(f"Running binary build script: {script_path}")
# Get full path to python3 executable for security (B607)
python_path = shutil.which("python3")
if not python_path:
msg = "python3 executable not found in PATH"
raise RuntimeError(msg)
try:
result = subprocess.run( # nosec B603 - using command list with full path, not shell=True
[python_path, str(script_path)], cwd=self.root, capture_output=True, text=True, check=True
)
if result.stdout:
self.app.display_info(result.stdout)
if result.stderr:
self.app.display_warning(result.stderr)
except subprocess.CalledProcessError as e:
self.app.display_error(f"Binary build script failed with exit code {e.returncode}")
if e.stdout:
self.app.display_info(f"stdout: {e.stdout}")
if e.stderr:
self.app.display_error(f"stderr: {e.stderr}")
raise

View File

@@ -0,0 +1 @@
broken.json

View File

@@ -0,0 +1,52 @@
# Exception Chain Explosion Demonstration Scripts
This directory contains executable demonstration scripts showing the anti-pattern of exception chain explosion in Typer CLI applications and the correct patterns to prevent it.
## Quick Reference
| Script | Output | Purpose |
| -------------------------------------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------- |
| [nested-typer-exception-explosion.py](./nested-typer-exception-explosion.py) | ~220 lines | Shows the anti-pattern with 7 layers of exception wrapping |
| [nested-typer-exception-explosion_naive_workaround.py](./nested-typer-exception-explosion_naive_workaround.py) | ~80 lines | Shows the isinstance band-aid workaround |
| [nested-typer-exception-explosion_corrected_typer_echo.py](./nested-typer-exception-explosion_corrected_typer_echo.py) | 1 line | Correct pattern using typer.echo |
| [nested-typer-exception-explosion_corrected_rich_console.py](./nested-typer-exception-explosion_corrected_rich_console.py) | 1 line | Correct pattern using Rich Console |
## Running the Scripts
All scripts use PEP 723 inline script metadata and can be run directly:
```bash
# Run directly (uv handles dependencies automatically)
./nested-typer-exception-explosion.py broken.json
# Or explicitly with uv
uv run nested-typer-exception-explosion.py broken.json
```
The scripts will create `broken.json` with invalid content if it doesn't exist.
## The Problem
**Anti-pattern:** Every function catches and re-wraps exceptions → 220 lines of traceback for "file not found"
**Correct pattern:** Let exceptions bubble naturally, only catch at specific points → 1 line of clean output
## Documentation
For detailed explanations, code patterns, and best practices, see:
**[Exception Handling in Python CLI Applications with Typer](../../references/exception-handling.md)**
This comprehensive guide includes:
- Complete explanation of the exception chain explosion problem
- Correct patterns using `typer.Exit` subclasses
- When to catch exceptions and when to let them bubble
- Full code examples with detailed annotations
- DO/DON'T guidelines
## External References
- [Typer Terminating Documentation](https://github.com/fastapi/typer/blob/master/docs/tutorial/terminating.md)
- [Typer Exceptions Documentation](https://github.com/fastapi/typer/blob/master/docs/tutorial/exceptions.md)
- [Typer Printing Documentation](https://github.com/fastapi/typer/blob/master/docs/tutorial/printing.md)

View File

@@ -0,0 +1,188 @@
#!/usr/bin/env -S uv run --quiet --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["typer>=0.19.2"]
# ///
# ruff: noqa: TRY300, TRY301
# mypy: ignore-errors
"""Demonstration of exception chain explosion anti-pattern.
This script demonstrates how catching and re-wrapping exceptions at every
layer creates massive traceback output (8+ pages) for simple errors.
Based on real AI-generated code patterns that destroy terminal UI.
Run this to see the problem:
./nested-typer-exception-explosion.py broken.json
The 'broken.json' file will be created with invalid JSON content.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Annotated
import typer # pyright: ignore[reportMissingImports]
app = typer.Typer()
class ConfigError(Exception):
"""Custom exception for configuration errors."""
# LAYER 1: Low-level file reading
def read_file_contents(file_path: Path) -> str:
"""Read file contents - ANTI-PATTERN: Wraps exceptions unnecessarily.
Raises:
ConfigError: If file cannot be read (wrapped)
"""
try:
return file_path.read_text(encoding="utf-8")
except FileNotFoundError as e:
raise ConfigError(f"File not found: {file_path}") from e
except PermissionError as e:
raise ConfigError(f"Permission denied: {file_path}") from e
except Exception as e:
# ANTI-PATTERN: Safety net catches the ConfigError we just raised!
raise ConfigError(f"Failed to read {file_path}: {e}") from e
# LAYER 2: JSON parsing
def parse_json_string(content: str, source: str) -> dict:
"""Parse JSON string - ANTI-PATTERN: Another wrapping layer.
Raises:
ConfigError: If JSON cannot be parsed (wrapped again)
"""
try:
return json.loads(content)
except json.JSONDecodeError as e:
raise ConfigError(f"Invalid JSON in {source} at line {e.lineno}, column {e.colno}: {e.msg}") from e
except Exception as e:
# ANTI-PATTERN: Safety net catches ConfigError we just raised
raise ConfigError(f"JSON parse error in {source}: {e}") from e
# LAYER 3: Load JSON from file
def load_json_file(file_path: Path) -> dict:
"""Load JSON from file - ANTI-PATTERN: Yet another wrapping layer.
Raises:
ConfigError: If file cannot be loaded (wrapped third time)
"""
try:
contents = read_file_contents(file_path)
data = parse_json_string(contents, str(file_path))
return data
except ConfigError as e:
# ANTI-PATTERN: Wrap the already-wrapped exception AGAIN
raise ConfigError(f"Failed to load JSON from {file_path}: {e}") from e
except Exception as e:
# ANTI-PATTERN: Safety net catches ConfigError we just raised
raise ConfigError(f"Unexpected error loading {file_path}: {e}") from e
# LAYER 4: Validate config structure
def validate_config_structure(data: object, source: str) -> dict:
"""Validate config structure - ANTI-PATTERN: More wrapping.
Raises:
ConfigError: If validation fails (wrapped fourth time)
"""
try:
if not isinstance(data, dict):
raise TypeError("Config must be a JSON object")
if not data:
raise ValueError("Config cannot be empty")
return data
except (TypeError, ValueError) as e:
raise ConfigError(f"Invalid config structure in {source}: {e}") from e
except Exception as e:
# ANTI-PATTERN: Safety net catches ConfigError we just raised
raise ConfigError(f"Config validation error in {source}: {e}") from e
# LAYER 5: Load and validate config
def load_config(file_path: Path) -> dict:
"""Load and validate config - ANTI-PATTERN: Fifth wrapping layer.
Raises:
ConfigError: If config cannot be loaded (wrapped fifth time)
"""
try:
data = load_json_file(file_path)
validated = validate_config_structure(data, str(file_path))
return validated
except ConfigError as e:
# ANTI-PATTERN: Wrap the already-quadruple-wrapped exception
raise ConfigError(f"Configuration loading failed: {e}") from e
except Exception as e:
# ANTI-PATTERN: Safety net catches ConfigError we just raised
raise ConfigError(f"Unexpected configuration error: {e}") from e
# LAYER 6: Process config
def process_config(file_path: Path) -> None:
"""Process configuration - ANTI-PATTERN: Sixth wrapping layer.
Raises:
ConfigError: If processing fails (wrapped sixth time)
"""
try:
config = load_config(file_path)
typer.echo(f"Successfully loaded config: {config}")
except ConfigError as e:
# ANTI-PATTERN: Wrap the already-quintuple-wrapped exception
raise ConfigError(f"Failed to process configuration: {e}") from e
except Exception as e:
# ANTI-PATTERN: Safety net catches ConfigError we just raised
raise ConfigError(f"Processing error: {e}") from e
# LAYER 7: CLI entry point
@app.command()
def main(
config_file: Annotated[Path, typer.Argument(help="Path to JSON configuration file")] = Path("broken.json"),
) -> None:
"""Load and process a JSON configuration file.
This demonstrates the ANTI-PATTERN of exception chain explosion.
When an error occurs, you'll see a massive exception chain through 7+ layers.
Example:
# Create a broken JSON file
echo "i'm broken" > broken.json
# Run the script to see exception explosion
./nested-typer-exception-explosion.py broken.json
"""
# DON'T catch here - let the exception chain explode through all layers
# This shows the full horror of the nested wrapping pattern
process_config(config_file)
@app.command()
def create_test_file() -> None:
"""Create a broken JSON file for testing the exception explosion."""
broken_file = Path("broken.json")
broken_file.write_text("i'm broken")
typer.echo(f"Created {broken_file} with invalid JSON content")
if __name__ == "__main__":
# Auto-create broken.json if it doesn't exist and is being used
if len(sys.argv) > 1:
arg = sys.argv[-1]
if arg == "broken.json" or (not arg.startswith("-") and Path(arg).name == "broken.json"):
broken_file = Path("broken.json")
if not broken_file.exists():
typer.echo("Creating broken.json for demonstration...")
broken_file.write_text("i'm broken")
typer.echo()
app()

View File

@@ -0,0 +1,185 @@
#!/usr/bin/env -S uv --quiet run --active --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["typer>=0.19.2"]
# ///
"""Demonstration of exception chain explosion anti-pattern corrected using rich console.
This shows how to resolve the issue with nested-typer-exception-explosion.py
by using rich console to print errors consistently with the CLI UX.
Run this to see the problem:
./nested-typer-exception-explosion.py broken.json
The 'broken.json' file will be created with invalid JSON content.
"""
# mypy: ignore-errors
from __future__ import annotations
import json
from pathlib import Path
from typing import Annotated, Any
try:
import typer # pyright: ignore[reportMissingImports]
from rich.console import Console # pyright: ignore[reportMissingImports]
except ImportError as e:
error_message = f"""
This script needs to be run using a PEP723 compliant executor like uv
which can handle finding and installing dependencies automatically,
unlike python or python3 which require you to manually install the dependencies.
What is inline-metadata? > https://packaging.python.org/en/latest/specifications/inline-script-metadata/#inline-script-metadata
What is PEP723? > https://peps.python.org/pep-0723/
How to do this yourself? > https://docs.astral.sh/uv/guides/scripts/
If you have uv on this system, then this script can be run without prefixing any application.
example: ./thisscript.py <arguments>
You can explicitly invoke it with uv:
example: uv run ./thisscript.py <arguments>
If you do not have uv installed, then you can install it following the instructions at:
https://docs.astral.sh/uv/getting-started/installation/
If that is TL;DR, then you can install it with the following command:
curl -fsSL https://astral.sh/uv/install.sh | bash
The longform way to run scripts with inline dependencies is to install the dependencies manually
and run the script with python or python3.
example:
python3 -m venv .venv
source .venv/bin/activate
pip install typer
python3 thisscript.py <arguments>
ImportException: {e!s}
"""
raise ImportError(error_message) from None
normal_console = Console()
err_console = Console(stderr=True)
app = typer.Typer()
DEFAULT_CONFIG_FILE = Path("broken.json")
class AppExitRich(typer.Exit):
"""Exception class for application exits using rich console"""
def __init__(self, code: int | None = None, message: str | None = None, console: Console = normal_console):
"""Custom exception using console based formatting to keep errors consistent with the CLI UX"""
self.code = code
self.message = message
if message is not None:
console.print(self.message, crop=False, overflow="ignore")
super().__init__(code=code)
class ConfigError(Exception):
"""Custom exception for errors that will be handled internally"""
# LAYER 1: Low-level file reading
def read_file_contents(file_path: Path) -> str:
"""Read file contents - ANTI-PATTERN: Wraps exceptions unnecessarily.
Raises:
FileNotFoundError: If file does not exist
PermissionError: If file is not readable
"""
return file_path.read_text(encoding="utf-8")
# LAYER 2: JSON parsing
def parse_json_string(content: str, source: str) -> dict:
"""Parse JSON string - ANTI-PATTERN: Another wrapping layer.
Bubbles up:
json.JSONDecodeError: If JSON is not valid
"""
return json.loads(content)
# LAYER 3: Load JSON from file
def load_json_file(file_path: Path) -> dict:
"""Load JSON from file - ANTI-PATTERN: Yet another wrapping layer.
Bubbles up:
FileNotFoundError: If file does not exist
PermissionError: If file is not readable
json.JSONDecodeError: If file is not valid JSON
"""
contents = read_file_contents(file_path)
try:
return parse_json_string(contents, str(file_path))
except json.JSONDecodeError as e:
raise AppExitRich(code=1, message=f"Invalid JSON in {file_path!s} at line {e.lineno}, column {e.colno}: {e.msg}") from e
# LAYER 4: Validate config structure
def validate_config_structure(data: Any, source: str) -> dict:
"""Validate config structure - ANTI-PATTERN: More wrapping.
Raises:
TypeError: If config is not a JSON object
ValueError: If config is empty
"""
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
# LAYER 5: Load and validate config (consolidate exception handling)
def load_config(file_path: Path) -> dict:
"""Load and validate config - ANTI-PATTERN: Fifth wrapping layer.
Raises:
ConfigError: If config cannot be loaded, invalid structure, or empty
"""
try:
data = load_json_file(file_path)
except (FileNotFoundError, PermissionError) as e:
raise AppExitRich(code=1, message=f"Failed to load config from {file_path}", console=err_console) from e
else:
return validate_config_structure(data, str(file_path))
# LAYER 6: Process config
def process_config(file_path: Path) -> dict:
"""Process configuration - ANTI-PATTERN: Sixth wrapping layer.
Bubbles up:
ConfigError: If processing fails (wrapped sixth time)
"""
config = load_config(file_path)
normal_console.print("Config successfully loaded")
return config
# LAYER 7: CLI entry point
@app.command()
def main(
config_file: Annotated[Path | None, typer.Argument(help="Path to JSON configuration file")] = None,
) -> None:
"""Load and process a JSON configuration file.
This demonstrates the ANTI-PATTERN of exception chain explosion.
When an error occurs, you'll see a massive exception chain through 7+ layers.
Example:
# Create a broken JSON file
echo "i'm broken" > broken.json
# Run the script to see exception explosion
./nested-typer-exception-explosion.py broken.json
"""
normal_console.print("Starting script")
if config_file is None:
normal_console.print(f"No config file provided, using default: {DEFAULT_CONFIG_FILE!s}")
config_file = DEFAULT_CONFIG_FILE
process_config(config_file)
if __name__ == "__main__":
app()

View File

@@ -0,0 +1,185 @@
#!/usr/bin/env -S uv --quiet run --active --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["typer>=0.19.2"]
# ///
"""Demonstration of exception chain explosion anti-pattern corrected using typer.echo.
This shows how to resolve the issue with nested-typer-exception-explosion.py
by using typer.echo to print errors consistently with the CLI UX.
Run this to see the problem:
./nested-typer-exception-explosion.py broken.json
The 'broken.json' file will be created with invalid JSON content.
"""
# mypy: ignore-errors
from __future__ import annotations
import json
from pathlib import Path
from typing import Annotated, Any
try:
import typer # pyright: ignore[reportMissingImports]
except ImportError as e:
error_message = f"""
This script needs to be run using a PEP723 compliant executor like uv
which can handle finding and installing dependencies automatically,
unlike python or python3 which require you to manually install the dependencies.
What is inline-metadata? > https://packaging.python.org/en/latest/specifications/inline-script-metadata/#inline-script-metadata
What is PEP723? > https://peps.python.org/pep-0723/
How to do this yourself? > https://docs.astral.sh/uv/guides/scripts/
If you have uv on this system, then this script can be run without prefixing any application.
example: ./thisscript.py <arguments>
You can explicitly invoke it with uv:
example: uv run ./thisscript.py <arguments>
If you do not have uv installed, then you can install it following the instructions at:
https://docs.astral.sh/uv/getting-started/installation/
If that is TL;DR, then you can install it with the following command:
curl -fsSL https://astral.sh/uv/install.sh | bash
The longform way to run scripts with inline dependencies is to install the dependencies manually
and run the script with python or python3.
example:
python3 -m venv .venv
source .venv/bin/activate
pip install typer
python3 thisscript.py <arguments>
ImportException: {e!s}
"""
raise ImportError(error_message) from None
app = typer.Typer()
DEFAULT_CONFIG_FILE = Path("broken.json")
class AppExit(typer.Exit):
"""Exception class for application exits using typer"""
def __init__(self, code: int | None = None, message: str | None = None):
"""Custom exception for using typer.echo"""
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)
class ConfigError(Exception):
"""Custom exception for errors that will be handled internally"""
# LAYER 1: Low-level file reading
def read_file_contents(file_path: Path) -> str:
"""Read file contents - ANTI-PATTERN: Wraps exceptions unnecessarily.
Raises:
FileNotFoundError: If file does not exist
PermissionError: If file is not readable
"""
return file_path.read_text(encoding="utf-8")
# LAYER 2: JSON parsing
def parse_json_string(content: str, source: str) -> dict:
"""Parse JSON string - ANTI-PATTERN: Another wrapping layer.
Bubbles up:
json.JSONDecodeError: If JSON is not valid
"""
return json.loads(content)
# LAYER 3: Load JSON from file
def load_json_file(file_path: Path) -> dict:
"""Load JSON from file - ANTI-PATTERN: Yet another wrapping layer.
Bubbles up:
FileNotFoundError: If file does not exist
PermissionError: If file is not readable
json.JSONDecodeError: If file is not valid JSON
"""
contents = read_file_contents(file_path)
try:
return parse_json_string(contents, str(file_path))
except json.JSONDecodeError as e:
raise AppExit(code=1, message=f"Invalid JSON in {file_path!s} at line {e.lineno}, column {e.colno}: {e.msg}") from e
# LAYER 4: Validate config structure
def validate_config_structure(data: Any, source: str) -> dict:
"""Validate config structure - ANTI-PATTERN: More wrapping.
Raises:
TypeError: If config is not a JSON object
ValueError: If config is empty
"""
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
# LAYER 5: Load and validate config (consolidate exception handling)
def load_config(file_path: Path) -> dict:
"""Load and validate config - ANTI-PATTERN: Fifth wrapping layer.
Raises:
ConfigError: If config cannot be loaded, invalid structure, or empty
"""
try:
data = load_json_file(file_path)
except (FileNotFoundError, PermissionError) as e:
raise AppExit(code=1, message=f"Failed to load config from {file_path}") from e
else:
return validate_config_structure(data, str(file_path))
# LAYER 6: Process config
def process_config(file_path: Path) -> dict:
"""Process configuration - ANTI-PATTERN: Sixth wrapping layer.
Bubbles up:
ConfigError: If processing fails (wrapped sixth time)
"""
config = load_config(file_path)
typer.echo("Config successfully loaded")
return config
# LAYER 7: CLI entry point
@app.command()
def main(
config_file: Annotated[Path | None, typer.Argument(help="Path to JSON configuration file")] = None,
) -> None:
"""Load and process a JSON configuration file.
This demonstrates the ANTI-PATTERN of exception chain explosion.
When an error occurs, you'll see a massive exception chain through 7+ layers.
Example:
# Create a broken JSON file
echo "i'm broken" > broken.json
# Run the script to see exception explosion
./nested-typer-exception-explosion.py broken.json
"""
typer.echo("Starting script")
if config_file is None:
typer.echo(f"No config file provided, using default: {DEFAULT_CONFIG_FILE!s}")
config_file = DEFAULT_CONFIG_FILE
process_config(config_file)
if __name__ == "__main__":
app()

View File

@@ -0,0 +1,207 @@
#!/usr/bin/env -S uv run --quiet --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["typer>=0.19.2"]
# ///
# ruff: noqa: TRY300, TRY301
# mypy: ignore-errors
"""Demonstration of the "naive workaround" to exception chain explosion.
This script shows the isinstance() check pattern that AI generates to avoid
double-wrapping exceptions. This is a BAND-AID on the real problem.
The workaround makes output cleaner (no massive chains) but:
- Adds complexity and cognitive load
- Treats the symptom, not the root cause
- Still has nested exception handling everywhere
- The code "knows" it's doing something wrong
The CORRECT solution: Don't catch and wrap at every layer in the first place.
Run this to see the "workaround" output:
./nested-typer-exception-explosion_naive_workaround.py broken.json
Compare to nested-typer-exception-explosion.py to see the difference.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Annotated
import typer # pyright: ignore[reportMissingImports]
app = typer.Typer()
class ConfigError(Exception):
"""Custom exception for configuration errors."""
# LAYER 1: Low-level file reading
def read_file_contents(file_path: Path) -> str:
"""Read file contents - ANTI-PATTERN: Wraps exceptions unnecessarily.
Raises:
ConfigError: If file cannot be read (wrapped)
"""
try:
return file_path.read_text(encoding="utf-8")
except FileNotFoundError as e:
raise ConfigError(f"File not found: {file_path}") from e
except PermissionError as e:
raise ConfigError(f"Permission denied: {file_path}") from e
except Exception as e:
# NAIVE WORKAROUND: Check isinstance to avoid double-wrapping
# This is treating the symptom, not fixing the root cause!
if isinstance(e, ConfigError):
raise # Re-raise without wrapping
raise ConfigError(f"Failed to read {file_path}: {e}") from e
# LAYER 2: JSON parsing
def parse_json_string(content: str, source: str) -> dict:
"""Parse JSON string - ANTI-PATTERN: Another wrapping layer.
Raises:
ConfigError: If JSON cannot be parsed (wrapped again)
"""
try:
return json.loads(content)
except json.JSONDecodeError as e:
raise ConfigError(f"Invalid JSON in {source} at line {e.lineno}, column {e.colno}: {e.msg}") from e
except Exception as e:
# NAIVE WORKAROUND: Check isinstance to avoid double-wrapping
if isinstance(e, ConfigError):
raise
raise ConfigError(f"JSON parse error in {source}: {e}") from e
# LAYER 3: Load JSON from file
def load_json_file(file_path: Path) -> dict:
"""Load JSON from file - ANTI-PATTERN: Yet another wrapping layer.
Raises:
ConfigError: If file cannot be loaded (wrapped third time)
"""
try:
contents = read_file_contents(file_path)
data = parse_json_string(contents, str(file_path))
return data
except ConfigError as e:
# ANTI-PATTERN: Wrap the already-wrapped exception AGAIN
raise ConfigError(f"Failed to load JSON from {file_path}: {e}") from e
except Exception as e:
# NAIVE WORKAROUND: Check isinstance to avoid double-wrapping
if isinstance(e, ConfigError):
raise
raise ConfigError(f"Unexpected error loading {file_path}: {e}") from e
# LAYER 4: Validate config structure
def validate_config_structure(data: dict, source: str) -> dict:
"""Validate config structure - ANTI-PATTERN: More wrapping.
Raises:
ConfigError: If validation fails (wrapped fourth time)
"""
try:
if not isinstance(data, dict):
raise TypeError("Config must be a JSON object")
if not data:
raise ValueError("Config cannot be empty")
return data
except (TypeError, ValueError) as e:
raise ConfigError(f"Invalid config structure in {source}: {e}") from e
except Exception as e:
# NAIVE WORKAROUND: Check isinstance to avoid double-wrapping
if isinstance(e, ConfigError):
raise
raise ConfigError(f"Config validation error in {source}: {e}") from e
# LAYER 5: Load and validate config
def load_config(file_path: Path) -> dict:
"""Load and validate config - ANTI-PATTERN: Fifth wrapping layer.
Raises:
ConfigError: If config cannot be loaded (wrapped fifth time)
"""
try:
data = load_json_file(file_path)
validated = validate_config_structure(data, str(file_path))
return validated
except ConfigError as e:
# ANTI-PATTERN: Wrap the already-quadruple-wrapped exception
raise ConfigError(f"Configuration loading failed: {e}") from e
except Exception as e:
# NAIVE WORKAROUND: Check isinstance to avoid double-wrapping
if isinstance(e, ConfigError):
raise
raise ConfigError(f"Unexpected configuration error: {e}") from e
# LAYER 6: Process config
def process_config(file_path: Path) -> None:
"""Process configuration - ANTI-PATTERN: Sixth wrapping layer.
Raises:
ConfigError: If processing fails (wrapped sixth time)
"""
try:
config = load_config(file_path)
typer.echo(f"Successfully loaded config: {config}")
except ConfigError as e:
# ANTI-PATTERN: Wrap the already-quintuple-wrapped exception
raise ConfigError(f"Failed to process configuration: {e}") from e
except Exception as e:
# NAIVE WORKAROUND: Check isinstance to avoid double-wrapping
if isinstance(e, ConfigError):
raise
raise ConfigError(f"Processing error: {e}") from e
# LAYER 7: CLI entry point
@app.command()
def main(
config_file: Annotated[Path, typer.Argument(help="Path to JSON configuration file")] = Path("broken.json"),
) -> None:
"""Load and process a JSON configuration file.
This demonstrates the ANTI-PATTERN of exception chain explosion.
When an error occurs, you'll see a massive exception chain through 7+ layers.
Example:
# Create a broken JSON file
echo "i'm broken" > broken.json
# Run the script to see exception explosion
./nested-typer-exception-explosion.py broken.json
"""
# DON'T catch here - let the exception chain explode through all layers
# This shows the full horror of the nested wrapping pattern
process_config(config_file)
@app.command()
def create_test_file() -> None:
"""Create a broken JSON file for testing the exception explosion."""
broken_file = Path("broken.json")
broken_file.write_text("i'm broken")
typer.echo(f"Created {broken_file} with invalid JSON content")
if __name__ == "__main__":
# Auto-create broken.json if it doesn't exist and is being used
if len(sys.argv) > 1:
arg = sys.argv[-1]
if arg == "broken.json" or (not arg.startswith("-") and Path(arg).name == "broken.json"):
broken_file = Path("broken.json")
if not broken_file.exists():
typer.echo("Creating broken.json for demonstration...")
broken_file.write_text("i'm broken")
typer.echo()
app()

View File

@@ -0,0 +1,147 @@
#!/usr/bin/env -S uv --quiet run --active --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "rich>=13.0.0",
# ]
# ///
"""Rich containers (Panel, Table) behavior with long content in non-TTY environments."""
from rich.console import Console, RenderableType
from rich.measure import Measurement
from rich.panel import Panel
from rich.table import Table
def get_rendered_width(renderable: RenderableType) -> int:
"""Get actual rendered width of any Rich renderable.
Handles color codes, Unicode, styling, padding, and borders.
Works with Panel, Table, or any Rich container.
"""
temp_console = Console(width=999999)
measurement = Measurement.get(temp_console, temp_console.options, renderable)
return int(measurement.maximum)
long_url = (
"https://raw.githubusercontent.com/python/cpython/main/Lib/asyncio/base_events.py"
"#L1000-L1100?ref=docs-example&utm_source=rich-demo&utm_medium=terminal"
"&utm_campaign=long-url-wrapping-behavior-test"
)
long_command = "\n".join([
"[bold cyan]:sparkles: v3.13.0 Release Highlights :sparkles:[/bold cyan] New JIT optimizations, faster startup, improved error messages, richer tracebacks, better asyncio diagnostics, enhanced typing features, smoother virtualenv workflows, and a refined standard library experience for developers everywhere.",
"[green]:rocket: Performance & Reliability :rocket:[/green] Lower latency event loops, smarter garbage collection heuristics, adaptive I/O backpressure, fine-tuned file system operations, reduced memory fragmentation, and sturdier cross-platform behavior in cloud-native deployments.",
"[magenta]:hammer_and_wrench: Developer Experience :hammer_and_wrench:[/magenta] More precise type hints, clearer deprecation warnings, friendlier REPL niceties, first-class debugging hooks, expanded `typing` utilities, and streamlined packaging stories for modern Python projects of all sizes.",
"[yellow]:shield: Security & Ecosystem :shield:[/yellow] Hardened TLS defaults, safer subprocess handling, improved sandboxing hooks, more robust hashing algorithms, curated secure defaults across modules, and deeper ecosystem integration for auditing, scanning, and compliance workflows.",
])
console = Console()
## BROKEN EXAMPLES AND ANTI-PATTERNS
print("=" * 80)
print("Panel with default settings")
print("=" * 80)
panel = Panel(f"URL: {long_url}\nCommand: {long_command}")
console.print(panel)
print("\n" + "=" * 80)
print("Panel with crop=False, overflow='ignore' on print")
print("=" * 80)
console.print(panel, crop=False, overflow="ignore")
print("\n" + "=" * 80)
print("Panel with expand=False and measured width")
print("=" * 80)
# Avoid doing this, where you set the console width for all output to a wide width,
# It will cause output that is 'extended' to fit to the console width,
# which is not what you want.
panel_content = f"URL: {long_url}\nCommand: {long_command}"
panel_measured = Panel(panel_content, expand=False)
temp_console = Console(width=99999)
measurement = Measurement.get(temp_console, temp_console.options, panel_measured)
panel_measured.width = int(measurement.maximum)
console.print(panel_measured, crop=False, overflow="ignore")
print("\n" + "=" * 80)
print("Table with default settings")
print("=" * 80)
table = Table()
table.add_column("Type", style="cyan")
table.add_column("Value", style="green")
table.add_row("URL", long_url)
table.add_row("Command", long_command)
console.print(table)
print("\n" + "=" * 80)
print("Table with no_wrap=True on columns")
print("=" * 80)
table_nowrap = Table()
table_nowrap.add_column("Type", style="cyan", no_wrap=True)
table_nowrap.add_column("Value", style="green", no_wrap=True)
table_nowrap.add_row("URL", long_url)
table_nowrap.add_row("Command", long_command)
console.print(table_nowrap, crop=False, overflow="ignore")
## WORKING EXAMPLES THAT DO WHAT IS EXPECTED
print("\n" + "=" * 80)
print("Panel that works: Use get_rendered_width() helper")
print("=" * 80)
# Panels fill the space up to the size of the Console,
# to to make a Panel that doesn't wrap,
# we need to set the width of the Console to the rendered panel width
content_lines = f"URL: {long_url}\n{long_command}"
panel_measured = Panel(content_lines)
panel_width = get_rendered_width(panel_measured)
console.width = panel_width
console.print(panel_measured, crop=False, overflow="ignore", no_wrap=True, soft_wrap=True)
print("\n" + "=" * 80)
print("Table that works: Use get_rendered_width() helper")
print("=" * 80)
# Tables are bossy and will display at their own width if set,
# regardless of the console width. So we need to measure the table width
# and set the width of the table to the measured width.
table_measured = Table()
table_measured.add_column("Type", style="cyan", no_wrap=True)
table_measured.add_column("Value", style="green", no_wrap=True)
table_measured.add_row("URL", long_url)
table_measured.add_row("Command", long_command)
# set table width to the measured width
table_measured.width = get_rendered_width(table_measured)
console.print(table_measured, crop=False, overflow="ignore", no_wrap=True, soft_wrap=True)
print("\n" + "=" * 80)
print("Plain text with crop=False, overflow='ignore'")
print("=" * 80)
# Plain text doesn't have a width, so we can just print it directly,
# as long as we set crop=False and overflow="ignore"
console.print(f"URL: {long_url}", crop=False, overflow="ignore")
console.print(f"Command: {long_command}", crop=False, overflow="ignore")
print("\n" + "=" * 80)
print("Table with matching Panel summary: Same width for both")
print("=" * 80)
# Create table with data
result_table = Table()
result_table.add_column("Type", style="cyan", no_wrap=True)
result_table.add_column("Value", style="green", no_wrap=True)
result_table.add_row("URL", long_url)
result_table.add_row("Command", long_command)
# Measure table width and set it
table_width = get_rendered_width(result_table)
result_table.width = table_width
# Create panel summary with same width
summary_text = "[bold]Summary:[/bold] Processed 2 items with no errors"
summary_panel = Panel(summary_text, title="Results", border_style="green")
# Panel needs Console width set to match table
console.width = table_width
# Print both - they'll have matching widths
console.print(result_table, crop=False, overflow="ignore", no_wrap=True, soft_wrap=True)
console.print(summary_panel, crop=False, overflow="ignore", no_wrap=True, soft_wrap=True)

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env -S uv --quiet run --active --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "rich>=13.0.0",
# ]
# ///
"""Minimal example: Preventing Rich Console word wrapping in CI/non-TTY environments.
Problem: Rich Console wraps text at default width (80 chars in non-TTY), breaking:
- URLs in log output
- Long command strings
- Stack traces
- Structured log parsing
Solution: Use crop=False + overflow="ignore" on console.print() calls.
"""
from rich.console import Console
# Sample long text that would wrap at 80 characters
long_url = (
"https://raw.githubusercontent.com/python/cpython/main/Lib/asyncio/base_events.py"
"#L1000-L1100?ref=docs-example&utm_source=rich-demo&utm_medium=terminal"
"&utm_campaign=long-url-wrapping-behavior-test"
)
long_command = """
[bold cyan]:sparkles: v3.13.0 Release Highlights :sparkles:[/bold cyan] New JIT optimizations, faster startup, improved error messages, richer tracebacks, "
better asyncio diagnostics, enhanced typing features, smoother virtualenv workflows, and a refined standard library experience for developers everywhere.
[green]:rocket: Performance & Reliability :rocket:[/green] Lower latency event loops, smarter garbage collection heuristics, adaptive I/O backpressure, fine-tuned file system operations, reduced memory fragmentation, and sturdier cross-platform behavior in cloud-native deployments.
[magenta]:hammer_and_wrench: Developer Experience :hammer_and_wrench:[/magenta] More precise type hints, clearer deprecation warnings, friendlier REPL niceties, first-class debugging hooks, expanded `typing` utilities, and streamlined packaging stories for modern Python projects of all sizes.",
[yellow]:shield: Security & Ecosystem :shield:[/yellow] Hardened TLS defaults, safer subprocess handling, improved sandboxing hooks, more robust hashing algorithms, curated secure defaults across modules, and deeper ecosystem integration for auditing, scanning, and compliance workflows.
"""
long_traceback = "Traceback (most recent call last): File /very/long/path/to/module/that/contains/the/failing/code/in/production/environment.py line 42 in process_data"
console = Console()
print("=" * 80)
print("PROBLEM: Default console.print() wraps long lines")
print("=" * 80)
console.print(f"URL: {long_url}")
console.print(f"Command: {long_command}")
console.print(f"Error: {long_traceback}")
print("\n" + "=" * 80)
print("SOLUTION: Use crop=False + overflow='ignore'")
print("=" * 80)
console.print(f"URL: {long_url}", crop=False, overflow="ignore")
console.print(f"Command: {long_command}", crop=False, overflow="ignore")
console.print(f"Error: {long_traceback}", crop=False, overflow="ignore")

View File

@@ -0,0 +1,153 @@
# Typer and Rich CLI Examples
This directory contains executable examples demonstrating solutions to common problems when building Python CLI applications with Typer and Rich.
## Available Examples
| Script | Problem Solved | Key Technique |
| --- | --- | --- |
| [console_no_wrap_example.py](./console_no_wrap_example.py) | Rich Console wraps text at 80 chars in CI/non-TTY | Use `crop=False, overflow="ignore"` on print calls |
| [console_containers_no_wrap.py](./console_containers_no_wrap.py) | Panels/Tables wrap long content even with crop=False | Use `get_rendered_width()` helper + dedicated Console |
## Quick Start
All scripts use PEP 723 inline script metadata and can be run directly:
```bash
# Run directly (uv handles dependencies automatically)
./console_no_wrap_example.py
# Or explicitly with uv
uv run console_no_wrap_example.py
```
## Problem 1: Rich Console Text Wrapping in CI
### The Problem
Rich Console wraps text at default width (80 chars in non-TTY environments like CI), breaking:
- URLs in log output
- Long command strings
- Stack traces
- Structured log parsing
### Why This Matters
**In non-interactive environments (CI, logs, automation), output is consumed by machines, not humans:**
- **Log parsing**: Tools like grep/awk/sed expect data on single lines - wrapping breaks patterns
- **URLs**: Wrapped URLs become invalid - can't click, copy-paste, or process with tools
- **Structured data**: JSON/CSV output splits across lines - breaks parsers and data processing
- **Commands**: Wrapped command strings can't be copy-pasted to execute
- **Error investigation**: Stack traces and file paths fragment across lines - harder to trace issues
**In interactive TTY (terminal), wrapping is good** - optimizes for human reading at terminal width.
**The solution must detect context and apply different behavior:**
- **TTY (interactive)**: Use terminal width, wrap for human readability
- **Non-TTY (CI/logs)**: Never wrap, optimize for machine parsing
### The Solution
Use `crop=False` + `overflow="ignore"` on `console.print()` calls:
```python
from rich.console import Console
console = Console()
# For text that should never wrap (URLs, commands, paths)
console.print(long_url, crop=False, overflow="ignore")
# For normal text that can wrap
console.print(normal_text)
```
### Example Script
[console_no_wrap_example.py](./console_no_wrap_example.py) demonstrates:
- The problem (default wrapping behavior)
- The solution (using crop=False + overflow="ignore")
- Usage patterns for different text types
## Problem 2: Rich Containers (Panel/Table) Wrapping Content
### The Problem
Rich containers like `Panel` and `Table` wrap content internally even when using `crop=False, overflow="ignore"` on the print call. This is because:
- Containers calculate their own internal layout
- Console width (default 80 in non-TTY) constrains container rendering
- Content wraps inside the container before `crop=False` can prevent it
### The Solution
Use a helper function to measure the actual rendered width, then apply width differently for Panel vs Table:
```python
from rich.console import Console, RenderableType
from rich.measure import Measurement
from rich.panel import Panel
from rich.table import Table
def get_rendered_width(renderable: RenderableType) -> int:
"""Get actual rendered width of any Rich renderable.
Handles color codes, Unicode, styling, padding, and borders.
Works with Panel, Table, or any Rich container.
"""
temp_console = Console(width=9999)
measurement = Measurement.get(temp_console, temp_console.options, renderable)
return int(measurement.maximum)
console = Console()
# Panel: Set Console width (Panel fills Console width)
panel = Panel(long_content)
panel_width = get_rendered_width(panel)
console.width = panel_width # Set Console width, NOT panel.width
console.print(panel, crop=False, overflow="ignore", no_wrap=True, soft_wrap=True)
# Table: Set Table width (Table controls its own width)
table = Table()
table.add_column("Type", style="cyan", no_wrap=True)
table.add_column("Value", style="green", no_wrap=True)
table.add_row("Data", long_content)
table.width = get_rendered_width(table) # Set Table width
console.print(table, crop=False, overflow="ignore", no_wrap=True, soft_wrap=True)
```
### Example Script
[console_containers_no_wrap.py](./console_containers_no_wrap.py) demonstrates:
- Default Panel/Table wrapping behavior
- Why `crop=False` alone doesn't work for containers
- The `get_rendered_width()` helper function
- Complete working examples for both Panel and Table
- Comparison of different approaches
## When to Use Each Technique
**Use `crop=False, overflow="ignore"` for:**
- Plain text output
- URLs, file paths, commands that must stay on single lines
- Text that doesn't use Rich containers
**Use `get_rendered_width()` + set width on container for:**
- Panel with long content
- Table with long cell values
- Any Rich container that wraps content
- Structured output that must preserve exact formatting
## Related Documentation
- [Rich Console Documentation](https://rich.readthedocs.io/en/stable/console.html)
- [Rich Panel Documentation](https://rich.readthedocs.io/en/stable/panel.html)
- [Rich Table Documentation](https://rich.readthedocs.io/en/stable/tables.html)
- [Typer Documentation](https://typer.tiangolo.com/)

View File

@@ -0,0 +1,48 @@
"""Compute the version number and store it in the `__version__` variable.
Based on <https://github.com/maresb/hatch-vcs-footgun-example>.
"""
def _get_hatch_version() -> str | None:
"""Compute the most up-to-date version number in a development environment.
Returns `None` if Hatchling is not installed, e.g. in a production environment.
For more details, see <https://github.com/maresb/hatch-vcs-footgun-example/>.
"""
import os
try:
from hatchling.metadata.core import ProjectMetadata
from hatchling.plugin.manager import PluginManager
from hatchling.utils.fs import locate_file
except ImportError:
# Hatchling is not installed, so probably we are not in
# a development environment.
return None
pyproject_toml = locate_file(__file__, "pyproject.toml")
if pyproject_toml is None:
raise RuntimeError("pyproject.toml not found although hatchling is installed")
root = os.path.dirname(pyproject_toml)
metadata = ProjectMetadata(root=root, plugin_manager=PluginManager())
# Version can be either statically set in pyproject.toml or computed dynamically:
return metadata.core.version or metadata.hatch.version.cached
def _get_importlib_metadata_version() -> str:
"""Compute the version number using importlib.metadata.
This is the official Pythonic way to get the version number of an installed
package. However, it is only updated when a package is installed. Thus, if a
package is installed in editable mode, and a different version is checked out,
then the version number will not be updated.
"""
from importlib.metadata import version
__version__ = version(__package__ or __name__)
return __version__
__version__ = _get_hatch_version() or _get_importlib_metadata_version()