Initial commit
This commit is contained in:
47
skills/python3-development/assets/.editorconfig
Normal file
47
skills/python3-development/assets/.editorconfig
Normal 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
|
||||
38
skills/python3-development/assets/.markdownlint.json
Normal file
38
skills/python3-development/assets/.markdownlint.json
Normal 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
|
||||
}
|
||||
109
skills/python3-development/assets/.pre-commit-config.yaml
Normal file
109
skills/python3-development/assets/.pre-commit-config.yaml
Normal 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/
|
||||
)
|
||||
119
skills/python3-development/assets/hatch_build.py
Normal file
119
skills/python3-development/assets/hatch_build.py
Normal 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
|
||||
1
skills/python3-development/assets/nested-typer-exceptions/.gitignore
vendored
Normal file
1
skills/python3-development/assets/nested-typer-exceptions/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
broken.json
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
147
skills/python3-development/assets/typer_examples/console_containers_no_wrap.py
Executable file
147
skills/python3-development/assets/typer_examples/console_containers_no_wrap.py
Executable 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)
|
||||
52
skills/python3-development/assets/typer_examples/console_no_wrap_example.py
Executable file
52
skills/python3-development/assets/typer_examples/console_no_wrap_example.py
Executable 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")
|
||||
|
||||
|
||||
153
skills/python3-development/assets/typer_examples/index.md
Normal file
153
skills/python3-development/assets/typer_examples/index.md
Normal 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/)
|
||||
48
skills/python3-development/assets/version.py
Normal file
48
skills/python3-development/assets/version.py
Normal 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()
|
||||
Reference in New Issue
Block a user