CLI scripting#

Python is excellent for one-shot scripts and full CLI tools. The stdlib gets you most of the way; click and typer add ergonomics for larger trees of subcommands.

Skeleton#

#!/usr/bin/env python3
"""short, what-it-does line."""
from __future__ import annotations

import argparse, sys, logging

def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("path")
    p.add_argument("-v", "--verbose", action="count", default=0)
    args = p.parse_args(argv)

    logging.basicConfig(level=max(10, 30 - 10 * args.verbose),
                        format="%(levelname)s %(message)s")

    try:
        run(args.path)
    except KeyboardInterrupt:
        return 130
    return 0

if __name__ == "__main__":
    sys.exit(main())

The main(argv=None) form lets pytest call main(["a", "b"]) without spawning a subprocess.

Argument parsing#

Library

When

argparse (stdlib)

Most cases; subcommands, choices, types, env defaults

click

Rich decorator-based, more ergonomic for big CLIs

typer

Click + type hints; minimal boilerplate

fire

Auto-CLI from any Python object; quick prototypes

Argparse essentials.

p.add_argument("input")                              # positional, required
p.add_argument("-o", "--output", default="-")        # default
p.add_argument("-n", "--count", type=int, default=1)
p.add_argument("-f", "--force", action="store_true")
p.add_argument("--mode", choices=["fast", "safe"])
p.add_argument("paths", nargs="+")                   # 1 or more
p.add_argument("--token", default=os.environ.get("API_TOKEN"))

Subcommands.

sub = p.add_subparsers(dest="cmd", required=True)
add  = sub.add_parser("add")
add.add_argument("name")
rm   = sub.add_parser("rm")
rm.add_argument("name")

args = p.parse_args()
{"add": do_add, "rm": do_rm}[args.cmd](args)

Stdin, stdout, argv#

import sys

data = sys.stdin.read()                  # whole stdin
for line in sys.stdin:                   # streaming
    process(line.rstrip("\n"))

print("hello", file=sys.stderr)          # write to stderr
sys.exit(2)                              # nonzero exit code

The classic Unix idiom (“read from a file or stdin if -“) via fileinput:

import fileinput
for line in fileinput.input(args.paths or "-"):
    sys.stdout.write(line)

Environment, paths, dirs#

import os, pathlib

token = os.environ["API_TOKEN"]                # KeyError if missing
home  = pathlib.Path.home()
conf  = pathlib.Path(os.environ.get("XDG_CONFIG_HOME",
                                    home / ".config")) / "myapp"

Running other commands#

import subprocess

# simple, captures stdout, raises on nonzero
r = subprocess.run(["git", "rev-parse", "HEAD"],
                   capture_output=True, text=True, check=True)
print(r.stdout.strip())

# streaming
with subprocess.Popen(["tail", "-F", "/var/log/syslog"],
                      stdout=subprocess.PIPE, text=True) as p:
    for line in p.stdout:
        handle(line)

Warning

Never pass user-controlled strings with ``shell=True``. It evaluates through /bin/sh and is a command-injection vector:

# WRONG
subprocess.run(f"grep {pattern} {path}", shell=True)

# right
subprocess.run(["grep", pattern, path])

The list form passes argv directly to execve, no shell, no quoting bugs.

Exit codes#

Code

Meaning

0

Success

1

Generic failure

2

Usage / arg parse error (argparse default)

126

Found but not executable

127

Not found

128 + N

Killed by signal N

130

Ctrl-C (SIGINT, 128 + 2)

Logging#

Don’t print() for diagnostics; use logging so verbosity is configurable.

import logging
log = logging.getLogger(__name__)

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)

log.debug("low level")
log.info("step done")
log.warning("weird thing")
log.error("failed: %s", err)
log.exception("unhandled")           # includes traceback

For structured output, use structlog or logging.Formatter with json.

Distribution#

Make a script available as a real command.

# pyproject.toml
[project.scripts]
myapp = "mypkg.cli:main"

Then pip install . or uv pip install -e . puts myapp on $PATH.

For one-file distribution: pyinstaller, shiv, pex, or just python -m zipapp.

See also: Testing (testing CLI tools), Libraries (logging, json, pathlib), https://click.palletsprojects.com/, https://typer.tiangolo.com/.