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 as | Gives 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=int | Automatic conversion, with a clear error if it fails |
default=x | What 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=True | Force 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:
- Exit 0 for success, non-zero for failure. This is how
&&, shell scripts and CI systems know whether to continue. A program that always exits 0 cannot be automated. - Errors go to stderr, results go to stdout. Then someone can write
mytool data.csv > results.txtand still see the error messages on screen rather than mixed into their output file.
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.
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.
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°CNote {{result:.{{args.precision}}f}}: an f-string can compute its own format spec from a variable. Genuinely useful and not widely known.
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 copiedDesigning 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.
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 passedWith 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.