Level 3 · Real Programs

Command-Line Programs ⌨️

The difference between a script you edit before every run and a tool you actually use is about fifteen lines of argument parsing.

The raw way: sys.argv

import sys

# when run as: python3 greet.py Guybrush pirate
print(sys.argv)          # ['greet.py', 'Guybrush', 'pirate']

sys.argv is a list of what was typed, with the script name first. It works, and for a two-argument throwaway it is fine. It gives you no help text, no validation, no flags, and no type conversion, which is why nobody uses it for anything real.

argparse: the standard answer

import argparse

parser = argparse.ArgumentParser(description="Greet someone, loudly if required.")
parser.add_argument("name", help="who to greet")
parser.add_argument("--times", type=int, default=1, help="how many times")
parser.add_argument("--shout", action="store_true", help="use capital letters")

# normally: args = parser.parse_args()
# for this lesson we pass the list ourselves so the example can run
args = parser.parse_args(["Guybrush", "--times", "3", "--shout"])

message = f"Hello, {args.name}!"
if args.shout:
    message = message.upper()

for _ in range(args.times):
    print(message)
HELLO, GUYBRUSH!
HELLO, GUYBRUSH!
HELLO, GUYBRUSH!

For fifteen lines you get, entirely for free:

$ python3 greet.py --help
usage: greet.py [-h] [--times TIMES] [--shout] name

Greet someone, loudly if required.

positional arguments:
  name           who to greet

options:
  -h, --help     show this help message and exit
  --times TIMES  how many times
  --shout        use capital letters

$ python3 greet.py
usage: greet.py [-h] [--times TIMES] [--shout] name
greet.py: error: the following arguments are required: name

Help text, usage lines, error messages, and a non-zero exit code on failure. Writing that by hand takes an hour and is worse.

The argument types you will use

import argparse

parser = argparse.ArgumentParser(prog="crewtool")

parser.add_argument("files", nargs="+", help="one or more files")
parser.add_argument("-o", "--output", default="out.txt", help="where to write")
parser.add_argument("-n", "--limit", type=int, default=10, help="max rows")
parser.add_argument("-v", "--verbose", action="store_true", help="chatty mode")
parser.add_argument("--format", choices=["json", "csv", "text"], default="text")

args = parser.parse_args(["a.csv", "b.csv", "-n", "5", "--format", "json", "-v"])

print(args.files)
print(args.output, args.limit, args.verbose, args.format)
['a.csv', 'b.csv']
out.txt 5 True json
Written asGives you
add_argument("name")A required positional argument
add_argument("--flag")An optional named argument
action="store_true"A yes/no switch, False unless present
type=intAutomatic conversion, with a clear error if it fails
default=xWhat to use when it is not given
choices=[...]Validation against a fixed set
nargs="+"One or more values, as a list
nargs="?"Optional positional
required=TrueForce an optional argument to be given

Subcommands, like git

import argparse

parser = argparse.ArgumentParser(prog="crew")
subs = parser.add_subparsers(dest="command", required=True)

add = subs.add_parser("add", help="add a crew member")
add.add_argument("name")
add.add_argument("--role", default="deckhand")

remove = subs.add_parser("remove", help="remove someone")
remove.add_argument("name")

subs.add_parser("list", help="show everyone")


def run(argv):
    args = parser.parse_args(argv)
    if args.command == "add":
        return f"Added {args.name} as {args.role}"
    if args.command == "remove":
        return f"Removed {args.name}"
    return "Crew: Guybrush, Elaine, Otis"


print(run(["add", "Meathook", "--role", "lookout"]))
print(run(["remove", "Otis"]))
print(run(["list"]))
Added Meathook as lookout
Removed Otis
Crew: Guybrush, Elaine, Otis

That is the shape of git commit, docker run and pip install. Each subcommand gets its own arguments and its own help.

Exit codes, and why they matter

import sys


def main():
    """Return 0 for success, non-zero for failure."""
    problem = True
    if problem:
        print("could not read the crew file", file=sys.stderr)
        return 1
    print("all good")
    return 0


# in a real script:
# if __name__ == "__main__":
#     sys.exit(main())

print("would exit with", main())
could not read the crew file
would exit with 1

Two conventions that make your tool a good citizen of the command line:

The complete shape of a real tool

