Building Command Line Interfaces#
Command-line interfaces (CLIs) are the fastest way to expose scripts, data tools, and automation to users and CI pipelines. Python ships argparse in the stdlib; libraries like click and typer add composability, validation, and better UX. A well-designed CLI has clear help text, sensible defaults, exit codes, and testable command functions.
How to use this page
Start with argparse for stdlib-only tools; reach for typer when you want type-hint-driven CLIs. Pair with Managing Secrets for credentials, Error Handling for exit codes, and Unit Testing for CLI tests.
At a glance
| Track | Python Advanced → Advanced Use Cases and Applications |
| Sections | 13 major topics |
| Outline | Use the right-hand TOC to jump |
Topics: CLI design principles · sys.argv — minimal parsing · argparse — stdlib standard · click — composable CLI framework · typer — type hints drive the CLI · Entry points — installable commands · Exit codes and errors · Logging vs user output · … (+5 more)
- CLI design principles
sys.argv— minimal parsingargparse— stdlib standardclick— composable CLI frameworktyper— type hints drive the CLI- Entry points — installable commands
- Exit codes and errors
- Logging vs user output
- Configuration and environment
- Progress and long-running tasks
- Testing CLIs
- Choosing a framework … and 1 more sections
CLI design principles#
| Principle | Practice |
|---|---|
Helpful --help |
Document every flag with examples |
| Sensible defaults | Minimize required arguments |
| Exit codes | 0 success, non-zero on failure |
| Idempotent flags | --verbose / --quiet don't conflict |
| stdin/stdout/stderr | Log diagnostics to stderr, data to stdout |
| Path args as strings | Convert to Path internally — File IO |
sys.argv — minimal parsing#
import sys
def main(argv: list[str] | None = None) -> int:
argv = argv or sys.argv[1:]
if not argv:
print("usage: greet <name>", file=sys.stderr)
return 1
print(f"Hello, {argv[0]}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Fine for one or two positional args — scales poorly without argparse.
argparse — stdlib standard#
import argparse
from pathlib import Path
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="csvmerge",
description="Merge CSV files by a key column",
)
parser.add_argument("inputs", nargs="+", type=Path, help="Input CSV paths")
parser.add_argument("-o", "--output", type=Path, required=True, help="Output path")
parser.add_argument("-k", "--key", default="id", help="Join key column")
parser.add_argument("-v", "--verbose", action="count", default=0)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.verbose:
print(f"Merging {len(args.inputs)} files on {args.key}")
merge_csvs(args.inputs, args.output, key=args.key)
return 0
if __name__ == "__main__":
raise SystemExit(main())
Common add_argument options#
| Parameter | Effect |
|---|---|
nargs="+" |
One or more positional values |
nargs="*" |
Zero or more |
action="store_true" |
Boolean flag |
choices=[...] |
Restrict to enumerated values |
type=Path |
Convert to pathlib.Path |
default= |
Value when flag omitted |
required=True |
Mandatory option |
Subcommands#
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command", required=True)
init_p = sub.add_parser("init", help="Initialize project")
init_p.add_argument("name")
run_p = sub.add_parser("run", help="Run pipeline")
run_p.add_argument("--dry-run", action="store_true")
click — composable CLI framework#
import click
from pathlib import Path
@click.group()
@click.option("--verbose", "-v", count=True)
@click.pass_context
def cli(ctx, verbose):
ctx.ensure_object(dict)
ctx.obj["verbose"] = verbose
@cli.command()
@click.argument("input", type=click.Path(exists=True, path_type=Path))
@click.option("--output", "-o", type=click.Path(path_type=Path), required=True)
def convert(input: Path, output: Path):
"""Convert INPUT file to OUTPUT format."""
click.echo(f"Converting {input} -> {output}")
if __name__ == "__main__":
cli()
| Feature | Benefit |
|---|---|
@click.group() |
Nested subcommands |
click.Path |
Validation + path_type=Path |
click.echo |
Cross-platform output |
click.prompt / confirm |
Interactive input |
ctx.obj |
Shared context across commands |
Password input#
Pairs with Managing Secrets — never echo secrets.
typer — type hints drive the CLI#
import typer
from pathlib import Path
from typing import Optional
app = typer.Typer()
@app.command()
def greet(
name: str,
loud: bool = False,
count: int = typer.Option(1, "--count", "-c", min=1),
):
"""Greet NAME count times."""
message = f"Hello, {name}"
if loud:
message = message.upper()
for _ in range(count):
typer.echo(message)
@app.command()
def process(
input: Path = typer.Argument(..., exists=True, readable=True),
output: Optional[Path] = typer.Option(None, "--output", "-o"),
):
...
if __name__ == "__main__":
app()
Built on Click — annotations define types, defaults, and help automatically. Ideal when you already use Type Hints and Annotations.
Entry points — installable commands#
pyproject.toml:
[project]
name = "csvmerge"
version = "0.1.0"
requires-python = ">=3.10"
[project.scripts]
csvmerge = "csvmerge.cli:main"
[project.entry-points."console_scripts"]
csvmerge = "csvmerge.cli:main"
After pip install -e ., users run csvmerge from any directory. See Modules and Packages.
Exit codes and errors#
import sys
class CLIError(Exception):
def __init__(self, message: str, code: int = 1):
super().__init__(message)
self.code = code
def main() -> int:
try:
run()
return 0
except CLIError as exc:
print(exc, file=sys.stderr)
return exc.code
except KeyboardInterrupt:
print("Interrupted", file=sys.stderr)
return 130 # conventional for SIGINT
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | General error |
| 2 | Misuse (argparse default) |
| 130 | Ctrl+C (128 + signal 2) |
Map domain errors explicitly — patterns from Error Handling.
Logging vs user output#
import logging
import sys
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
logger = logging.getLogger(__name__)
def main():
logger.info("starting pipeline") # diagnostics → stderr
print('{"status": "ok"}') # machine-readable → stdout
Scripts piped to other tools must keep stdout clean — only structured results there.
Configuration and environment#
import os
import typer
app = typer.Typer()
@app.command()
def deploy(env: str = typer.Option(os.environ.get("APP_ENV", "dev"))):
...
Precedence: CLI flags > environment variables > config file > defaults. Load secrets from env — Managing Secrets.
Progress and long-running tasks#
import typer
from typer import progressbar
def process_files(paths: list[Path]) -> None:
with progressbar(length=len(paths), label="Processing") as pb:
for path in paths:
handle(path)
pb.update(1)
For minimal deps, print status to stderr with \r or use rich (Typer integrates well).
Testing CLIs#
argparse#
def test_merge_writes_output(tmp_path):
inp = tmp_path / "a.csv"
inp.write_text("id,name\n1,Ada\n", encoding="utf-8")
out = tmp_path / "out.csv"
rc = main(["str(inp)", "-o", str(out), "-k", "id"])
assert rc == 0
assert out.exists()
Pass argv explicitly — never shell out in unit tests.
typer / click#
from typer.testing import CliRunner
runner = CliRunner()
def test_greet_loud():
result = runner.invoke(app, ["greet", "World", "--loud"])
assert result.exit_code == 0
assert "HELLO, WORLD" in result.stdout
See Unit Testing.
Choosing a framework#
| Tool | Best when |
|---|---|
argparse |
Stdlib only, scripts, no extra deps |
click |
Complex nested commands, custom types |
typer |
Type-hint-first, fast development |
sys.argv |
Prototype or single-argument tool |
Complete argparse example#
#!/usr/bin/env python3
"""fetch-url — download a URL to a file."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
import httpx
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("url", help="URL to download")
p.add_argument("-o", "--output", type=Path, default=Path("out.bin"))
p.add_argument("--timeout", type=float, default=30.0)
return p
def run(url: str, output: Path, timeout: float) -> None:
with httpx.stream("GET", url, timeout=timeout, follow_redirects=True) as r:
r.raise_for_status()
with output.open("wb") as f:
for chunk in r.iter_bytes():
f.write(chunk)
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
run(args.url, args.output, args.timeout)
except httpx.HTTPError as exc:
print(f"download failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
Combines HTTP Requests and File IO.
Interview traps (quick reference)#
| Trap | What goes wrong | Safe approach |
|---|---|---|
| Print debug info to stdout | Breaks piping | stderr for logs |
| No exit code on failure | CI thinks success | raise SystemExit(1) |
| Required positional args without help | Bad UX | argparse with descriptions |
| Parsing paths as raw strings | Windows/posix bugs | type=Path |
| Secrets as CLI flags | Visible in ps history |
Env vars or prompt |
Untestable main() |
No injection of argv | main(argv=None) pattern |
action="store_true" with default=True |
Flag cannot disable | Use store_false pair |
| Global state in commands | Hard to test | Pass context / dependency injection |
Mental model checklist#
- What exit code should a failed CLI return?
- Why send logs to stderr instead of stdout?
- How do you test argparse without subprocess?
- What is the
if __name__ == "__main__"guard for? - When is
typerpreferable to rawargparse? - How do entry points make a module runnable as
csvmerge?
What's next#
| Topic | Page |
|---|---|
| Packaging | Modules and Packages |
| Credentials | Managing Secrets |
| HTTP downloads | HTTP Requests |
| Output files | File IO |
| CLI tests | Unit Testing |