CLI Tools with argparse
1 · The lesson
readA 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
# 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:
$ 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 asargs.input. - Optional arguments start with
-or--—parser.add_argument("--verbose")orparser.add_argument("-v", "--verbose"). They're optional by default.
Short and long forms together is the idiomatic pattern:
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.
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:
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
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:
nargs | Means | Result type |
|---|---|---|
| (unset) | Exactly one | scalar |
"?" | Zero or one | scalar or default |
"*" | Zero or more | list |
"+" | One or more | list |
N (int) | Exactly N | list |
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
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:
$ 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:
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:
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:
Namespace(command='add', description='buy milk', priority=1)
Two things to call out:
dest="command"is essential. Without it,args.commanddoesn't exist and you can't tell which subcommand the user invoked.required=Trueonadd_subparsersmakes "you must pick a subcommand" enforced — otherwise the user can runtodowith 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=...):
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:
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— success1— generic error2— invalid usage (this is whatargparseitself 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:
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 -:
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:
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:
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.
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:
| Library | Style | When to use |
|---|---|---|
| argparse | Imperative, stdlib | Default. No dependencies. |
| click | Decorator-based | Multi-command apps, fancy prompts, colour output |
| typer | Type-hint-based (built on click) | You already use type hints; love them |
| rich | (output only) | Tables, progress bars, syntax highlighting |
click example:
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):
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:
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 whethernot (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 intry / 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
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
pathplus 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:
$ 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 $?
1That last bit — exit code 1 visible to $? — is what makes your CLI usable from a Bash script.
What You Learned
argparseturnssys.argvinto a typed, validated, self-documenting interface. Stdlib, no install.- Positional vs optional — positional args have no leading dash and are required; optional args use
--nameand are optional by default. type=int/type=Pathcoerces strings at parse time. Custom callables work too — raiseArgumentTypeErrorfor nice errors.action="store_true"for on/off flags.action="count"for repeatable verbosity.dest=for--no-foostyle flags.add_subparsers(dest="command", required=True)for git-style CLIs.set_defaults(func=...)dispatch — each subparser gets its own handler.- Exit codes —
0success,1general error,2bad usage. Errors to stderr, not stdout. -for stdin is the Unix convention.argparse.FileTypeis convenient but often less clear than a manualopen.if __name__ == "__main__":wraps your CLI so the module stays importable. See modules.click,typer,richwhen you outgrowargparse— butargparsecovers ~80% of CLIs forever.
Next: Environment Variables & Configuration — read settings from the environment, never from hardcoded constants.