Practical projects · about 50 min

Building a command-line tool with argparse

Why this matters

Much of the useful software you will write is small tools run from a terminal, scripts, cron jobs or other programs: "rename these files", "summarize this log", "convert this CSV". A good command-line interface (CLI) behaves like the Unix tools around it: it takes arguments and options, prints help, reports usage errors clearly, and signals success or failure through its exit status. Python's standard argparse module gives you all of that with a few declarations.

Where arguments come from: sys.argv

When you run python3 tool.py report.txt --verbose, the shell splits the command line into words and the operating system passes them to the new process. Python puts them in sys.argv:

import sys
print(sys.argv)
$ python3 tool.py report.txt --verbose
['tool.py', 'report.txt', '--verbose']

sys.argv[0] is the script name; the rest are strings, exactly as the shell produced them (after its quote handling: "Grace Hopper" arrives as one item). Parsing them by hand works for one argument, but soon you want optional flags in any order, defaults, type checking, help text and error messages. That is argparse's job.

A first parser

import argparse

def main():
    parser = argparse.ArgumentParser(description="Count lines in a file.")
    parser.add_argument("path", help="file to read")
    parser.add_argument("-v", "--verbose", action="store_true", help="print details")
    args = parser.parse_args()

    with open(args.path) as f:
        n = sum(1 for _ in f)
    if args.verbose:
        print(f"{args.path}: {n} lines")
    else:
        print(n)

if __name__ == "__main__":
    main()

Step by step:

You get help for free:

$ python3 count.py --help
usage: count.py [-h] [-v] path

Count lines in a file.

positional arguments:
  path           file to read

options:
  -h, --help     show this help message and exit
  -v, --verbose  print details

Types, defaults, choices, nargs

Arguments arrive as strings. type= converts them, and a failed conversion becomes a usage error:

parser.add_argument("-n", "--lines", type=int, default=10, help="lines to show (default: 10)")
$ python3 head.py -n ten file.txt
usage: head.py [-h] [-n LINES] path
head.py: error: argument -n/--lines: invalid int value: 'ten'
$ echo $?
2

type can be any function that takes a string: int, float, pathlib.Path, or your own function that raises argparse.ArgumentTypeError (or ValueError) on bad input.

Other useful parameters:

ParameterEffectExample
default=value when the option is absent (otherwise None)default=10
choices=restrict to a set of allowed valueschoices=["json", "csv"]
required=Truemake an option mandatory (use sparingly; that is what positionals are for)
nargs="+"one or more values, collected in a listfiles, nargs="+"
nargs="*"zero or more values
nargs="?"optional positional, uses default when missing
action="count"count repetitions: -vvv gives 3verbosity levels
action="append"repeatable option collecting a list--tag a --tag b
metavar=name shown in help instead of the defaultmetavar="N"
dest=attribute name to store into

An example combining several:

parser = argparse.ArgumentParser(prog="convert")
parser.add_argument("files", nargs="+", help="input files")
parser.add_argument("--format", choices=["json", "csv"], default="json")
parser.add_argument("-v", "--verbose", action="count", default=0)
args = parser.parse_args(["a.txt", "b.txt", "-vv", "--format", "csv"])
print(args)
Namespace(files=['a.txt', 'b.txt'], format='csv', verbose=2)

Passing a list to parse_args instead of reading sys.argv is handy for experimenting in the REPL and for testing your parser.

Exit statuses and where output goes

A CLI communicates through three channels:

argparse follows these rules: usage errors go to stderr with status 2 (parser.error("message") lets you raise your own usage error the same way), and --help goes to stdout with status 0. For your own failures, such as a missing input file, print to stderr and exit with 1:

import sys

def main() -> int:
    ...
    try:
        text = open(args.path).read()
    except FileNotFoundError:
        print(f"count: {args.path}: no such file", file=sys.stderr)
        return 1
    ...
    return 0

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

Having main() return the status and calling sys.exit(main()) keeps the function testable: tests can call main() and check the return value without the process exiting.

Structure of a well-behaved tool

#!/usr/bin/env python3
"""wordfreq: print the most common words in text files."""
import argparse
import sys
from collections import Counter


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("files", nargs="+")
    p.add_argument("-n", "--top", type=int, default=10)
    return p


def count_words(text: str) -> Counter[str]:
    return Counter(text.lower().split())


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    total: Counter[str] = Counter()
    for name in args.files:
        try:
            with open(name, encoding="utf-8") as f:
                total += count_words(f.read())
        except OSError as e:
            print(f"wordfreq: {e}", file=sys.stderr)
            return 1
    for word, n in total.most_common(args.top):
        print(f"{n:6} {word}")
    return 0


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

