Robust CLIs: click, typing, dataclasses & packaging
Structured tools with subcommands, type hints, config and clean exit codes.
A one-off script grows into a tool your team depends on, and at that point structure stops being optional. Real CLIs have subcommands, typed arguments, sensible exit codes, configuration that layers env over flags over defaults, and a package layout that lets people install and import them. The goal is a tool that is obvious to run, hard to misuse, and easy to test.
Subcommands and typed arguments
argparse ships with Python and handles subcommands, types, and help; click and typer add decorators and nicer ergonomics. Whichever you pick, the principles are the same: give every flag a type and a default, group them into subcommands for distinct actions, support --dry-run for anything destructive, and return a meaningful exit code. Do not read sys.argv by hand — you will reimplement argument parsing badly.
import click@click.group()@click.option("--verbose", "-v", count=True, help="repeat for more detail")@click.pass_contextdef cli(ctx, verbose):ctx.obj = {"verbose": verbose}@cli.command()@click.argument("target")@click.option("--dry-run", is_flag=True, help="show what would happen")@click.pass_contextdef rotate(ctx, target, dry_run):"Rotate credentials for TARGET."if dry_run:click.echo(f"[dry-run] would rotate {target}"); return... # real workif __name__ == "__main__":cli()
Types, dataclasses, and configuration
Type hints are documentation the machine checks: run mypy and a whole class of "wrong shape" bugs never ships. Dataclasses give you a typed record for config and results with almost no boilerplate. For configuration, resolve in a clear precedence — explicit flag beats environment variable beats config file beats default — and validate once at startup so the rest of the program can trust its inputs.
from dataclasses import dataclassimport os@dataclass(frozen=True)class Config:endpoint: strtimeout: float = 10.0concurrency: int = 16def load_config(flag_endpoint: str | None) -> Config:# precedence: CLI flag > env > defaultendpoint = flag_endpoint or os.environ.get("APP_ENDPOINT")if not endpoint:raise SystemExit("error: --endpoint or APP_ENDPOINT is required")return Config(endpoint=endpoint,timeout=float(os.environ.get("APP_TIMEOUT", "10")),)
Exit codes and packaging
Exit codes are your tool’s contract with the shell and CI: 0 for success, distinct non-zero codes for distinct failure classes so callers can branch. Package the tool with a pyproject.toml and a console-script entry point so pip install gives users a real command on their PATH, not a python path/to/script.py incantation. That is what separates a shared tool from a snippet.
[project]name = "sec-tools"version = "1.2.0"requires-python = ">=3.10"dependencies = ["click>=8", "httpx>=0.27"][project.scripts]sec-rotate = "sec_tools.cli:cli" # -> installs a `sec-rotate` command[build-system]requires = ["hatchling"]build-backend = "hatchling.build"