Contents
33 Chapter

Building CLI tools (Typer)

How to build a type-hints-first CLI with Typer instead of argparse. Subcommands, autocompletion, and Rich-powered output.

This is the last chapter of the book. As the close of Part 5 (Operations · Packaging · Testing), it covers how to build a CLI type-hints-first. Ship the result of this chapter through Chapter 32’s flow on PyPI, and the path from CLI implementation to distribution is complete.

The tool is Typer — a CLI framework by the same author as FastAPI, sharing the same philosophy: the function signature is the CLI interface.

The limits of argparse #

The reason to pick Typer over the standard-library argparse:

Same thing in argparse
import argparse

def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("name", type=str)
    parser.add_argument("--age", type=int, default=0)
    parser.add_argument("--greet", action="store_true")
    args = parser.parse_args()

    if args.greet:
        print(f"Hello, {args.name}! You are {args.age}.")

Three problems.

  • No type hints — the IDE doesn’t know the type of args.name.
  • Lots of boilerplate — the parser setup is longer than what the function does.
  • You write validation by hand — Enum, Path, ranges all need extra code.

Typer compresses the same code:

Same thing in Typer
import typer

def main(name: str, age: int = 0, greet: bool = False) -> None:
    if greet:
        print(f"Hello, {name}! You are {age}.")

if __name__ == "__main__":
    typer.run(main)

The type hint is the validation, the default is the option default, and a bool is a flag.

Install and first run #

uv add typer
hello.py
import typer

def main(name: str) -> None:
    print(f"Hello, {name}!")

if __name__ == "__main__":
    typer.run(main)
$ uv run python hello.py World
Hello, World!

$ uv run python hello.py --help
                                                                                
 Usage: hello.py [OPTIONS] NAME                                                 
                                                                                
╭─ Arguments ────────────────────────────────────────────────────────────────╮
│ *    name      TEXT  [default: None] [required]                            │
╰────────────────────────────────────────────────────────────────────────────╯

--help is generated automatically.

Options vs arguments #

The rule is simple.

  • No default → argument — passed by position. mycli hello World.
  • Has a default → option--name=World or --name World.
def main(name: str, age: int = 0) -> None:
    ...
  • name → required positional argument.
  • age--age=30 optional.

A bool default of False is natural, and you automatically get both --flag / --no-flag forms.

def main(verbose: bool = False) -> None: ...
$ mycli --verbose
$ mycli --no-verbose

The Annotated pattern — separating metadata #

The basic form is clean, but to add help text, short names, value validation, etc., Annotated is the standard pattern.

annotated_example.py
from typing import Annotated

import typer


def main(
    name: Annotated[str, typer.Argument(help="Tell me your name.")],
    age: Annotated[int, typer.Option("--age", "-a", min=0, max=150)] = 0,
    verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
) -> None:
    if verbose:
        print(f"name={name}, age={age}")
    print(f"Hello, {name}!")
$ uv run python annotated_example.py --help
...
 -a, --age          INTEGER RANGE [0<=x<=150]
 -v, --verbose / --no-verbose

With Annotated[X, typer.Option(...)] you keep the type and add only the metadata. This pattern is the same consistent style you’ve seen across the book — Pydantic, FastAPI, Typer all share it.

Subcommands — the Typer app tree #

To build subcommands like git’s mycli init, mycli build, mycli publish, create a typer.Typer instance and decorate with @app.command().

src/swcli/cli.py
import typer

app = typer.Typer(help="Schoolofweb sample CLI")


@app.command()
def init(path: str = ".") -> None:
    """Initialize the project."""
    typer.echo(f"init at {path}")


@app.command()
def build() -> None:
    """Build the project."""
    typer.echo("building...")


@app.command()
def publish(prod: bool = False) -> None:
    """Publish."""
    target = "PRODUCTION" if prod else "STAGING"
    typer.echo(f"publishing to {target}")


if __name__ == "__main__":
    app()
$ swcli --help
 Commands
   init     Initialize the project.
   build    Build the project.
   publish  Publish.

$ swcli publish --prod
publishing to PRODUCTION