The parser is built in its own function, the real work lives in small pure functions (count_words) that are easy to unit-test, and main(argv) accepts an optional argument list so tests can call main(["file.txt", "-n", "3"]). The #!/usr/bin/env python3 line plus chmod +x wordfreq.py lets you run it as ./wordfreq.py.

Common mistakes

Recap

Going further: read the argparse tutorial in the Python docs ("Argparse Tutorial" under HOWTOs), especially the section on mutually exclusive groups, and look at subcommands (add_subparsers), which the to-do project at the end of this module uses.

Exercises

Exercise 1 · quiz

What --help does

A script uses argparse. You run python3 tool.py --help. Which statement is true?

Answer
  • argparse prints the help text to stdout and exits with status 0 before parse_args() returns

-h/--help is added automatically. When parse_args() sees it, it prints the generated help to stdout and calls sys.exit(0): asking for help is a success. Usage errors (missing argument, bad value) print a short usage line and the error to stderr and exit with status 2, the Unix convention for "you called me wrong".

Exercise 2 · write code

A greeting tool

Write a command-line tool with argparse:

usage: main.py [-h] [-n TIMES] [--shout] name

Each line is Hello, <name>!. Examples:

$ python3 main.py Ada
Hello, Ada!
$ python3 main.py Ada -n 2 --shout
HELLO, ADA!
HELLO, ADA!

Missing name or a non-integer --times must make argparse exit with status 2 (it does that by itself if you declare the arguments correctly). Put the logic in a main() function called under if __name__ == "__main__":.

File: main.py

Starting point

import argparse


def main():
    parser = argparse.ArgumentParser(description="Greet someone.")
    parser.add_argument("name")
    args = parser.parse_args()
    print(f"Hello, {args.name}!")


if __name__ == "__main__":
    main()

Example with arguments Ada

expected output

Hello, Ada!

Example with arguments Ada -n 2 --shout

expected output

HELLO, ADA!
HELLO, ADA!
Hint 1

parser.add_argument("-n", "--times", type=int, default=1) gives you args.times as an int.

Hint 2

A flag is parser.add_argument("--shout", action="store_true"); args.shout is then True or False.

Reference solution
import argparse


def main():
    parser = argparse.ArgumentParser(description="Greet someone.")
    parser.add_argument("name", help="who to greet")
    parser.add_argument("-n", "--times", type=int, default=1, help="how many times")
    parser.add_argument("--shout", action="store_true", help="use upper case")
    args = parser.parse_args()
    line = f"Hello, {args.name}!"
    if args.shout:
        line = line.upper()
    for _ in range(args.times):
        print(line)


if __name__ == "__main__":
    main()

Exercise 3 · write code

A number-crunching tool

Write main.py that takes one or more numbers as positional arguments and prints one statistic:

usage: main.py [-h] [--op {sum,min,max,mean}] [--precision P] numbers [numbers ...]

Print only the result, formatted with that many decimals (f"{value:.{p}f}"). Examples:

$ python3 main.py 1 2 3.5
6.50
$ python3 main.py --op mean --precision 3 1 2 4
2.333

An invalid --op or no numbers at all must exit with status 2.

File: main.py

Starting point

import argparse


def main():
    parser = argparse.ArgumentParser(description="Compute a statistic.")
    parser.add_argument("numbers")
    args = parser.parse_args()
    print(args.numbers)


if __name__ == "__main__":
    main()

Example with arguments 1 2 3.5

expected output

6.50

Example with arguments --op mean --precision 3 1 2 4

expected output

2.333
Hint 1

nargs="+" collects one or more values into a list; with type=float each one is converted.

Hint 2

choices=["sum", "min", "max", "mean"] makes argparse reject anything else with exit status 2.

Hint 3

A dict from operation name to function avoids a chain of if/elif.

Reference solution
import argparse

OPS = {
    "sum": sum,
    "min": min,
    "max": max,
    "mean": lambda xs: sum(xs) / len(xs),
}


def main():
    parser = argparse.ArgumentParser(description="Compute a statistic.")
    parser.add_argument("numbers", nargs="+", type=float, help="the input numbers")
    parser.add_argument("--op", choices=sorted(OPS), default="sum", help="statistic to compute")
    parser.add_argument("--precision", type=int, default=2, help="decimal places")
    args = parser.parse_args()
    value = OPS[args.op](args.numbers)
    print(f"{value:.{args.precision}f}")


if __name__ == "__main__":
    main()

Exercise 4 · quiz

Negative numbers and --

In the stats tool, one test runs python3 main.py --op min -- -5 3 -7.25. What does the -- do?

Answer
  • It tells argparse that everything after it is a positional argument, so -5 is not mistaken for an option

-- is a standard Unix convention: "end of options". After it, words starting with - are treated as positional values. argparse is clever about negative numbers when the parser has no options that look like numbers, but -- is the reliable, universal way, and it also works for file names that start with a dash (rm -- -weird-file).