PythonMastery
intermediate 20 min read · lesson 10 of 12 in Python How-To

CLI Tools with argparse

1 · The lesson

read

A script that reads sys.argv[1] directly is fine for the first ten minutes. The moment someone other than you runs it — or you come back to it in three months — you want --help, type-checked flags, and a real error when an argument is missing. That's argparse, and it's in the standard library.

This lesson covers argparse end-to-end: positional vs optional arguments, type coercion, subcommands (the git-style CLI pattern), exit codes, and the dispatch pattern that scales from one command to twenty. We'll also flag where click, typer, and rich fit.


1. Why a Real CLI Matters

The progression every script goes through:

  • input() prompts — fine for a tutorial, terrible for automation. You can't pipe, you can't script, you can't --help.
  • Bare positional args (sys.argv[1]) — works, but no validation, no documentation, no error messages worth reading.
  • argparse — flags discoverable via --help, types coerced automatically, errors that tell the user what went wrong.

Flags beat prompts because they compose: you can wire your CLI into a pipeline, a cron job, or another script. --help is documentation that can never go stale because it's generated from the code.


2. The Five-Line argparse Tour

python
# greet.py
import argparse

parser = argparse.ArgumentParser(description="Say hello.")
parser.add_argument("name", help="who to greet")
parser.add_argument("--shout", action="store_true", help="UPPERCASE the output")
args = parser.parse_args()

greeting = f"Hello, {args.name}!"
print(greeting.upper() if args.shout else greeting)

Run it:

text
$ python greet.py world
Hello, world!

$ python greet.py world --shout
HELLO, WORLD!

$ python greet.py --help
usage: greet.py [-h] [--shout] name

Say hello.

positional arguments:
  name        who to greet

options:
  -h, --help  show this help message and exit
  --shout     UPPERCASE the output

$ python greet.py
usage: greet.py [-h] [--shout] name
greet.py: error: the following arguments are required: name

Three lines of setup gave you --help, required-arg validation, and a real error message. That's the deal.


3. Positional vs Optional

The naming convention is argparse-specific and worth getting right:

  • Positional arguments have no leading - — parser.add_argument("input"). They're required by default and accessed as args.input.
  • Optional arguments start with - or -- — parser.add_argument("--verbose") or parser.add_argument("-v", "--verbose"). They're optional by default.

Short and long forms together is the idiomatic pattern:

python
parser.add_argument("-v", "--verbose", action="store_true")
parser.add_argument("-o", "--output", help="output file path")
+ setup added so this can run · defines parser
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

parser = _AutoMock('parser')

The long form (--verbose) shows up in --help and is what scripts should use for clarity. The short form (-v) is for typing at the prompt. The attribute name comes from the long form — args.verbose, args.output — with dashes converted to underscores.


4. Types — Don't Settle for Strings

argparse returns strings by default. If you forget to coerce, you'll discover at runtime that "5" + 1 is a TypeError.

python
parser.add_argument("--port", type=int, default=8000)
parser.add_argument("--ratio", type=float)
parser.add_argument("--config", type=pathlib.Path, default=Path("config.yaml"))
+ setup added so this can run · defines parser, pathlib, Path
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

parser = _AutoMock('parser')
pathlib = _AutoMock('pathlib')
def Path(*_a, **_kw):
    print('-> Path() called')
    return _AutoMock('Path()')

type=... is any callable that takes a string and returns the value you want. int, float, and Path cover most needs. The conversion runs before parse_args() returns, so by the time you read args.port it's already an integer — or argparse has bailed with error: argument --port: invalid int value: 'abc'.

Custom converters are just functions:

python
def positive_int(s):
    n = int(s)
    if n <= 0:
        raise argparse.ArgumentTypeError(f"must be positive, got {n}")
    return n

parser.add_argument("--workers", type=positive_int, default=4)
+ setup added so this can run · defines parser, argparse
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

parser = _AutoMock('parser')
argparse = _AutoMock('argparse')

Raising ArgumentTypeError triggers the same nice error format as built-in failures. Raising plain ValueError works too — argparse catches both.


5. Defaults, Choices, nargs

python
parser.add_argument("--mode", default="dev", choices=["dev", "staging", "prod"])
parser.add_argument("--retries", type=int, default=3)
parser.add_argument("--name", required=True)               # force-required optional
+ setup added so this can run · defines parser
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

parser = _AutoMock('parser')

choices=[...] restricts allowed values — argparse rejects anything else with a helpful message. required=True makes an optional argument mandatory (rare, but useful when you want a flag-style required input).

nargs controls how many values an argument consumes:

nargsMeansResult type
(unset)Exactly onescalar
"?"Zero or onescalar or default
"*"Zero or morelist
"+"One or morelist
N (int)Exactly Nlist
python
parser.add_argument("files", nargs="+")                    # at least one file required
parser.add_argument("--tags", nargs="*", default=[])       # zero or more
parser.add_argument("--coord", nargs=2, type=float)        # exactly two floats
+ setup added so this can run · defines parser
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

parser = _AutoMock('parser')

nargs="+" is the pattern for "this command takes a list of files." It enforces "at least one" so the user gets a helpful error if they forget.


6. Boolean Flags — store_true and count

python
parser.add_argument("--verbose", "-v", action="store_true")        # --verbose → True
parser.add_argument("--quiet",   "-q", action="store_false")       # --quiet → False
parser.add_argument("--verbose-count", "-V", action="count", default=0)
+ setup added so this can run · defines parser
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

parser = _AutoMock('parser')

store_true is the standard pattern for an on/off flag — present means True, absent means False (the default). store_false flips it.

action="count" lets a flag be repeated for intensity:

text
$ myscript -V          # args.verbose_count = 1
$ myscript -VV         # args.verbose_count = 2
$ myscript -VVV        # args.verbose_count = 3  (the classic verbose-debug pattern)

For --no-foo style negation flags — common in build tools — the canonical incantation is:

python
parser.add_argument("--no-cache", dest="cache", action="store_false", default=True)
+ setup added so this can run · defines parser
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

parser = _AutoMock('parser')

That gives you args.cache == True by default and args.cache == False when the user passes --no-cache. The dest= is the trick — without it you'd get the awkward args.no_cache.


7. Subcommands — Git-Style CLIs

Once your tool grows past two or three commands, model it like git — top-level commands with their own arguments:

python
import argparse

parser = argparse.ArgumentParser(prog="todo")
subparsers = parser.add_subparsers(dest="command", required=True)

# todo add "task description"
add_p = subparsers.add_parser("add", help="add a task")
add_p.add_argument("description")
add_p.add_argument("--priority", type=int, default=3)

# todo list [--all]
list_p = subparsers.add_parser("list", help="list tasks")
list_p.add_argument("--all", action="store_true", help="include completed")

# todo done <id>
done_p = subparsers.add_parser("done", help="mark a task complete")
done_p.add_argument("id", type=int)

args = parser.parse_args()
print(args)

Output for todo add "buy milk" --priority 1:

text
Namespace(command='add', description='buy milk', priority=1)

Two things to call out:

  • dest="command" is essential. Without it, args.command doesn't exist and you can't tell which subcommand the user invoked.
  • required=True on add_subparsers makes "you must pick a subcommand" enforced — otherwise the user can run todo with no args and get nothing.

8. The Dispatch Pattern

Once you have subcommands, the cleanest way to wire each one to a handler function is set_defaults(func=...):

python
def cmd_add(args):
    print(f"adding: {args.description!r} (priority {args.priority})")

def cmd_list(args):
    print(f"listing all={args.all}")

def cmd_done(args):
    print(f"completing #{args.id}")

# inside the parser setup:
add_p.set_defaults(func=cmd_add)
list_p.set_defaults(func=cmd_list)
done_p.set_defaults(func=cmd_done)

args = parser.parse_args()
args.func(args)                                      # dispatch
+ setup added so this can run · defines add_p, list_p, done_p, parser
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

add_p = _AutoMock('add_p')
list_p = _AutoMock('list_p')
done_p = _AutoMock('done_p')
parser = _AutoMock('parser')

Now adding a new command is two steps: define the subparser, write the handler, point them at each other. No if args.command == "add": ... chain that grows linearly with your commands.


9. Errors, Exit Codes, and stderr

When something goes wrong, print to stderr and exit non-zero. Pipelines depend on this:

python
import sys

if not config_path.exists():
    print(f"error: config not found at {config_path}", file=sys.stderr)
    sys.exit(2)
+ setup added so this can run · defines config_path
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

config_path = _AutoMock('config_path')

The conventions every Unix tool follows:

  • 0 — success
  • 1 — generic error
  • 2 — invalid usage (this is what argparse itself returns)
  • >2 — specific failure modes (your choice; document them)

You can also use parser.error("message") to bail with the same format argparse uses for missing-arg errors — it prints to stderr, shows usage, and exits with code 2:

python
if args.workers > 100 and not args.force:
    parser.error("--workers > 100 requires --force")
+ setup added so this can run · defines args, parser
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

args = _AutoMock('args')
parser = _AutoMock('parser')

Why stderr matters: when someone pipes your output (mytool | grep foo), error messages mixed into stdout would corrupt their pipeline. Errors on stderr stay visible on the terminal while stdout streams cleanly.


10. Reading from stdin — the - Convention

CLI tools that process files typically also accept input on stdin, signalled by -:

python
import sys

if args.input == "-":
    text = sys.stdin.read()
else:
    text = Path(args.input).read_text(encoding="utf-8")