Nesting — sub-subcommands #

auth_app = typer.Typer(help="Auth-related commands.")
app.add_typer(auth_app, name="auth")


@auth_app.command()
def login(email: str) -> None:
    typer.echo(f"logging in as {email}")


@auth_app.command()
def logout() -> None:
    typer.echo("logged out")
$ swcli auth login me@example.com
logging in as me@example.com

You can build deep trees like git’s git remote add or kubectl’s kubectl config set-context.

Integrating with Rich — color · progress · tables #

Typer uses Rich internally — use Rich’s Console, Progress, and Table directly instead of typer.echo and the UX becomes richer.

Console #

from rich.console import Console

console = Console()
console.print("[bold green]Success![/]")
console.print("[red]Failed:[/] connection refused")

Progress #

progress for long-running work
import time

from rich.progress import Progress


@app.command()
def crawl(pages: int = 100) -> None:
    with Progress() as progress:
        task = progress.add_task("[cyan]Crawling...", total=pages)
        for _ in range(pages):
            time.sleep(0.05)
            progress.advance(task)

Progress automatically draws percentage, ETA, and a spinner.

Table #

render a result as a table
from rich.table import Table


@app.command()
def users() -> None:
    table = Table(title="Users")
    table.add_column("ID", justify="right", style="cyan")
    table.add_column("Email", style="magenta")
    table.add_column("Active", justify="center")

    table.add_row("1", "alice@example.com", "✔")
    table.add_row("2", "bob@example.com", "✘")

    console.print(table)

In environments where the console doesn’t support color (CI logs, pipes), Rich turns color off automatically.

Auto-mapping environment variables #

Give an option envvar= and it falls back to reading from the environment when no CLI argument was passed.

@app.command()
def deploy(
    api_key: Annotated[
        str,
        typer.Option(envvar="SWCLI_API_KEY", help="API key"),
    ],
) -> None:
    typer.echo(f"deploying with {api_key[:4]}***")
$ export SWCLI_API_KEY=secret123
$ swcli deploy
deploying with secr***

Naturally fits the standard pattern of passing secrets via env vars in CI.

Shell completion (bash · zsh · fish) #

Typer installs shell completion in one command.

swcli --install-completion

After installation, swcli p<TAB> completes to publish. Subcommand options (--prod) also complete — a big drop in user learning cost.

Exit codes and error handling #

The CLI convention is 0 = success, non-zero = failure. In Typer, throw typer.Exit.

@app.command()
def lint(path: str) -> None:
    errors = run_lint(path)
    if errors:
        typer.secho(f"{len(errors)} errors", fg="red", err=True)
        raise typer.Exit(code=1)
    typer.secho("OK", fg="green")
  • err=True → emit to stderr (so error messages are still visible when the user redirects stdout to a file).
  • raise typer.Exit(code=1) → sets the exit code.

For a tool that composes with scripts, separating stdout and stderr matters.

Testing — CliRunner #

Typer exposes click’s CliRunner directly. It tests in-process, without spawning a real process.

tests/test_cli.py
from typer.testing import CliRunner

from swcli.cli import app

runner = CliRunner()


def test_hello() -> None:
    result = runner.invoke(app, ["init", "/tmp/myproj"])
    assert result.exit_code == 0
    assert "init at /tmp/myproj" in result.stdout


def test_publish_prod() -> None:
    result = runner.invoke(app, ["publish", "--prod"])
    assert result.exit_code == 0
    assert "PRODUCTION" in result.stdout


def test_missing_required_arg() -> None:
    result = runner.invoke(app, ["init"])
    assert result.exit_code == 0   # init's path has a default, so OK

Construct it with mix_stderr=False to assert against stderr separately.

Relationship to Click #

Typer uses Click internally — Typer = a type-hints-first wrapper around Click. Click’s rich features (Context, pass_context, callbacks) still work the same way in Typer.

You can add new commands to an existing Click codebase with Typer, or mix Click’s advanced features into parts of a Typer-built tool.

Common pitfalls #

Position of typer.Option’s default #

With Annotated, the default goes after the = in the function signature.

