Index Ai Academy
Phase 7 · Software engineering · Python in depth

Command-line programs

argparse subcommands, stdin, exit codes and testable main functions.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. sys.argv: the raw words
  2. argparse
  3. Subcommands
  4. Behaving like a good tool
  5. A unit converter
  6. Settings, help and other libraries
  7. 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)
It runs right here in your browser, with nothing to install.

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=int converts the text, and choices limits it to a fixed list.
  • action="store_true" makes a flag: --rush sets 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".

Try it now

Step through and look at args after parse_args: qty is already an int, and rush is True because the flag was given.

Quick check
What does this print?
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)
  1. 10 False
  2. 5 True
  3. 10 True
  4. 55 True
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 from main, and end the file with raise SystemExit(main(sys.argv[1:])) (or sys.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.stdin is 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.

Drill

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"])
In the lesson, your code is checked as you write it.

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

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 "-")
In the lesson you can change this and run it.

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

Create a free account

Try a quiz question

A tool found a problem with its input. Where should its error message go, and what should it return?

  1. stderr, with a non-zero exit code
  2. stdout, with exit code 0
  3. stdout, with a non-zero exit code
  4. A log file only, with exit code 0

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson