Command-line programs
argparse subcommands, stdin, exit codes and testable main functions.
Create a free account Try part of it here
Why it matters
Command-line tools are how developers automate their work: scripts that run in a scheduler, in CI, in a deploy pipeline, or by hand in a terminal. A good one explains its own options with --help, rejects bad input with a clear message, and reports success or failure through its exit code, so other programs can chain it. Python's argparse handles most of that for you. This lesson builds a small notes tool the right way, with a structure you can test.
What you'll be able to do
- Read command-line arguments with sys.argv, then with argparse: positionals, options, types and choices
- Build a tool with subcommands, each with its own arguments
- Use exit codes, stderr and stdin the way other command-line tools expect
- Structure the program as main(argv) so it can be tested without a terminal
What it covers
- sys.argv: the raw words
- argparse
- Subcommands
- Behaving like a good tool
- A unit converter
- Settings, help and other libraries
- Worked example: testing a command-line tool
sys.argv: the raw words
When you run python notes.py add "Buy tent pegs" --due friday, the program receives the words after python as a list of strings in sys.argv. The first is the script's own name:
import sys def main(argv): print("program:", argv[0]) print("arguments:", argv[1:]) if len(argv) < 2: print("usage: notes.py COMMAND [ARGS]", file=sys.stderr) return 2 return 0 code = main(["notes.py", "add", "Buy tent pegs", "--due", "friday"]) print("exit code:", code)
The shell has already split the line into words, respecting quotes, so "Buy tent pegs" arrives as one item. Here main takes the list as a parameter instead of reading sys.argv directly. That's the key design choice of this lesson: a real run calls main(sys.argv), and a test calls main([...]) with any arguments it likes.
Handling flags, optional values, numbers and help text by hand gets messy fast. That's what argparse is for.
argparse
argparse turns a description of your arguments into a parser that converts types, fills in defaults, writes the --help text and rejects bad input:
import argparse def build_parser(): parser = argparse.ArgumentParser(prog="restock", description="Plan restock orders.") parser.add_argument("sku", help="product code, like TE-101") parser.add_argument("qty", type=int, help="how many to order") parser.add_argument("--store", choices=["portland", "seattle", "denver"], default="portland") parser.add_argument("--rush", action="store_true", help="ship overnight") return parser args = build_parser().parse_args(["TE-101", "12", "--rush"]) print(args) print(args.sku, args.qty + 1, args.store, args.rush) build_parser().print_help()
- Names without dashes are positional arguments: required, in order.
- Names with dashes are options: optional, in any order, with a
default. type=intconverts the text, andchoiceslimits it to a fixed list.action="store_true"makes a flag:--rushsets it to True, and leaving it out leaves it False.
parse_args normally reads sys.argv[1:]; passing a list instead is how you test it. When the input is wrong, argparse prints a usage message to stderr and exits with code 2, which is the conventional code for "you used this wrong".
Step through and look at args after parse_args: qty is already an int, and rush is True because the flag was given.
import argparse p = argparse.ArgumentParser() p.add_argument("n", type=int) p.add_argument("--times", type=int, default=2) p.add_argument("-v", "--verbose", action="store_true") a = p.parse_args(["5", "-v"]) print(a.n * a.times, a.verbose)
Show the answer
10 True
n is converted to the int 5, --times takes its default of 2, and -v is the short form of --verbose, so it's True.
Subcommands
Tools like git have subcommands: git add, git commit, each with its own arguments. argparse builds them with add_subparsers. A neat trick connects each subcommand to the function that handles it: set_defaults(func=...).
import argparse def cmd_add(args): print(f"added note: {args.text!r} (priority {args.priority})") return 0 def cmd_list(args): print("listing notes", "including done ones" if args.all else "") return 0 def build_parser(): parser = argparse.ArgumentParser(prog="notes") sub = parser.add_subparsers(dest="command", required=True) add = sub.add_parser("add", help="add a note") add.add_argument("text") add.add_argument("-p", "--priority", type=int, choices=[1, 2, 3], default=2) add.set_defaults(func=cmd_add) show = sub.add_parser("list", help="list notes") show.add_argument("--all", action="store_true") show.set_defaults(func=cmd_list) return parser def main(argv): args = build_parser().parse_args(argv) return args.func(args) main(["add", "Check stove fuel", "-p", "1"]) main(["list", "--all"])
required=True makes running the tool with no subcommand an error with a helpful message. After parsing, args.func is whichever handler belongs to the subcommand used, so main stays two lines however many commands you add.
Behaving like a good tool
Command-line tools are glued together by scripts and pipelines, which rely on a few conventions:
- Exit codes. 0 means success; anything else means failure. Scripts check it (
if ./notes.py list; then ...) and CI fails a build on it. Return an int frommain, and end the file withraise SystemExit(main(sys.argv[1:]))(orsys.exit(main(...))). - stdout for results, stderr for messages. Print the actual output to stdout so it can be piped into another program or a file; print errors and progress to stderr (
print(..., file=sys.stderr)) so they don't end up in the data. - Read stdin when it's useful. Accepting input on stdin lets a tool sit in a pipeline:
cat orders.csv | python totals.py.sys.stdinis a file-like object you can loop over line by line. - Be quiet on success. Many good tools print nothing when everything worked, so they stay useful inside other scripts.
import io import sys def totals(lines, out, err): total, bad = 0.0, 0 for n, line in enumerate(lines, start=1): try: total += float(line.split(",")[-1]) except ValueError: bad += 1 print(f"line {n}: not a number, skipped", file=err) print(f"{total:.2f}", file=out) return 1 if bad else 0 out, err = io.StringIO(), io.StringIO() code = totals(["TE-101,200", "PA-108,55.5", "oops,abc"], out, err) print("stdout:", out.getvalue().strip(), "| stderr:", err.getvalue().strip(), "| exit:", code)
Passing the streams in, as with argv, keeps the function testable: a real run passes sys.stdin, sys.stdout and sys.stderr.
A unit converter
Write main(argv) with argparse: a positional value (float), an option --to with choices km and mi (default km), converting from the other unit (1 mi = 1.609344 km). Print the result rounded to 2 decimals with the unit, like 16.09 km, and return 0.
import argparse def main(argv): return 0 main(["10"]) main(["16.09", "--to", "mi"])
Settings, help and other libraries
Real tools take settings from more than one place. A common order of precedence, from strongest to weakest, is: a command-line option, then an environment variable, then a config file, then a built-in default. argparse handles the first and last directly; for the middle two, read the environment or the file yourself and pass the value as the option's default, for example default=os.environ.get("NOTES_FILE", "notes.json"). Secrets such as API keys belong in the environment, never in command-line options, because options show up in shell history and in process lists that other users can see.
Good help text is part of the interface. Give the parser a description, give every argument a short help string, and add an epilog with an example or two. Users run --help before anything else, and an example usually answers their question faster than a list of options.
argparse is in the standard library, so it's always available. Two popular third-party libraries build on the same ideas with less code: click, which uses decorators on functions, and typer, which reads the arguments from a function's type hints. Learn argparse first; the others will then feel familiar.
Finally, a tool should be easy to run. The packaging lesson shows how a [project.scripts] entry in pyproject.toml turns main into a real command, so people type notes add ... instead of python path/to/notes.py add ....
Worked example: testing a command-line tool
Because main takes argv and returns a code, tests can run whole commands and check what they printed and returned, without a terminal.
import argparse import contextlib import io def build_parser(): parser = argparse.ArgumentParser(prog="stock") sub = parser.add_subparsers(dest="command", required=True) check = sub.add_parser("check") check.add_argument("sku") return parser STOCK = {"TE-101": 4, "PA-108": 0} def main(argv): args = build_parser().parse_args(argv) if args.sku not in STOCK: print(f"unknown product {args.sku}", file=sys.stderr) return 1 print(f"{args.sku}: {STOCK[args.sku]} in stock") return 0 if STOCK[args.sku] > 0 else 3 import sys def run(argv): out, err = io.StringIO(), io.StringIO() with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): try: code = main(argv) except SystemExit as e: code = e.code return code, out.getvalue().strip(), err.getvalue().strip() for argv in [["check", "TE-101"], ["check", "PA-108"], ["check", "XX-000"], []]: code, out, err = run(argv) print(argv, "->", code, "|", out or "-", "|", err.splitlines()[-1] if err else "-")
run captures stdout and stderr and turns argparse's SystemExit into an exit code, so every case can be checked the same way. The tool uses distinct codes: 0 for in stock, 3 for out of stock, 1 for an unknown product, and argparse's own 2 for wrong usage. A script calling it can tell those apart. pytest's capsys fixture does the capturing for you in real test suites.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
A tool found a problem with its input. Where should its error message go, and what should it return?