# OK
def f(x: Annotated[int, typer.Option()] = 0) -> None: ...

# Not recommended (old style)
def f(x: int = typer.Option(0)) -> None: ...

The old style works, but pyright’s strict mode is awkward about inferring the default’s type.

Module not exposed as the entry point #

If the right side of project.scripts from Chapter 32 is wrong, the command won’t be found after install.

[project.scripts]
swcli = "swcli.cli:app"   # OK — the app object in src/swcli/cli.py

Verify with which swcli after install.

--no- prefix on bool options is not what you wanted #

Writing use_cache: bool = True produces --use-cache / --no-use-cache automatically. If --no-use-cache feels awkward, name the option explicitly.

def f(
    no_cache: Annotated[bool, typer.Option("--no-cache")] = False,
) -> None: ...

print vs typer.echo vs console.print #

  • print — most general. Usually fine.
  • typer.echo — click’s echo. Handles Windows colorama compatibility automatically.
  • console.print — supports Rich markup ([bold red]).

For complex output use console.print, for simple messages print is enough. Pick one within a project.

Import cost on a large command tree #

Importing every module that carries @app.command() slows CLI startup. Lazy-import rarely used commands or apply click’s LazyGroup pattern.

Pairing with Chapter 32 — release flow #

Add just the following to pyproject.toml and this chapter’s CLI is installable as a package.

pyproject.toml — Chapter 33 + Chapter 32
[project]
name = "swcli"
version = "0.1.0"
dependencies = [
  "typer>=0.12,<1",
  "rich>=13,<15",
]

[project.scripts]
swcli = "swcli.cli:app"

Run uv build && uv publish along Chapter 32’s flow and your users can do pip install swcli and then swcli init, swcli publish --prod, etc.

At the end of this book #

A quick recap for those who made it here:

  • Part 1 (Chapters 1–7) covered variables · type hints · control flow · collections · functions · errors · pyproject.
  • Part 2 (Chapters 8–14) covered dataclass · Generic · Protocol · context managers · generators · decorators · pattern matching · asyncio intro.
  • Part 3 (Chapters 15–21) went into magic methods · descriptors · metaclasses · asyncio in depth · GIL · advanced typing · performance.
  • Part 4 (Chapters 22–29) built a full web service end to end — FastAPI · Pydantic · SQLAlchemy · auth · background jobs · testing and deployment · capstone.
  • Part 5 (Chapters 30–33) covered static verification · observability · publishing libraries · CLIs — refining things into operable tools.

All of this connects through one consistent mental model in a single language — Python. Type hints become validation, documentation, and autocompletion; function signatures become routes and CLIs; async ties web · background · DB into the same pattern; the toolchain gathers in one pyproject.toml.

Exercises #

  1. Run swcli through the release flow — Build this chapter’s swcli as-is and publish 0.0.1 to TestPyPI through Chapter 32’s flow. After install, verify that swcli --install-completion works too. Where did you get stuck?
  2. Extend your own subcommand tree — Pick a daily task of yours (e.g., “tidy git repos,” “organize photo files,” “flush an API response cache”) and build it as a Typer CLI with a subcommand tree. How much shorter do your daily commands get when you layer in autocompletion and env var mapping?
  3. Limits of CliRunner — Because CliRunner is in-process, it can’t catch some behaviors like os.exit. If your CLI has behavior that only validates with a real process (signal handling, subprocess calls), how would you split tests between subprocess.run(["swcli", ...])-based and CliRunner-based?
Note
In one line — Typer kills argparse’s boilerplate with the philosophy that the function signature is the CLI interface. Separate metadata with Annotated, enrich output with Rich, test in-process with CliRunner, and ship to PyPI through Chapter 32’s flow.

This is the last chapter of the book. From the first chapter of Part 5, Chapter 30 Type checker setup and CI integration, to this chapter, one line runs through it all — automate the correctness of code (Chapter 30), make what happens at runtime visible (Chapter 31), ship the code you made out into the world (Chapter 32), and build the interface users actually hold in their hands (Chapter 33). Every chapter of the book meets on that line.

Next is your own project. May this book be its starting point.

X