Command-line applications
Build testable CLIs and choose between argparse and Typer according to project needs.
A command-line interface is an API for people and automation. Stable exit codes, predictable output, clear help, and careful error handling matter as much as argument parsing.
Keep the CLI thin
Separate parsing and presentation from application behavior:
from collections.abc import Sequence
import argparse
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="word-count")
parser.add_argument("path", help="UTF-8 text file to inspect")
parser.add_argument(
"--minimum-length",
type=int,
default=1,
metavar="N",
help="count words with at least N characters (default: 1)",
)
return parser
def count_words(text: str, minimum_length: int) -> int:
if minimum_length < 1:
raise ValueError("minimum length must be positive")
return sum(len(word) >= minimum_length for word in text.split())
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
arguments = parser.parse_args(argv)
try:
with open(arguments.path, encoding="utf-8") as file:
result = count_words(file.read(), arguments.minimum_length)
except (OSError, ValueError) as error:
parser.error(str(error))
print(result)
return 0Use the module guard only to adapt the returned status to the process:
if __name__ == "__main__":
raise SystemExit(main())Declaring a [project.scripts] entry in pyproject.toml makes the command available after
installation without relying on the module guard.
Design stdout, stderr, and exit status
- Write requested data to stdout so it can be piped.
- Write diagnostics and progress to stderr.
- Return
0for success and a nonzero status for failure. - Keep machine-readable output stable; add
--jsonwhen automation needs a contract. - Do not print a traceback for expected user errors.
- Preserve tracebacks for unexpected defects through logs or an opt-in debug mode.
Avoid prompts by default in commands intended for automation. For destructive operations, offer explicit confirmation and a noninteractive flag whose behavior is documented.
Subcommands with argparse
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="inventory")
subparsers = parser.add_subparsers(dest="command", required=True)
import_parser = subparsers.add_parser("import", help="import inventory records")
import_parser.add_argument("path")
list_parser = subparsers.add_parser("list", help="list inventory records")
list_parser.add_argument("--limit", type=int, default=20)
return parserMap the parsed command to a small handler. Do not place database and network logic directly in parser callbacks.
argparse or Typer?
Choose argparse when
- zero runtime dependencies are important;
- the CLI is small or distributed with Python itself;
- precise parser behavior and long-term stdlib availability matter;
- the team is comfortable defining arguments explicitly.
Choose Typer when
- type annotations can define a larger command tree clearly;
- automatic help and shell completion provide real user value;
- Click ecosystem integration is useful;
- the team accepts its transitive dependencies and upgrade policy.
A small Typer command can be concise:
from pathlib import Path
from typing import Annotated
import typer
app = typer.Typer(no_args_is_help=True)
@app.command()
def inspect(
path: Path,
minimum_length: Annotated[int, typer.Option(min=1)] = 1,
) -> None:
text = path.read_text(encoding="utf-8")
typer.echo(count_words(text, minimum_length))Conciseness is not free: framework versions can affect parsing, help formatting, and type support. Test the installed command, not only its handler.
Test the interface
Test pure application functions directly. Add a smaller set of CLI tests for parsing, output, stderr, and exit codes:
def test_main_prints_count(tmp_path, capsys) -> None:
path = tmp_path / "words.txt"
path.write_text("one three seven", encoding="utf-8")
status = main([str(path), "--minimum-length", "5"])
assert status == 0
assert capsys.readouterr().out == "2\n"Typer provides a test runner through its Click integration. Also run an installed-package smoke test to catch broken entry-point metadata.
Pitfalls
- parsing
sys.argvduring import; - mixing domain logic with terminal colors and prompts;
- changing stdout text that scripts depend on;
- returning success after partial failure;
- accepting ambiguous dates, encodings, or units;
- exposing secrets in command history through arguments;
- selecting a framework only because the first example is shorter.
Checklist
- Help describes purpose, defaults, units, and examples.
- Business logic can run without a terminal.
- stdout, stderr, and exit codes have explicit contracts.
- Expected errors are concise and actionable.
- Secrets use safer channels than command-line arguments.
- Parser and installed entry point are tested.
- The framework choice follows documented requirements.
Practice
Build the same two-command tool with argparse and Typer. Compare dependency count, help
output, test ergonomics, startup time, shell completion, and handling of invalid input. Choose
one using weighted requirements rather than source line count.
LEARNING RECORD
Finish this lesson
Mark it complete when you can explain the main decision without looking.
KNOWLEDGE CHECK