+ setup added so this can run · defines args, Path
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

args = _AutoMock('args')
def Path(*_a, **_kw):
    print('-> Path() called')
    return _AutoMock('Path()')

That lets users do cat data.txt | mytool - or mytool data.txt interchangeably. For line-by-line streaming:

python
source = sys.stdin if args.input == "-" else open(args.input, encoding="utf-8")
for line in source:
    process(line.rstrip("\n"))
+ setup added so this can run · defines sys, process, args
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

sys = _AutoMock('sys')
def process(*_a, **_kw):
    print('-> process() called')
    return _AutoMock('process()')
args = _AutoMock('args')

There's also argparse.FileType("r"), which opens the file for you — but it's awkward with encodings, doesn't close the handle, and doesn't help with the - convention beyond the basics. The manual version above is usually clearer.


11. Polishing Help — Epilog and Examples

argparse generates the basics. For a CLI people will actually use, add an epilog with example invocations:

python
parser = argparse.ArgumentParser(
    prog="wordcount",
    description="Count lines, words, and characters in a file or stdin.",
    epilog="""\
examples:
  wordcount notes.txt              # all three counts
  wordcount -l notes.txt           # lines only
  cat notes.txt | wordcount -wc -  # words and chars from stdin
""",
    formatter_class=argparse.RawDescriptionHelpFormatter,
)
+ setup added so this can run · defines argparse
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

argparse = _AutoMock('argparse')

RawDescriptionHelpFormatter preserves your line breaks — the default formatter reflows everything into one paragraph, which destroys aligned examples. Always use it when your description or epilog has formatting.


12. The if __name__ == "__main__": Block

Wrap the CLI in a main() function and gate it on __main__. This lets your module be both a CLI and an importable library — see modules.

python
def main(argv=None):
    parser = build_parser()
    args = parser.parse_args(argv)
    return args.func(args) or 0          # handler returns exit code, defaults to 0

if __name__ == "__main__":
    sys.exit(main())
+ setup added so this can run · defines build_parser, sys
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def build_parser(*_a, **_kw):
    print('-> build_parser() called')
    return _AutoMock('build_parser()')
sys = _AutoMock('sys')

Passing argv=None to parse_args falls through to sys.argv[1:] — which is what you want in production. But accepting argv as a parameter means you can call main(["add", "buy milk"]) from a test, no shell required.


13. Beyond argparse — click, typer, rich

argparse is in the standard library, which is its single biggest virtue — zero install. The third-party options are nicer in places:

LibraryStyleWhen to use
argparseImperative, stdlibDefault. No dependencies.
clickDecorator-basedMulti-command apps, fancy prompts, colour output
typerType-hint-based (built on click)You already use type hints; love them
rich(output only)Tables, progress bars, syntax highlighting

click example:

python
import click

@click.command()
@click.argument("name")
@click.option("--shout", is_flag=True)
def greet(name, shout):
    msg = f"Hello, {name}!"
    click.echo(msg.upper() if shout else msg)

if __name__ == "__main__":
    greet()

typer example (basically click with type hints doing the schema work):

python
import typer

def greet(name: str, shout: bool = False):
    msg = f"Hello, {name}!"
    print(msg.upper() if shout else msg)

if __name__ == "__main__":
    typer.run(greet)

rich is a different category — it doesn't replace argparse, it makes your output pretty. Tables, progress bars, syntax-highlighted tracebacks, Markdown rendering. Add it on top of any of the above.

Pick argparse until you feel real friction. The friction usually shows up around subcommand sprawl, prompt-style interactions, or "I want my CLI to look professional."


14. Common Mistakes

1. Reading sys.argv[1] directly. No --help, no validation, no error message worth the name. Always go through argparse.

2. Forgetting type=int. args.port is a string by default, and args.port + 1 becomes "80001" instead of 8001. Coerce at parse time.

3. --no- flags done wrong. The canonical form is add_argument("--no-cache", dest="cache", action="store_false", default=True). Without dest=, you get args.no_cache and have to remember to invert it everywhere.

4. Subcommands without dest=. add_subparsers(dest="command") is what lets you tell which subcommand fired. Without it, args.command doesn't exist and dispatch is impossible.

5. Printing errors to stdout. Stdout is for output that downstream tools will consume. Errors go to sys.stderr (or use parser.error(...)). Otherwise you break pipelines.

6. No examples in --help. A 50-flag CLI with no example invocations is a CLI nobody uses correctly. Add an epilog with two or three real commands.

7. argparse.FileType("r") everywhere. Convenient, but it doesn't close handles, doesn't compose well with encoding overrides, and obscures the open call. Manual open() (or Path.read_text()) is usually clearer.


🎯 Your Turn — A Real wordcount CLI

Build wordcount.py that mirrors the classic Unix wc tool, with proper argparse plumbing.

