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:
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:
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 typerimport 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=Worldor--name World.
def main(name: str, age: int = 0) -> None:
...name→ required positional argument.age→--age=30optional.
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-verboseThe Annotated pattern — separating metadata #
The basic form is clean, but to add help text, short names, value validation, etc., Annotated is the standard pattern.
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-verboseWith 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().
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 PRODUCTIONNesting — 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.comYou 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 #
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 #
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-completionAfter 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.
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 OKConstruct 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.pyVerify 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.
[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 #
- Run
swclithrough the release flow — Build this chapter’sswclias-is and publish 0.0.1 to TestPyPI through Chapter 32’s flow. After install, verify thatswcli --install-completionworks too. Where did you get stuck? - 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?
- Limits of CliRunner — Because
CliRunneris in-process, it can’t catch some behaviors likeos.exit. If your CLI has behavior that only validates with a real process (signal handling, subprocess calls), how would you split tests betweensubprocess.run(["swcli", ...])-based andCliRunner-based?
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.