Python Atlas
Software engineering

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 0

Use 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 0 for success and a nonzero status for failure.
  • Keep machine-readable output stable; add --json when 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 parser

Map 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.argv during 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

Command-line applications

1How should a CLI separate output so automation can compose it reliably?
2Which requirement most strongly favors argparse over Typer?