Requirements:

1. Positional argument path — a file path, or - for stdin.
2. Optional flags: -l/--lines, -w/--words, -c/--chars. Show counts for whichever flags are given; if none are given, show all three.
3. Exit code 0 on success, 2 on bad usage (handled by argparse automatically), 1 if the file can't be read.
4. Errors print to stderr.
5. --help shows a useful description and at least one example in the epilog.

Skeleton:

python
import argparse
import sys
from pathlib import Path

def build_parser():
    parser = argparse.ArgumentParser(
        prog="wordcount",
        description="Count lines, words, and characters.",
        epilog="example:  wordcount -l notes.txt",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    # TODO 1: add path (positional)
    # TODO 2: add -l, -w, -c flags
    return parser

def read_text(path):
    # TODO 3: stdin if path == "-", else read file with utf-8
    ...

def main(argv=None):
    parser = build_parser()
    args = parser.parse_args(argv)
    # TODO 4: read text, count, print only requested counts (or all if none chosen)
    ...

if __name__ == "__main__":
    sys.exit(main())
Hint 1 — "All if none" Check whether not (args.lines or args.words or args.chars). If so, set all three to True. That gives you the classic wc behaviour where no flags means all counts.
Hint 2 — Catching read errors Wrap the read in try / except OSError as e, print f"wordcount: {e}" to sys.stderr, and return 1 from main(). Argparse covers usage errors; you cover I/O errors.
Show full solution
python
import argparse
import sys
from pathlib import Path


def build_parser():
    parser = argparse.ArgumentParser(
        prog="wordcount",
        description="Count lines, words, and characters in a file or stdin.",
        epilog="""\
examples:
  wordcount notes.txt              # show all three counts
  wordcount -l notes.txt           # lines only
  cat notes.txt | wordcount -wc -  # words and chars from stdin
""",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument("path", help="file path, or '-' for stdin")
    parser.add_argument("-l", "--lines", action="store_true", help="show line count")
    parser.add_argument("-w", "--words", action="store_true", help="show word count")
    parser.add_argument("-c", "--chars", action="store_true", help="show character count")
    return parser


def read_text(path):
    if path == "-":
        return sys.stdin.read()
    return Path(path).read_text(encoding="utf-8")


def main(argv=None):
    parser = build_parser()
    args = parser.parse_args(argv)

    try:
        text = read_text(args.path)
    except OSError as e:
        print(f"wordcount: {e}", file=sys.stderr)
        return 1

    # If no flags given, show all three
    show_lines = args.lines
    show_words = args.words
    show_chars = args.chars
    if not (show_lines or show_words or show_chars):
        show_lines = show_words = show_chars = True

    parts = []
    if show_lines:
        parts.append(f"{text.count(chr(10)):>8}")
    if show_words:
        parts.append(f"{len(text.split()):>8}")
    if show_chars:
        parts.append(f"{len(text):>8}")
    parts.append(args.path)

    print(" ".join(parts))
    return 0


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

What you built:

  • A positional path plus three boolean flags — short and long forms.
  • Stdin support via the - convention, which makes the tool pipeline-friendly.
  • Auto-fallback to "all three counts" when no flag is given — matches the real wc.
  • Errors on stderr with a non-zero exit code, so shell scripts can detect failure.
  • An epilog with three real example invocations, kept verbatim by RawDescriptionHelpFormatter.

Test it:

text
$ echo -e "one\ntwo\nthree" | python wordcount.py -
       3        3       14 -

$ python wordcount.py -l README.md
      42 README.md

$ python wordcount.py missing.txt
wordcount: [Errno 2] No such file or directory: 'missing.txt'
$ echo $?
1

That last bit — exit code 1 visible to $? — is what makes your CLI usable from a Bash script.


What You Learned

  • argparse turns sys.argv into a typed, validated, self-documenting interface. Stdlib, no install.
  • Positional vs optional — positional args have no leading dash and are required; optional args use --name and are optional by default.
  • type=int / type=Path coerces strings at parse time. Custom callables work too — raise ArgumentTypeError for nice errors.
  • action="store_true" for on/off flags. action="count" for repeatable verbosity. dest= for --no-foo style flags.
  • add_subparsers(dest="command", required=True) for git-style CLIs.
  • set_defaults(func=...) dispatch — each subparser gets its own handler.
  • Exit codes — 0 success, 1 general error, 2 bad usage. Errors to stderr, not stdout.
  • - for stdin is the Unix convention. argparse.FileType is convenient but often less clear than a manual open.
  • if __name__ == "__main__": wraps your CLI so the module stays importable. See modules.
  • click, typer, rich when you outgrow argparse — but argparse covers ~80% of CLIs forever.

Next: Environment Variables & Configuration — read settings from the environment, never from hardcoded constants.