ADR-070 — CLI framework: Typer¶
Status · Accepted; partially superseded by ADR-076 (2026-05-03) — the
maskssub-command group (list/generate/regenerate/tag/invalidate) was removed in v1.5.0; the CLI now has 1 sub-command group (vocab), not 2. Typer remains the framework choice and the rest of this ADR's reasoning stands. Date · 2026-05-03 TA anchor ·/components/cli Related RFC · RFC-020 (closes here)
Context¶
Implementing the CLI (per ADR-069) requires choosing a framework. The candidates were:
argparse— Python standard library, zero dependencyclick— widely used, decorator-basedtyper— built on Click, annotation-driven- Custom — hand-rolled argument parsing
The command surface is moderate: 22 verbs, 2 sub-command groups (vocab, masks), ~3 options per command on average, 5 global options shared across all verbs. This is well beyond where argparse is pleasant and well within Click/Typer territory.
Decision¶
Use Typer. Pinned to typer>=0.12 in pyproject.toml; uv.lock pins the exact version for CI reproducibility.
Rationale¶
Against argparse¶
argparse requires explicit add_argument calls for every parameter. For a 22-verb surface with shared global options and grouped subcommands, that's roughly 100 lines of repetitive setup code that type annotations plus Typer generate automatically. argparse's error messages and help formatting are also weaker.
The zero-dependency argument is real but not decisive: Typer brings Click + Rich transitively, both stable and widely used, and adding them does not meaningfully increase supply-chain risk for a project with darktable as a hard dependency.
Click vs Typer¶
Click is the foundation; Typer adds:
- Type annotations drive the CLI. Parameters are declared as Python function arguments with type hints. No separate
add_argumentcalls. For a 22-verb surface this reduces boilerplate by roughly 60%. - Auto-generated
--helpfrom docstrings. No separate help strings to maintain. typer.testing.CliRunner(from Click) for synchronous, deterministic testing — no subprocess required for integration tests.- Escape hatch to raw Click. Typer exposes the underlying Click command/group objects when needed. If Typer's abstractions become a constraint, the migration to raw Click is mechanical.
Against hand-rolled¶
Not seriously considered for a 22-verb surface. The maintenance cost of a custom parser grows with the number of commands.
Consequences¶
Positive:
- Annotation-driven CLI definition is concise and maintainable.
CliRunnermade the test suite straightforward — 80+ integration tests written without subprocess overhead.- Click's error handling is robust and covers edge cases (missing args, wrong types, ambiguous abbreviations) without us writing them.
- Auto-generated
chemigram --helpandchemigram <verb> --helpare accurate and complete.
Negative:
- Typer dependency. Adds Typer + Click + Rich to the dependency tree. Acceptable: these are stable, widely used, and their combined footprint is small.
typer.Option(...)as default argument value triggers ruff B008 — the canonical Typer idiom. Suppressed via per-file ignore inpyproject.tomlforchemigram.cli.mainandchemigram.cli.commands.*.- Mypy untyped-decorator warning on
@app.command— Typer's decorators don't type-narrow the wrapped function. Suppressed via per-module mypy overridedisallow_untyped_decorators = falseforchemigram.cli.*. - CliRunner API has shifted across Click versions. Click 8.2 removed
mix_stderrfromCliRunner.__init__; integration tests use the newer API (default-separated stderr). uv.lock is the version-pinning source of truth.
Alternatives considered¶
See "Rationale" above for argparse and hand-rolled rejections. Click directly was considered as a fallback if Typer's abstractions became a problem; not needed.