CoursesAdvanced scripting for DevSecOpsAdvanced Python engineering

Robust CLIs: click, typing, dataclasses & packaging

Structured tools with subcommands, type hints, config and clean exit codes.

Advanced35 min · lesson 8 of 15

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.

a structured CLI with click
import click
@click.group()
@click.option("--verbose", "-v", count=True, help="repeat for more detail")
@click.pass_context
def 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_context
def rotate(ctx, target, dry_run):
"Rotate credentials for TARGET."
if dry_run:
click.echo(f"[dry-run] would rotate {target}"); return
... # real work
if __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.

typed config with clear precedence
from dataclasses import dataclass
import os
@dataclass(frozen=True)
class Config:
endpoint: str
timeout: float = 10.0
concurrency: int = 16
def load_config(flag_endpoint: str | None) -> Config:
# precedence: CLI flag > env > default
endpoint = 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.

pyproject.toml — an installable command
[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"
Exit codes as an API
10
success
21
generic/unexpected error
32
usage error (bad args)
43+
domain failures CI can branch on
Reserve small, documented codes for the failure classes callers care about; a tool that only ever returns 0 or 1 forces callers to scrape stderr.
Do not catch-all and exit 0
A top-level except Exception: that logs and falls through to a normal exit turns every failure into a success from the shell’s point of view. Let unexpected exceptions propagate (Python already exits non-zero with a traceback), or catch specifically and exit with a deliberate non-zero code. Swallowing exceptions to keep output clean is how a broken job reports green.