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 |
|---|---|
|
Most cases; subcommands, choices, types, env defaults |
|
Rich decorator-based, more ergonomic for big CLIs |
|
Click + type hints; minimal boilerplate |
|
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 |
|
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/.