Python CLI Frameworks
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against Python 3.12, Typer 0.27.2, Click 8.5.0, Rich 15.0.0, Textual 8.2.8 and the docsctl running example
of this sub-section, as checked on 2026-09-30. The Python snippets were run against those versions (the tests with pytest).
The fictional docsctl exists only on these pages, and its behaviour here is illustrative.
The rules in the earlier pages — data on stdout, messages on stderr, stable exit codes, JSON output, no prompting without a
terminal — do not depend on the language. What differs is how much work a framework does for you. This page shows what
each Python option gives you for free and what you must add yourself, using the docsctl contract (search, publish,
--json, --no-input, exit code 5 for "confirmation required"). The basics of the language itself are in
Python.
| Library | What it is | Strengths for agent-friendly tools | Watch out for |
|---|---|---|---|
|
Standard library parser |
No dependency; starts fast; exit code 2 on usage errors; |
Verbose; no built-in JSON schema of the command tree; help text and subcommands are all manual. |
Click |
Decorator-based framework |
Mature; groups and subcommands; |
Type hints are not the source of truth; you write decorators for every option. |
Typer |
Type-hint layer built on Click |
Options and arguments come from function signatures; Rich-formatted help; |
Inherits Click’s model; importing Rich and Click at start-up costs a little time. |
Rich |
Terminal rendering library |
Tables, progress bars and spinners that disable themselves off a terminal |
A rendering library, not a parser; must be kept off stdout data. |
Textual |
Full-screen TUI framework |
Rich interactive views for people |
Never a substitute for a non-interactive mode (see TUIs vs. CLIs). |
argparse: the standard library baseline
argparse ships with Python, adds nothing to the install, and does the essentials: --help generated from the declarations,
subcommands through add_subparsers, and exit status 2 when the command line is wrong. Two settings matter most for tools that agents
will drive. allow_abbrev=False stops --comp from silently meaning --component, which is the ambiguous prefix
matching that CLI Design Fundamentals advises against. add_subparsers(required=True)
makes a missing subcommand an error instead of a silent no-op.
import argparse
import json
import sys
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="docsctl", allow_abbrev=False)
parser.add_argument("--json", action="store_true", help="Emit JSON on stdout.")
sub = parser.add_subparsers(dest="command", required=True)
search = sub.add_parser("search", allow_abbrev=False, help="Search the documentation.")
search.add_argument("query")
search.add_argument("--component")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv) # usage errors exit with status 2
if args.command == "search":
result = {"schemaVersion": 1, "query": args.query, "total": 0, "results": []}
print(json.dumps(result, sort_keys=True) if args.json else f"0 results for {args.query}")
return 0
if __name__ == "__main__":
sys.exit(main())
parse_args writes usage errors to stderr and exits with 2, which matches the USAGE row of the contract. Every other exit
code you choose yourself, so argparse suits a tool with two or three commands and falls behind as the tree grows.
Reference: Python — argparse.
Click: the mature decorator framework
Click builds commands from decorated functions. @click.group() creates a parent command, @group.command() adds a
subcommand, and options and arguments are declared with @click.option and @click.argument. Its click.echo writes to
stdout by default and to stderr with err=True, and click.Choice validates enumerated values. Usage errors exit with status
2, as with argparse.
import sys
import click
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
def cli() -> None:
"""Search and publish this site's documentation."""
@cli.command()
@click.option("--component", required=True, help="Component to publish.")
@click.option("--dry-run", is_flag=True, help="Show the plan only.")
@click.option("--yes", "-y", is_flag=True, help="Assume yes for confirmations.")
def publish(component: str, dry_run: bool, yes: bool) -> None:
"""Publish a component (destructive unless --dry-run)."""
if dry_run:
click.echo('{"dryRun": true, "schemaVersion": 1}')
return
if not yes:
click.echo("docsctl: publish needs confirmation; re-run with --yes or use --dry-run.", err=True)
sys.exit(5) # CONFIRMATION_REQUIRED
if __name__ == "__main__":
cli()
Click is the engine under Typer, so everything learned here carries over; choose plain Click when you want explicit decorators and no type-hint magic. Reference: Click documentation.
Typer: the primary docsctl example
Typer derives the command line from ordinary function signatures and type hints, so the signature is the schema: a
bool becomes a flag, an Enum becomes a validated choice, an int with min and max becomes a bounded number, and the docstring
becomes the help text. This suits agent-facing tools because the declared types and the help stay in one place. The
following is the primary Python implementation used throughout this sub-section. It shows the global options (--json,
--format, --limit, --no-input) handled by an @app.callback(), one read-only command and one destructive command with
the fail-fast confirmation rule from
TTY, Colour & Interactivity.
import json
from enum import Enum
from typing import Annotated, Optional
import typer
EXIT_CONFIRMATION_REQUIRED = 5
app = typer.Typer(
name="docsctl",
help="Search and publish this site's documentation.",
no_args_is_help=True,
add_completion=False,
pretty_exceptions_enable=False, # no stack traces or Rich panels on internal errors
context_settings={"help_option_names": ["-h", "--help"]},
)
class Format(str, Enum):
text = "text"
json = "json"
jsonl = "jsonl"
class State: # global options, filled by the callback
fmt: Format = Format.text
limit: int = 10
state = State()
@app.callback()
def main(
fmt: Annotated[Format, typer.Option("--format", help="Output format.", envvar="DOCSCTL_FORMAT")] = Format.text,
as_json: Annotated[bool, typer.Option("--json", help="Alias for --format json.")] = False,
limit: Annotated[int, typer.Option(min=1, max=100, help="Maximum results.", envvar="DOCSCTL_LIMIT")] = 10,
no_input: Annotated[bool, typer.Option("--no-input", help="Never prompt.", envvar="DOCSCTL_NO_INPUT")] = False,
) -> None:
state.fmt = Format.json if as_json else fmt
state.limit = limit
@app.command()
def search(
query: Annotated[str, typer.Argument(help="Words to look for.")],
component: Annotated[Optional[str], typer.Option(help="Restrict to one component.")] = None,
) -> None:
"""Search the documentation.
Example: docsctl search kalman --component irurueta-statistics --json
"""
payload = {"schemaVersion": 1, "query": query, "total": 0, "nextCursor": None, "truncated": False, "results": []}
if state.fmt is Format.text:
typer.echo(f"{payload['total']} result(s) for {query}")
else:
typer.echo(json.dumps(payload, sort_keys=True)) # data: stdout only
@app.command()
def publish(
component: Annotated[str, typer.Option(help="Component to publish.")],
dry_run: Annotated[bool, typer.Option("--dry-run", help="Show the plan only.")] = False,
yes: Annotated[bool, typer.Option("--yes", "-y", help="Assume yes for confirmations.")] = False,
) -> None:
"""Publish a component (destructive unless --dry-run)."""
if dry_run:
typer.echo(json.dumps({"schemaVersion": 1, "dryRun": True, "component": component}))
return
if not yes:
typer.echo("docsctl: publish needs confirmation; re-run with --yes or use --dry-run.", err=True)
raise typer.Exit(code=EXIT_CONFIRMATION_REQUIRED)
if __name__ == "__main__":
app()
Points worth noticing:
-
envvar=binds a flag to aDOCSCTL_*variable, which gives the part of the precedence rule from Configuration & Auth for free. -
typer.Exit(code=…)ends the command with a chosen status. Typer and Click report usage errors with status 2 on their own. -
no_args_is_help=Trueprints help instead of failing silently when a person types justdocsctl. -
pretty_exceptions_enable=Falsekeeps unexpected errors as plain tracebacks instead of decorated panels, which is what a log or an agent wants to read. Catch your own exceptions, print the error envelope from the machine-readable output page and exit with the contract code, so a traceback appears only for genuine bugs. -
Only data goes to stdout; the confirmation message goes to stderr with
err=True.
The --json global option lives in a callback, so it comes before the subcommand (docsctl --json search kalman). Frameworks
place global options differently, and per-command duplicates such as docsctl search kalman --json need a second declaration. Decide
once and document it in --help, as Self-Describing Help recommends.
Reference: Typer documentation.
Testing with CliRunner
Both Click and Typer ship a CliRunner that invokes the command in-process, so tests run in milliseconds and can assert on
exit code, stdout and stderr separately, which is the property an agent depends on. Typer’s runner comes from
typer.testing, and Click’s from click.testing. The Typer documentation lists result.exit_code, result.output (stdout
and stderr combined), result.stdout and result.stderr.
import json
from typer.testing import CliRunner
from docsctl import app
runner = CliRunner()
def test_search_json_is_pure_stdout():
result = runner.invoke(app, ["--json", "search", "kalman"])
assert result.exit_code == 0
assert json.loads(result.stdout)["schemaVersion"] == 1 # stdout parses as JSON
assert result.stderr == "" # nothing leaked to stderr
def test_publish_without_yes_fails_fast():
result = runner.invoke(app, ["--no-input", "publish", "--component", "irurueta-algebra"])
assert result.exit_code == 5
assert "--yes" in result.stderr # the message names the bypass flag
assert result.stdout == ""
def test_usage_error_is_2():
result = runner.invoke(app, ["search"]) # missing QUERY
assert result.exit_code == 2
These three tests encode the contract: JSON output is parseable with nothing else on stdout, a missing confirmation fails
fast and names its bypass flag, and a usage error is 2. CliRunner replaces the streams in the current process, so it
does not exercise a real terminal or the Python start-up cost; add a handful of subprocess tests against the installed entry
point for those. Click’s CliRunner.isolated_filesystem() is documented as deprecated and slated for removal in Click 9.0,
so use a temporary directory and absolute paths in new tests. Reference:
Typer — Testing and
Click — Testing Click Applications.
Rich output only on a TTY
Typer uses Rich when it is installed to format help and error output, and Rich is also the usual choice for tables and progress bars. Two rules keep it from spoiling machine output:
-
Print data with
typer.echoorprint, never through a RichConsolethat decorates it. Rich is for commentary and human views. -
Send Rich to stderr (
Console(stderr=True)) and let it turn itself off: aConsolereportsis_terminal, honoursNO_COLOR, and skips colour for pipes and files.
import json
import sys
from rich.console import Console
from rich.table import Table
def show_components(components: list[dict]) -> None:
console = Console() # stdout
if console.is_terminal and not console.is_dumb_terminal:
table = Table(title="Components")
table.add_column("Name")
table.add_column("Latest")
for item in components:
table.add_row(item["name"], item["latest"])
console.print(table)
else:
sys.stdout.write(json.dumps({"schemaVersion": 1, "components": components}, sort_keys=True) + "\n")
Choosing output by terminal detection is a convenience for people, not a way to select the machine format. The machine
format should also be selectable explicitly with --json, as the contract states, because an agent’s tool call is
sometimes attached to a pseudo-terminal. The colour, NO_COLOR and progress-bar rules are in
TTY, Colour & Interactivity. Reference:
Rich documentation.
Textual for full-screen interfaces
Textual is a framework for terminal and web user interfaces, built around an App class, widgets, CSS-style
styling and an automated-testing "pilot". Use it for the dashboard-like part of a tool (browsing search results, reviewing a
publish plan) and never as the only way to do something. The reasoning, and a comparison with the equivalents in other
languages, is on TUIs vs. CLIs.
import sys
from textual.app import App, ComposeResult
from textual.widgets import DataTable, Footer, Header
# Rows as returned in "results" by `docsctl search --json -- kalman`.
RESULTS = [
{"id": "irurueta-statistics::kalman.adoc", "title": "Kalman filter", "score": 0.91},
{"id": "irurueta-algebra::index.adoc", "title": "Irurueta Algebra", "score": 0.42},
]
class BrowseApp(App):
"""`docsctl browse`: an interactive view over search results."""
BINDINGS = [("q", "quit", "Quit")]
def compose(self) -> ComposeResult:
yield Header()
yield DataTable(cursor_type="row")
yield Footer()
def on_mount(self) -> None:
table = self.query_one(DataTable)
table.add_columns("Page", "Title", "Score")
for r in RESULTS:
table.add_row(r["id"], r["title"], f"{r['score']:.2f}")
if __name__ == "__main__":
if not (sys.stdin.isatty() and sys.stdout.isatty()):
sys.stderr.write("docsctl: browse needs a terminal; use `docsctl search --json` instead.\n")
sys.exit(2)
BrowseApp().run()
compose yields the widgets, on_mount fills the table once the app is running, and the q binding maps to the built-in
quit action. The terminal check runs before App.run, so a pipe or an agent gets exit code 2 and a pointer to
search --json instead of a full-screen interface. With Textual 8.2.8 on Python 3.12 the file passed python -m py_compile,
a test through App.run_test() found the two rows and quit with q, and running it with standard input redirected printed the
message and exited with code 2. Reference: Textual documentation.
Packaging the entry point
A Python CLI is installed by declaring a console-script entry point in pyproject.toml
([project.scripts], for example docsctl = "docsctl:app"), after which pipx or uv tool install puts a docsctl
executable on the PATH. Keep the entry-point module cheap to import, because every invocation pays for it; the guidance on fast
start-up in Agent Ergonomics applies directly. Distribution is covered on
Packaging & Distribution, and testing the installed tool on Testing CLIs.