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.

Table 1. The Python options at a glance
Library What it is Strengths for agent-friendly tools Watch out for

argparse

Standard library parser

No dependency; starts fast; exit code 2 on usage errors; allow_abbrev=False turns off prefix matching

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; CliRunner; click.echo(err=True); Context.exit

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; typer.Exit(code=…​); CliRunner

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.

A small docsctl search with argparse
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.

The publish confirmation rule in Click
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.

docsctl in Typer (docsctl.py)
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 a DOCSCTL_* variable, which gives the flags  env 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=True prints help instead of failing silently when a person types just docsctl.

  • pretty_exceptions_enable=False keeps 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.

Contract tests for docsctl (test_docsctl.py)
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:

  1. Print data with typer.echo or print, never through a Rich Console that decorates it. Rich is for commentary and human views.

  2. Send Rich to stderr (Console(stderr=True)) and let it turn itself off: a Console reports is_terminal, honours NO_COLOR, and skips colour for pipes and files.

A human table on a terminal, JSON otherwise
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.

browse.py — search results in a Textual DataTable
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.