CLI Tools with argparse
1 · The lesson
readA script that reads sys.argv[1] directly is fine for the first ten minutes. As soon as someone else runs it, or you come back to it after three months, you want --help, type-checked flags, and a clear error when an argument is missing. That's argparse, and it's in the standard library.
This lesson covers argparse from start to finish: positional vs optional arguments, type conversion, subcommands (the git-style CLI pattern), exit codes, and a dispatch pattern that works as well for twenty commands as for one. We'll also show where click, typer and rich fit.
1. Why a Real CLI Matters
The progression every script goes through:
input()prompts: fine for a tutorial, bad for automation. You can't pipe input in, you can't call it from a script, and there is no--help.- Bare positional args (
sys.argv[1]): this works, but there is no validation, no documentation and no useful error messages. argparse: users can find every flag with--help, types are converted automatically, and errors tell the user what went wrong.
Flags are better than prompts because other tools can use them: you can call your CLI from a pipeline, a cron job or another script. --help is documentation that never goes out of date, because it is 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, a check for required arguments, and a clear error message.
3. Positional vs Optional
These two names have a specific meaning in argparse:
- 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.
The usual pattern is to give both a short and a long form:
parser.add_argument("-v", "--verbose", action="store_true") parser.add_argument("-o", "--output", help="output file path")
setup added so this can run · re-runs 1 earlier block on this page first
# 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') # Earlier blocks on this page that this one builds on, re-run quietly first. try: import argparse except Exception: pass _pm_earlier = [ '# greet.py\nimport argparse\n\nparser = argparse.ArgumentParser(description="Say hello.")\nparser.add_argument("name", help="who to greet")\nparser.add_argument("--shout", action="store_true", help="UPPERCASE the output")\nargs = parser.parse_args()\n\ngreeting = f"Hello, {args.name}!"\nprint(greeting.upper() if args.shout else greeting)', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
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 Accept Plain Strings
argparse returns strings by default. If you forget to convert them, you'll find out 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. If the conversion fails, argparse stops 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 gives the same clear error format as the built-in failures, with your message. Raising plain ValueError works too, but then argparse shows a general "invalid value" message instead of yours.
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) # an option that must be given
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=[...] limits the allowed values, and 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 sets it to True parser.add_argument("--quiet", "-q", action="store_false") # --quiet sets it to 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 does the opposite.
action="count" lets the user repeat a flag to increase a level:
$ 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, which are common in build tools, the standard form 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 important part. 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, each with its 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 note:
dest="command"is important. Without it,args.commanddoesn't exist, and the only way to tell which subcommand the user ran is theset_defaultsdispatch in Section 8.required=Trueonadd_subparsersmakes the user choose a subcommand. Otherwise the user can runtodowith no arguments and nothing useful happens.
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) # call the handler for the chosen command
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 takes three steps: define the subparser, write the handler, and connect them with set_defaults. There is no if args.command == "add": ... chain that gets longer with every command.
9. Errors, Exit Codes, and stderr
When something goes wrong, print to stderr and exit non-zero. Pipelines rely 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: general error2: invalid usage (this is whatargparseitself returns)>2: specific kinds of failure (your choice, but document them)
You can also use parser.error("message") to stop with the same format argparse uses for missing-argument errors. It prints to stderr, shows the usage line, 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 end up in their data. Errors on stderr still appear in the terminal, and stdout contains only the real output.
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. It is deprecated since Python 3.14. It is awkward with encodings, doesn't close the file, and only handles the - convention in a basic way. The manual version above is usually clearer.
11. Better Help: Epilog and Examples
argparse generates the basics. For a CLI that people will really use, add an epilog with example commands:
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 keeps your line breaks. The default formatter joins everything into one paragraph, which ruins 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 # the handler returns the exit code (0 if it returns None) 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 makes it use 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, without a shell.
13. Beyond argparse: click, typer, rich
argparse is in the standard library, so there is nothing to install, and that is its main advantage. The third-party options are nicer in some ways:
| 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 and like 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 (it is built on click, and uses your type hints to define the arguments):
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 kind of library. It works alongside argparse and formats your output: tables, progress bars, syntax-highlighted tracebacks, Markdown rendering. Add it on top of any of the above.
Use argparse until it really starts to get in your way. That usually happens when you have many subcommands, when you need interactive prompts, or when you want your CLI to look more polished.
14. Common Mistakes
1. Reading sys.argv[1] directly. No --help, no validation, and no useful error message. Always go through argparse.
2. Forgetting type=int. args.port is a string by default, so args.port + 1 raises TypeError instead of giving 8001. Convert it at parse time.
3. --no- flags done wrong. The standard 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 was used. Without it, args.command doesn't exist, and an if args.command == ... dispatch can't work.
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. If a CLI has 50 flags and no examples, few people will use it correctly. Add an epilog with two or three real commands.
7. argparse.FileType("r") everywhere. It is deprecated since Python 3.14. It doesn't close files, is awkward when you need a specific encoding, and hides the open call. Manual open() (or Path.read_text()) is usually clearer.
🎯 Your Turn: A Real wordcount CLI
Build wordcount.py, a copy of the classic Unix wc tool, using argparse properly.
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, and 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, each with a short and a long form. - Stdin support via the
-convention, which makes the tool pipeline-friendly. - All three counts when no flag is given, the same as 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 $?
1The last part, exit code 1 visible in $?, 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, and optional args use
--nameand are optional by default. type=int/type=Pathcoerces strings at parse time. Custom callables work too. RaiseArgumentTypeErrorfor clear 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 deprecated since Python 3.14, so use a manualopen.if __name__ == "__main__":wraps your CLI so the module stays importable. See modules.click,typer,richwhen you outgrowargparse. For most CLIs,argparseis all you need.
Next: Environment Variables & Configuration. Read settings from the environment, not from hardcoded constants.
Found this useful? Share it with a friend who’s learning Python.