Before writing Python, a user can already infer much of the interface:
summarize and check select different jobs;
an input path follows the job, and - probably means standard input;
--limit and --status attach named values;
--tag may be repeated; and
ordering and spelling are intentional.
That public shape is a command grammar. argparse translates valid strings in that grammar into Python values, generates help, and rejects invalid shapes consistently.
As you work, answer these questions:
How does text typed in a shell become a Python list of argument strings?
When is a positional, option, flag, repeatable option, or subcommand clearest?
Which invalid values should argparse reject before the core runs?
What can help and usage errors tell both a user and a developer?
How can a parser be inspected without changing the notebook’s real arguments?
The table is more useful than immediately calling add_argument(). It answers which values are essential, which have defaults, and which combinations make sense. Add three more examples before implementation:
trail-report summarize signals.txttrail-report summarize signals.txt --limit 10trail-report check ---status open --tag river --tag bridge
A good parser should reject all three with usage evidence. The reusable trail logic should never need to interpret "zero" as an integer or guess what "maybe" means.
2. Follow shell text into sys.argv
The shell and Python do different jobs. The shell first turns a command line into argument strings. It starts the process and supplies those strings. Python exposes them as sys.argv:
import sysfor index, value inenumerate(sys.argv):print(index, repr(value))
When a file named show_args.py contains that code, a shell session might be:
Quotes kept North Ridge.txt together as one argument. The quote characters themselves are not normally part of the value. Every element is a string, including "3". argparse can perform the requested integer conversion later.
Different shells have different quoting and variable-expansion rules. Do not attempt to reproduce a shell by calling .split() on a string:
argparse.ArgumentParser.parse_args() accepts such a list. When no list is passed, it reads the real process arguments after sys.argv[0]. Explicit lists make notebook experiments predictable.
Notice that quoting belongs to the shell step; argparse receives a list of strings and returns a namespace containing converted values.
flowchart LR
A["Shell command text"] --> B["Shell tokenization"]
B --> C["sys.argv strings"]
C --> D["ArgumentParser"]
D --> E["Namespace with Python values"]
Use -- when a value resembles an option
Suppose a file is literally named --draft. The parser may interpret it as an option. Many command grammars use a standalone -- to end option processing:
This is not a universal promise that every application must accept every filename. It is a widely understood boundary worth preserving when argparse’s normal grammar supports it.
import argparsedef build_basic_parser(): parser = argparse.ArgumentParser( prog="trail-report", description="Summarize and validate trail checkpoint reports.", epilog="Use '-' as INPUT to read from standard input.", ) parser.add_argument("input", metavar="INPUT", help="report file or '-' for stdin")return parserparser = build_basic_parser()arguments = parser.parse_args(["signals.txt"])print(arguments)
Namespace(input='signals.txt')
The namespace is a simple object whose attributes correspond to parser destinations. arguments.input is still text because no conversion was requested.
Generated help is part of the interface. In a terminal, argparse prints it and exits successfully:
$ trail-report --helpusage: trail-report [-h] INPUTSummarize and validate trail checkpoint reports.positional arguments:INPUT report file or '-' for stdinoptions:-h,--help show this help message and exitUse'-' as INPUT to read from standard input.
Exact section labels can vary slightly by supported Python version. The stable design questions are more important: Can a new user tell what the command does? Does each metavariable communicate the expected kind of value? Does the help mention a special value such as -?
You can capture help in a direct check. --help raises SystemExit(0) after writing to stdout:
Catch SystemExit here because observing help is the focused task. Do not wrap an entire application in except SystemExit: and discard the result; that would prevent argparse from fulfilling its process contract.
4. Choose an argument shape that matches the decision
Positionals identify the central subject
An input path is a good positional when almost every operation needs it:
parser.add_argument("input", metavar="INPUT")
Positionals are concise, but their meaning depends on order. Five unexplained positionals make a command difficult to read. Move secondary choices behind named options.
Options attach a value to a visible name
parser.add_argument("--limit",type=int, default=20, metavar="COUNT",help="show at most COUNT records (default: 20)",)
type=int converts the string during parsing. default=20 is already an integer. The result is predictable:
Do not use type=bool. bool("false") is True because any non-empty string is truthy. A flag needs an action, or an option needs an explicit text-to-boolean converter.
append preserves order and makes repetition explicit. If no tag is supplied, the selected default controls whether the core sees [] or None. Choose that contract deliberately.
An explicit zero default prevents the absent value from being None. Later, the logging lesson will translate 0, 1, and 2 into logging levels.
Short options are conveniences, not a second secret vocabulary. Reserve -v, -q, or -o for conventions the audience is likely to recognize, and always provide descriptive long forms for less obvious choices. Argparse accepts -vv for a counted -v and often accepts --limit=3 as well as --limit 3; show the ordinary spelling in help and examples rather than requiring users to discover every compact form. Never reuse one short letter for different meanings inside related subcommands unless the distinction is genuinely clear.
5. Reject invalid command syntax before the core runs
An unsupported value produces usage and status 2. It should not reach a core function that silently guesses.
For a value with a compact syntax rule, write a converter:
def positive_count(text):"""Convert text to a positive integer for argparse."""try: value =int(text)exceptValueErroras error:raise argparse.ArgumentTypeError("must be an integer") from errorif value <1:raise argparse.ArgumentTypeError("must be at least 1")return valuecount_parser = argparse.ArgumentParser(prog="count-demo")count_parser.add_argument("--limit", type=positive_count, default=20)print(count_parser.parse_args(["--limit", "4"]).limit)
4
The converter owns command syntax: can this token represent a positive count? A domain function still owns questions such as whether 4 reports are available or allowed for this account. That separation keeps the core reusable when values come from a web request or another Python caller.
Capture a usage error to inspect its channel and status:
import contextlibimport ioerror_output = io.StringIO()try:with contextlib.redirect_stderr(error_output): count_parser.parse_args(["--limit", "zero"])exceptSystemExitas error: usage_status = error.codeprint(usage_status)print("must be an integer"in error_output.getvalue())
2True
Argparse owns this grammar failure. Later processing failures usually use a different application status, often 1, and a message written by the application boundary.
NotePrefer Path values over an already-open file here
argparse.FileType can open files during parsing, but passing a pathlib.Path or the special "-" string gives the application boundary control over encoding, lifetime, cleanup, and error translation. Unit 9 already established those responsibilities.
The namespace contains only data needed by the selected subcommand plus the dispatch marker. A later main() can choose a callable, but the core should not receive the namespace itself. A namespace couples domain behavior to the CLI framework and makes another caller invent fake command arguments.
set_defaults(handler="check") uses a string here so the parsing example stays self-contained. A real application may store a function instead:
def run_summary(arguments):returnf"summarize {arguments.input} with limit {arguments.limit}"def run_check(arguments):returnf"check {arguments.input} for {arguments.status}"
Then set_defaults(handler=run_summary) supports arguments.handler(arguments). Both designs require an explicit adapter that extracts ordinary values before calling reusable application logic.
Do not create subcommands only for decoration
If the program performs one job and merely changes format, one parser with a --format option may be clearer. Use subcommands when verbs have meaningfully different inputs or workflows. Ask a new user whether convert --format json or json as a mysterious subcommand is easier to predict.
7. Improve help by reading it as a new user
Help is generated, but it is still authored. Inspect each level:
check_parser =next( actionfor action in parser._actionsifisinstance(action, argparse._SubParsersAction)).choices["check"]print(check_parser.format_usage())
The _actions access above deliberately inspects an internal attribute for a short learning experiment. Production code should normally keep the returned subparser while building, or test help through public process behavior, rather than depend on a private implementation detail.
Review help with these questions:
Does the description say what result the command produces?
Does every positional have a useful metavar and explanation?
Are defaults stated when they affect behavior?
Are choices meaningful rather than internal abbreviations?
Does each subcommand help mention its special input and output?
Is the shortest ordinary invocation actually short?
Can invalid syntax be repaired by reading the usage line?
Do not overload epilog with a manual. Give one or two examples, then link to longer documentation in the package README.
Add focused capture checks for --help, missing subcommand, invalid status, a non-integer limit, zero limit, and an input named --draft after --.
Hint 1: sketch the grammar tree
Write trail-report at the root. Draw two child verbs. Put shared-looking INPUT on both children because argparse subparsers own their arguments. Add only the options meaningful to each child.
Hint 2: implement conversion separately
Write and check positive_count(text) before building the parser. It should catch ValueError, raise ArgumentTypeError with a useful message, reject values below 1, and return the integer otherwise.
Hint 3: capture one exit at a time
Use a fresh StringIO, redirect_stdout or redirect_stderr, and a focused try/except SystemExit around one parse_args() call. Record both error.code and the captured text.
Show one complete parser after attempting the lab
import argparsedef positive_count(text):"""Return a positive integer parsed from text."""try: value =int(text)exceptValueErroras error:raise argparse.ArgumentTypeError("must be an integer") from errorif value <1:raise argparse.ArgumentTypeError("must be at least 1")return valuedef build_trail_parser(): parser = argparse.ArgumentParser( prog="trail-report", description="Summarize and validate trail checkpoint reports.", epilog="Use '-' as INPUT to read from standard input.", ) subcommands = parser.add_subparsers(dest="command", required=True) summarize = subcommands.add_parser("summarize", help="count reports by status" ) summarize.add_argument("input", metavar="INPUT") summarize.add_argument("--limit", type=positive_count, default=20) summarize.set_defaults(handler="summarize") check = subcommands.add_parser("check", help="emit reports matching one status" ) check.add_argument("input", metavar="INPUT") check.add_argument("--status", choices=("open", "limited", "closed"), default="closed", ) check.add_argument("--tag", action="append", default=[]) check.add_argument("--strict", action="store_true") check.add_argument("-v", "--verbose", action="count", default=0) check.set_defaults(handler="check")return parser
This solution builds only the grammar. It does not open the input or run the trail logic; those jobs belong to later boundaries.
Key points
Design example invocations and invalid shapes before writing parser calls.
The shell turns command text into strings; argparse parses the resulting argument list into converted Python values.
Positionals identify central subjects, options label values, flags represent switches, repeatable actions collect values, and subcommands separate jobs.
Use type, choices, and focused converters for command-syntax validation; keep domain validation in reusable application code.
Generated help, usage text, and status 2 errors are observable parts of the public command interface.
Pass an explicit list to parse_args() for direct experiments, and do not pass the resulting Namespace into the reusable core.