Structuring and testing CLIs

argparse · Advanced · 11 min

A CLI you can call from tests is a CLI you can change safely.

A testable layout separates parsing from work:

def build_parser(): ...
def main(argv=None):
    args = build_parser().parse_args(argv)
    ...
    return 0
if __name__ == "__main__":
    raise SystemExit(main())

parse_args(None) reads sys.argv[1:], so the real CLI works, while tests call main(["--port", "1"]) directly. Return an exit code from main and pass it to SystemExit.

Usage errors call sys.exit(2), which raises SystemExit. In tests, assert on it: with pytest.raises(SystemExit) as e: ... then e.value.code == 2, and capture stderr to check the message.

ArgumentParser(exit_on_error=False) makes some errors (such as invalid type conversions) raise argparse.ArgumentError instead of exiting, useful when embedding a parser in a larger program, though not every error path respects it, so tests should still expect SystemExit for others.

parse_known_args(argv) returns (namespace, leftovers) instead of failing on unknown arguments. Use it for wrappers that pass the rest to another program. parse_intermixed_args allows options in between positionals that have nargs.

Example

import argparse, contextlib, io

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

def main(argv=None):
    args = build_parser().parse_args(argv)
    for _ in range(args.times):
        print(f"hello {args.name}")
    return 0
assert main(["ada", "--times", "2"]) == 0         # hello ada / hello ada

err = io.StringIO()
with contextlib.redirect_stderr(err):
    try:
        main([])
    except SystemExit as e:
        print("exit code:", e.code)               # exit code: 2
print(err.getvalue().splitlines()[-1])            # greet: error: the following arguments are required: name

known, rest = build_parser().parse_known_args(["bob", "--color", "-x"])
print(known.name, rest)                           # bob ['--color', '-x']

Remember

  • main(argv=None) + parse_args(argv) makes the CLI callable from tests
  • Usage errors raise SystemExit(2); assert on it and on stderr
  • parse_known_args returns leftovers instead of failing

Watch out for

  • Testing by patching sys.argv globally instead of passing argv explicitly.

Check yourself

  1. How does a test check that main(['--bad']) is rejected as a usage error?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/argparse.html#exit-on-error