#!/usr/bin/env python3
"""wordcount: count lines, words and characters in files."""

import argparse
import sys
from pathlib import Path


def count(path):
    """Return (lines, words, characters) for one file."""
    text = Path(path).read_text(encoding="utf-8")
    return len(text.splitlines()), len(text.split()), len(text)


def build_parser():
    parser = argparse.ArgumentParser(
        prog="wordcount",
        description="Count lines, words and characters.",
    )
    parser.add_argument("files", nargs="+", help="files to count")
    parser.add_argument("-l", "--lines-only", action="store_true")
    return parser


def main(argv=None):
    args = build_parser().parse_args(argv)
    failures = 0

    for name in args.files:
        try:
            lines, words, chars = count(name)
        except FileNotFoundError:
            print(f"wordcount: {name}: no such file", file=sys.stderr)
            failures += 1
            continue
        if args.lines_only:
            print(f"{lines:6} {name}")
        else:
            print(f"{lines:6} {words:6} {chars:6}  {name}")

    return 1 if failures else 0


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

Note the structure: count is a pure function with no argument parsing in it, build_parser is separate so tests can inspect it, and main takes an optional argv so tests can call it directly without touching the real command line. That last trick is what makes a CLI testable, and it costs one default argument.

🎨 When you want it to look good

The standard library is deliberately plain. Two popular third-party options: Typer builds the whole parser from your function's type hints, and rich gives you colours, tables, progress bars and spinners in about two lines. Neither is needed to learn the ideas, and both are a joy once you have.

Exercise 1

Add flags to a converter

Build a parser for a temperature tool that takes a number, a --to choice of c or f, and an optional --precision. Show it working for two different calls.

Reveal solution
import argparse

parser = argparse.ArgumentParser(description="Convert temperatures.")
parser.add_argument("value", type=float, help="the temperature to convert")
parser.add_argument("--to", choices=["c", "f"], required=True, help="target scale")
parser.add_argument("--precision", type=int, default=1, help="decimal places")


def convert(argv):
    args = parser.parse_args(argv)
    if args.to == "f":
        result = args.value * 9 / 5 + 32
    else:
        result = (args.value - 32) * 5 / 9
    return f"{result:.{args.precision}f}°{args.to.upper()}"


print(convert(["100", "--to", "f"]))
print(convert(["212", "--to", "c", "--precision", "3"]))
212.0°F
100.000°C

Note {{result:.{{args.precision}}f}}: an f-string can compute its own format spec from a variable. Genuinely useful and not widely known.

Exercise 2

Design a CLI on paper

You are building a tool that backs up a folder. Write out the --help output you would want before writing any code. What arguments does it need?

Reveal solution
usage: backup [-h] [--dest DEST] [--exclude PATTERN] [--dry-run]
              [--compress] [-v] source

Back up a folder, skipping anything you tell it to.

positional arguments:
  source              the folder to back up

options:
  -h, --help          show this help message and exit
  --dest DEST         where to put the backup (default: ./backups)
  --exclude PATTERN   glob to skip, may be given several times
  --dry-run           show what would happen, change nothing
  --compress          write a .zip instead of a folder
  -v, --verbose       list every file as it is copied

Designing the interface first is a real technique, sometimes called README-driven development. --dry-run in particular is the mark of a considerate tool: anything that deletes or overwrites should offer a way to preview it, and you will thank yourself the first time you point it at the wrong folder.

Exercise 3

Make it testable

Why does def main(argv=None) matter, and how would you test the tool without running it from a terminal?

Reveal solution
import argparse


def build_parser():
    p = argparse.ArgumentParser()
    p.add_argument("name")
    p.add_argument("--times", type=int, default=1)
    return p


def main(argv=None):
    """argv=None means 'read the real command line', but tests can pass a list."""
    args = build_parser().parse_args(argv)
    return [f"Hello, {args.name}!" for _ in range(args.times)]


# a test, with no terminal involved at all
assert main(["Guybrush"]) == ["Hello, Guybrush!"]
assert len(main(["Elaine", "--times", "3"])) == 3
print("both assertions passed")
both assertions passed

With argv=None, argparse falls back to sys.argv[1:] in real use, and your tests hand it a list instead. Same code path, no subprocess, no fixtures. Lesson 29 turns those assert lines into a proper test suite.

+100 XP