Python argparse: Build Command-Line Tools with Examples

By Engr. Henry Akpan

Scripts That Take Instructions

Professional tools are controlled from the command line: git commit -m "message", pip install --upgrade numpy, python -m http.server 8000. Your own Python scripts can work the same way, taking file names, numbers and options when they start, instead of asking questions with input() or needing their code edited. Python's standard library includes argparse to build such command-line interfaces, complete with automatic help messages and error checking. This tutorial covers Python argparse step by step: sys.argv, positional and optional arguments, types, defaults, choices, flags, multiple values, mutually exclusive options, subcommands and a complete grade-calculator tool. It builds on the tutorials on Python functions and command-line basics.

The Raw Material: sys.argv

When you run python grades.py results.csv --top 3, Python stores the words in the list sys.argv: ["grades.py", "results.csv", "--top", "3"]. You could pick these apart by hand, but checking types, handling missing values, supporting options in any order and writing help text quickly becomes tedious. argparse does all of this for you.

A First Parser

import argparse

parser = argparse.ArgumentParser(prog="greet", description="Greet someone by name.")
parser.add_argument("name", help="the person to greet")
parser.add_argument("--times", type=int, default=1, help="how many times to greet (default: 1)")

args = parser.parse_args(["Ada", "--times", "2"])
for _ in range(args.times):
    print(f"Hello, {args.name}!")
print(args)

Output:

Hello, Ada!
Hello, Ada!
Namespace(name='Ada', times=2)

In a real script you call parser.parse_args() with no list, and it reads sys.argv automatically. Passing a list, as here, is the standard way to test a parser without the command line. parse_args returns a Namespace object, with each argument as an attribute.

Positional and Optional Arguments

  • Positional arguments (like name) are identified by their position and are required.
  • Optional arguments start with -- (or a single - for a short form) and can appear in any order. They are optional unless you pass required=True.

A dash inside an option's name becomes an underscore in the attribute name, so --pass-mark is read as args.pass_mark.

Automatic Help

Every parser gets -h and --help options for free, generated from the descriptions you supply:

print(parser.format_help())

Output:

usage: greet [-h] [--times TIMES] name

Greet someone by name.

positional arguments:
  name           the person to greet

options:
  -h, --help     show this help message and exit
  --times TIMES  how many times to greet (default: 1)

Write a short, clear help string for every argument. It is your tool's documentation.

Error Messages for Free

If the user gives a missing or invalid argument, argparse prints the usage line and a clear error message, then exits with status code 2.

import contextlib, io

def try_parse(argv):
    err = io.StringIO()
    try:
        with contextlib.redirect_stderr(err):
            return parser.parse_args(argv)
    except SystemExit as e:
        print("exit code", e.code, "|", err.getvalue().strip().splitlines()[-1])

try_parse([])
try_parse(["Ada", "--times", "two"])

Output:

exit code 2 | greet: error: the following arguments are required: name
exit code 2 | greet: error: argument --times: invalid int value: 'two'

The helper captures the error so it can be shown here. In a normal script, argparse prints the message to the terminal and stops the program.

Types, Defaults, Choices and Flags

p = argparse.ArgumentParser(prog="report")
p.add_argument("--level", type=int, choices=[100, 200, 300, 400], default=100)
p.add_argument("--format", choices=["text", "csv"], default="text")
p.add_argument("--pass-mark", type=float, default=45.0)
p.add_argument("-v", "--verbose", action="store_true", help="print extra detail")
p.add_argument("--no-header", dest="header", action="store_false")

print(p.parse_args([]))
print(p.parse_args(["--level", "200", "-v", "--format", "csv", "--pass-mark", "50"]))
print(p.parse_args(["--no-header"]).header)

Output:

Namespace(level=100, format='text', pass_mark=45.0, verbose=False, header=True)
Namespace(level=200, format='csv', pass_mark=50.0, verbose=True, header=True)
False
  • type converts the text to a number (or applies any function you give it).
  • choices restricts the allowed values, so anything else is an error.
  • action="store_true" makes a flag, which is True if present and False otherwise. store_false works the opposite way, and dest sets the attribute name.
  • A short form such as -v can be given alongside the long form.

Several Values: nargs

calc = argparse.ArgumentParser(prog="avg")
calc.add_argument("scores", type=float, nargs="+", help="one or more scores")
calc.add_argument("--drop-lowest", action="store_true")

args = calc.parse_args(["72", "58", "90", "45", "--drop-lowest"])
scores = sorted(args.scores)[1:] if args.drop_lowest else args.scores
print(scores, round(sum(scores) / len(scores), 2))

Output:

[58.0, 72.0, 90.0] 73.33

nargs="+" means one or more values, "*" means zero or more, "?" means zero or one, and a number such as 2 means exactly that many. The values arrive as a list.

Mutually Exclusive Options

Some options must not be used together, such as "quiet" and "verbose":

noise = argparse.ArgumentParser(prog="tool")
group = noise.add_mutually_exclusive_group()
group.add_argument("--quiet", action="store_true")
group.add_argument("--verbose", action="store_true")

print(noise.parse_args(["--quiet"]))
err = io.StringIO()
try:
    with contextlib.redirect_stderr(err):
        noise.parse_args(["--quiet", "--verbose"])
