FreeCampus Python

Design a Friendly Command with argparse

Turn example terminal invocations into a clear argparse grammar with positionals, options, flags, validation, generated help, and focused subcommands.
python-foundations command-line-applications argparse command-design
Open in Colab
  • Level: Python Foundations
  • Estimated time: 4–5 hours
  • You will learn: Specify a command grammar, explain shell-to-argv conversion, choose clear argument shapes, validate command syntax, read generated help, and dispatch focused subcommands.
  • Practice in: Google Colab or JupyterLab for parser calls, plus a local terminal for real shell tokenization

A trail coordinator wants one program to summarize reports and check them for a particular status. These two invocations are the design sketch:

trail-report summarize signals.txt --limit 5
trail-report check - --status closed --tag weather --tag safety

Before writing Python, a user can already infer much of the interface:

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:

1. Read the command before writing the parser

Annotate this command one token at a time:

trail-report check reports/today.txt --status closed --verbose
Token Role Expected Python meaning
trail-report program name identifies the installed command
check subcommand selects the check operation
reports/today.txt positional value input path
--status option names the next token’s purpose
closed option value text, later constrained to known statuses
--verbose flag True when present

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.txt
trail-report summarize signals.txt --limit 10
trail-report check - --status open --tag river --tag bridge

Then list invalid shapes deliberately:

trail-report summarize
trail-report summarize signals.txt --limit zero
trail-report check signals.txt --status maybe

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 sys

for index, value in enumerate(sys.argv):
    print(index, repr(value))

When a file named show_args.py contains that code, a shell session might be:

$ python show_args.py check "North Ridge.txt" --limit 3
0 'show_args.py'
1 'check'
2 'North Ridge.txt'
3 '--limit'
4 '3'

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:

command_text = 'check "North Ridge.txt" --limit 3'
print(command_text.split())
['check', '"North', 'Ridge.txt"', '--limit', '3']

That list contains the quotes and splits the path incorrectly. In parser labs, write the already-tokenized list explicitly:

example_argv = ["check", "North Ridge.txt", "--limit", "3"]
print(example_argv)

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:

import argparse

parser = argparse.ArgumentParser(prog="show-input")
parser.add_argument("input")
namespace = parser.parse_args(["--", "--draft"])
print(namespace.input)
--draft

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.

Checkpoint: from shell tokens to namespace values

3. Let argparse write useful help

Start with the smallest honest parser:

import argparse


def 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 parser


parser = 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 --help
usage: trail-report [-h] INPUT

Summarize and validate trail checkpoint reports.

positional arguments:
  INPUT       report file or '-' for stdin

options:
  -h, --help  show this help message and exit

Use '-' 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:

import contextlib
import io

help_output = io.StringIO()

try:
    with contextlib.redirect_stdout(help_output):
        parser.parse_args(["--help"])
except SystemExit as error:
    help_status = error.code

print(help_status)
print("report file" in help_output.getvalue())
0
True

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:

limit_parser = argparse.ArgumentParser(prog="limit-demo")
limit_parser.add_argument("--limit", type=int, default=20)

print(limit_parser.parse_args([]).limit)
print(limit_parser.parse_args(["--limit", "3"]).limit)
20
3

Flags represent a switch

store_true makes absence and presence readable:

flag_parser = argparse.ArgumentParser(prog="flag-demo")
flag_parser.add_argument("--strict", action="store_true")

print(flag_parser.parse_args([]).strict)
print(flag_parser.parse_args(["--strict"]).strict)
False
True

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.

Repeatable options collect several values

tag_parser = argparse.ArgumentParser(prog="tag-demo")
tag_parser.add_argument("--tag", action="append", default=[])
tags = tag_parser.parse_args(["--tag", "weather", "--tag", "safety"]).tag
print(tags)
['weather', 'safety']

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.

Counted flags support several detail levels

verbosity_parser = argparse.ArgumentParser(prog="detail-demo")
verbosity_parser.add_argument("-v", "--verbose", action="count", default=0)

for argv in ([], ["-v"], ["-vv"]):
    print(argv, verbosity_parser.parse_args(argv).verbose)
[] 0
['-v'] 1
['-vv'] 2

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

choices is ideal for a short, stable set:

status_parser = argparse.ArgumentParser(prog="status-demo")
status_parser.add_argument(
    "--status",
    choices=("open", "limited", "closed"),
    default="open",
)

print(status_parser.parse_args(["--status", "closed"]).status)
closed

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)
    except ValueError as error:
        raise argparse.ArgumentTypeError("must be an integer") from error
    if value < 1:
        raise argparse.ArgumentTypeError("must be at least 1")
    return value


count_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 contextlib
import io

error_output = io.StringIO()

try:
    with contextlib.redirect_stderr(error_output):
        count_parser.parse_args(["--limit", "zero"])
except SystemExit as error:
    usage_status = error.code

print(usage_status)
print("must be an integer" in error_output.getvalue())
2
True

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.

Checkpoint: choose the clearest argument shape

6. Give different jobs focused subcommands

One command with fifty flags often represents several jobs forced together. Subcommands give each job its own focused arguments and help:

import argparse


def build_parser():
    parser = argparse.ArgumentParser(
        prog="trail-report",
        description="Summarize and validate trail checkpoint reports.",
    )
    subcommands = parser.add_subparsers(dest="command", required=True)

    summarize = subcommands.add_parser(
        "summarize", help="count reports by checkpoint 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.set_defaults(handler="check")

    return parser

Inspect three calls directly:

parser = build_parser()

cases = [
    ["summarize", "signals.txt"],
    ["summarize", "signals.txt", "--limit", "5"],
    ["check", "-", "--status", "open", "--tag", "river", "--strict"],
]

for argv in cases:
    print(parser.parse_args(argv))

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):
    return f"summarize {arguments.input} with limit {arguments.limit}"


def run_check(arguments):
    return f"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:

parser = build_parser()

root_help = parser.format_help()
print("summarize" in root_help)
print("check" in root_help)

Subcommand help is separate:

check_parser = next(
    action
    for action in parser._actions
    if isinstance(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.

Checkpoint: read help and failure evidence

8. Build the trail-report command grammar

Create a parser that satisfies this contract:

  1. program name trail-report and a useful description;
  2. required summarize and check subcommands;
  3. both commands accept positional INPUT;
  4. summarize accepts positive --limit, default 20;
  5. check accepts --status from open, limited, or closed, default closed;
  6. check accepts repeatable --tag, --strict, and counted -v/--verbose;
  7. both subcommands store a clear dispatch value; and
  8. help explains that - means stdin.

Start by predicting the namespace for each case:

lab_cases = [
    ["summarize", "signals.txt"],
    ["summarize", "signals.txt", "--limit", "5"],
    ["check", "-", "--tag", "river", "--tag", "bridge", "-vv"],
]

Then check field values rather than only printing the namespace:

lab_parser = build_parser()

summary = lab_parser.parse_args(lab_cases[1])
assert summary.command == "summarize"
assert summary.input == "signals.txt"
assert summary.limit == 5

check = lab_parser.parse_args(lab_cases[2])
assert check.command == "check"
assert check.input == "-"
assert check.tag == ["river", "bridge"]
assert check.verbose == 2
assert check.strict is False

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 argparse


def positive_count(text):
    """Return a positive integer parsed from text."""
    try:
        value = int(text)
    except ValueError as error:
        raise argparse.ArgumentTypeError("must be an integer") from error
    if value < 1:
        raise argparse.ArgumentTypeError("must be at least 1")
    return value


def 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.

Continue learning

Back to top