| name | python-cli-engineering |
| description | Engineer production-grade Python CLI tools with UV for package management, Ruff for linting, Pyright for strict typing, Typer for commands, and Rich for polished output. Addresses fail-fast patterns, pydantic-settings configuration, modular code organization, and professional UX conventions. Apply when creating admin utilities, data processors, or developer tooling. |
Python CLI Engineering
Modern patterns for building production-grade Python command-line applications.
When to Use This Skill
Use when building:
- Command-line tools and utilities
- Data processing applications
- Admin/operations tooling
- Developer utilities
- ETL/sync scripts with user interaction
- Analysis tools with formatted output
- Database management CLIs
Technology Stack Overview
Core Tools
- UV - Package manager (10-100x faster than pip/poetry)
- Ruff - Linting + formatting (Rust-based, replaces 6+ tools)
- Pyright - Type checking in strict mode (3-5x faster than mypy)
- Typer - CLI framework with type hints + auto-help
- Rich - Console output with colors, tables, progress bars
Installation:
curl -LsSf https://astral.sh/uv/install.sh | sh
uv init my-cli
uv add typer rich
For complete tool guides, configuration examples, and advanced patterns:
See @references/tech-stack.md
Fail-Fast Discipline
Core Principles
- No default values (except in explicit config files)
- No try/catch swallowing (let exceptions propagate)
- No fallback mechanisms (fail immediately on errors)
- Explicit configuration (missing config = app stops)
- Type everything (strict Pyright mode)
Exception Handling Pattern
class AppError(Exception):
"""Base exception - let it bubble up."""
pass
def main() -> None:
"""Main entry point - ONLY catch at top level."""
try:
app()
except AppError as e:
console.print(f"[red]ERROR: {e}[/red]")
raise typer.Exit(code=1) from e
Key Rules:
- ❌ Never:
except Exception: pass or except: return None
- ✅ Always: Let exceptions bubble to main entry point
- ✅ Always: Use specific exception types for different failure modes
- ✅ Always: Exit with non-zero code on failure
Modular Architecture
Module Size Constraints
CRITICAL RULE: 500 lines maximum per module
When module approaches 500 lines:
- Extract related functions to new module
- Split by domain/responsibility
- Create subpackage if >3 related modules
Project Structure
my_cli/
├── pyproject.toml # UV project config
├── .env.example # Template for .env
├── config.yaml # Application defaults
├── Makefile # Development tasks
└── src/
└── my_cli/
├── __init__.py
├── __main__.py # Entry point
├── cli/ # CLI commands
│ ├── __init__.py
│ └── commands.py # Typer app
├── core/ # Business logic
│ ├── __init__.py
│ ├── config.py # pydantic-settings
│ └── exceptions.py
├── db/ # Database layer
│ ├── __init__.py
│ ├── models.py # SQLAlchemy models
│ └── connection.py
└── services/ # External integrations
└── __init__.py
For complete architecture patterns, module organization, and size management:
See @references/architecture.md
Configuration Pattern
Dual Configuration (YAML + .env)
Pattern: Merge YAML defaults with .env overrides using pydantic-settings
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
db_host: str = "localhost"
db_port: int = 5432
db_name: str
db_user: str
db_password: str
api_key: str
api_url: str = "https://api.example.com"
settings = Settings()
config.yaml (defaults):
db_host: localhost
db_port: 5432
api_url: https://api.example.com
.env (secrets):
DB_PASSWORD=secret
API_KEY=abc123
For complete configuration patterns, validation, and advanced examples:
See @references/configuration.md
Quick Start
1. Initialize Project
uv init my-cli
cd my-cli
uv add typer rich pydantic-settings pyyaml
uv add --dev ruff pyright pytest
2. Create Entry Point
import typer
from rich.console import Console
app = typer.Typer()
console = Console()
@app.command()
def hello(name: str) -> None:
"""Say hello."""
console.print(f"[green]Hello {name}![/green]")
if __name__ == "__main__":
app()
3. Configure Tools
Copy configuration templates:
4. Run
uv run python -m my_cli hello World
Development Workflow
Standard Tasks (via Makefile)
make lint
make type
make test
make check
make clean
Pre-commit Checks
Always run before committing:
make check
Why: Catches type errors, style issues, and test failures early
Common Patterns
Advanced Pattern References
Build integration: See patterns/make-integration.md - Integrate CLI with Makefiles
Multi-method authentication: See patterns/multi-method-auth.md - Support OAuth, TBA, API keys
PostgreSQL JSONB: See patterns/postgresql-jsonb.md - Flexible schema with JSONB columns
Pydantic flexibility: See patterns/pydantic-flexible.md - Handle dynamic/unknown fields
Schema resilience: See patterns/schema-resilience.md - Robust API schema handling
Database migrations: See @reference/database-migrations.md - Alembic migration patterns
CLI Command with Config
@app.command()
def sync(
config_file: Path = typer.Option("config.yaml"),
verbose: bool = typer.Option(False, "--verbose", "-v"),
) -> None:
"""Sync data from source."""
settings = Settings(_env_file=".env")
if verbose:
console.print(f"[yellow]Connecting to {settings.db_host}[/yellow]")
Database Connection with SQLAlchemy
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
Base = declarative_base()
engine = create_engine(f"postgresql://{settings.db_user}:{settings.db_password}@{settings.db_host}/{settings.db_name}")
Session = sessionmaker(bind=engine)
Rich Progress Bar
from rich.progress import track
for item in track(items, description="Processing..."):
process(item)
Error Handling
Custom Exception Hierarchy
class AppError(Exception):
"""Base for all app exceptions."""
pass
class ConfigurationError(AppError):
"""Config missing/invalid."""
pass
class DatabaseError(AppError):
"""DB operation failed."""
pass
class ExternalServiceError(AppError):
"""External API failed."""
pass
Usage in Commands
@app.command()
def process() -> None:
"""Process data."""
if not settings.api_key:
raise ConfigurationError("API_KEY not set in .env")
try:
response = api.fetch_data()
except Exception as e:
raise ExternalServiceError(f"API fetch failed: {e}") from e
Type Checking Best Practices
Strict Mode Configuration
[tool.pyright]
typeCheckingMode = "strict"
pythonVersion = "3.12"
reportMissingTypeStubs = true
reportUnknownMemberType = true
Common Type Patterns
from typing import Any
from collections.abc import Sequence
def process_items(items: Sequence[dict[str, Any]]) -> list[str]:
return [item["name"] for item in items]
from typing import Optional
def get_value(key: str) -> Optional[str]:
return cache.get(key)
from typing import TypeGuard
def is_string_list(val: list[Any]) -> TypeGuard[list[str]]:
return all(isinstance(x, str) for x in val)
Testing
Pytest Structure
tests/
├── conftest.py # Fixtures
├── test_cli.py # CLI command tests
├── test_config.py # Config loading tests
└── test_services.py # Service integration tests
Testing CLI Commands
from typer.testing import CliRunner
runner = CliRunner()
def test_hello_command():
result = runner.invoke(app, ["hello", "World"])
assert result.exit_code == 0
assert "Hello World" in result.stdout
Deployment
Building Distribution
uv build
ls dist/
Installing
uv pip install dist/my_cli-0.1.0-py3-none-any.whl
my-cli hello World
Key Principles Summary
- Fail Fast - No silent errors, no defaults for critical config
- Type Everything - Strict Pyright mode catches issues early
- Modular - 500-line max per module, clear separation of concerns
- Explicit Config - YAML defaults + .env secrets, no magic
- Rich Output - Professional CLI UX with colors and progress
- UV for Speed - 10-100x faster than traditional tools
- Test Early - pytest + type checking before commit
For detailed guides: See the references/ directory
For project templates: See the templates/ directory
End of Skill