except SystemExit:
    print(err.getvalue().strip().splitlines()[-1])

Output:

Namespace(quiet=True, verbose=False)
tool: error: argument --verbose: not allowed with argument --quiet

Subcommands

Tools like git have subcommands (git add, git commit), each with its own arguments. argparse supports this with add_subparsers, and set_defaults(func=...) neatly connects each subcommand to the function that handles it.

def cmd_add(args):
    print(f"added {args.course} ({args.units} units)")

def cmd_list(args):
    print("listing", "all" if args.all else "current", "courses")

app = argparse.ArgumentParser(prog="courses")
sub = app.add_subparsers(dest="command", required=True)

add_p = sub.add_parser("add", help="register a course")
add_p.add_argument("course")
add_p.add_argument("--units", type=int, default=3)
add_p.set_defaults(func=cmd_add)

list_p = sub.add_parser("list", help="show registered courses")
list_p.add_argument("--all", action="store_true")
list_p.set_defaults(func=cmd_list)

for argv in (["add", "MTH101", "--units", "4"], ["list", "--all"]):
    args = app.parse_args(argv)
    args.func(args)

Output:

added MTH101 (4 units)
listing all courses

A Complete Tool: a GPA Calculator

A good structure is to put the logic in functions, build the parser in its own function, and call everything from main(argv=None). Passing None makes argparse read the real command line, while passing a list makes the tool easy to test.

import sys

GRADE_POINTS = {"A": 5, "B": 4, "C": 3, "D": 2, "E": 1, "F": 0}

def build_parser():
    p = argparse.ArgumentParser(prog="gpa", description="Calculate a GPA from course results.")
    p.add_argument("results", nargs="+", metavar="GRADE:UNITS",
                   help="results such as A:3 B:2 (grade letter and credit units)")
    p.add_argument("--decimals", type=int, default=2, help="decimal places (default: 2)")
    return p

def parse_result(text):
    grade, _, units = text.partition(":")
    if grade.upper() not in GRADE_POINTS or not units.isdigit():
        raise ValueError(f"bad result {text!r}: use GRADE:UNITS, e.g. A:3")
    return GRADE_POINTS[grade.upper()], int(units)

def main(argv=None):
    args = build_parser().parse_args(argv)
    try:
        pairs = [parse_result(r) for r in args.results]
    except ValueError as e:
        print("error:", e)
        return 1
    total_units = sum(u for _, u in pairs)
    gpa = sum(p * u for p, u in pairs) / total_units
    print(f"GPA: {gpa:.{args.decimals}f} over {total_units} units")
    return 0

print(main(["A:3", "B:2", "c:1"]))
print(main(["A:3", "B:3", "--decimals", "1"]))
print(main(["A:3", "X:2"]))

Output:

GPA: 4.33 over 6 units
0
GPA: 4.5 over 6 units
0
error: bad result 'X:2': use GRADE:UNITS, e.g. A:3
1

Saved as gpa.py, the script would finish with these lines, so that its return value becomes the program's exit code (0 for success, anything else for failure):

In a terminal:

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

# Then, in a terminal:
#   python gpa.py A:3 B:2 C:1
#   python gpa.py --help

Exit codes let other programs and scripts check whether your tool succeeded.

Common Mistakes with Python argparse

  • Forgetting type=int or type=float, so that the values arrive as strings and arithmetic fails or gives strange results.
  • Using type=bool for a flag. bool("False") is True. Use action="store_true".
  • Reading args.pass-mark. Dashes become underscores: args.pass_mark.
  • Calling parse_args() at import time, outside main, which makes the module impossible to import or test.
  • Leaving out help strings, which makes the tool hard for others (and your future self) to use.

Python argparse: Practice Exercises

  1. Build a parser for convert, taking a temperature (a float) and a --to option with choices "C" and "F" (default "F"). Convert the temperature accordingly.
  2. Add a --round option, with a default of 1, for the number of decimal places.
  3. Show that giving --to K produces an error.

Sample solutions:

conv = argparse.ArgumentParser(prog="convert")
conv.add_argument("temperature", type=float)
conv.add_argument("--to", choices=["C", "F"], default="F")
conv.add_argument("--round", type=int, default=1)

def convert(argv):
    a = conv.parse_args(argv)
    value = a.temperature * 9 / 5 + 32 if a.to == "F" else (a.temperature - 32) * 5 / 9
    return round(value, a.round)

print(convert(["37"]), convert(["98.6", "--to", "C"]), convert(["25", "--round", "0"]))
err = io.StringIO()
try:
    with contextlib.redirect_stderr(err):
        convert(["10", "--to", "K"])
except SystemExit:
    print(err.getvalue().strip().splitlines()[-1])

Output:

98.6 37.0 77.0
convert: error: argument --to: invalid choice: 'K' (choose from C, F)

Conclusion

argparse turns a script into a proper command-line tool. Create an ArgumentParser, add positional arguments (required, in order) and optional ones (with dashes, in any order), and read them from the Namespace returned by parse_args. Use type to convert values, default and choices to control them, store_true for flags, nargs for lists, mutually exclusive groups for conflicting options, and subparsers for git-style subcommands. argparse writes the help text and error messages for you, and a main(argv=None) function that returns an exit code keeps the tool easy to test.

Related Tutorials