PythonMastery
intermediate 20 min read · lesson 12 of 14 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. 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

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, 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 as args.input.
  • Optional arguments start with - or --: parser.add_argument("--verbose") or parser.add_argument("-v", "--verbose"). They're optional by default.

The usual pattern is to give both a short and a long form:

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

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. If the conversion fails, argparse stops 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 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

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)               # 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:

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

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, which are common in build tools, the standard form 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 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:

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 note:

  • dest="command" is important. Without it, args.command doesn't exist, and the only way to tell which subcommand the user ran is the set_defaults dispatch in Section 8.
  • required=True on add_subparsers makes the user choose a subcommand. Otherwise the user can run todo with 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=...):

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)                                      # 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:

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: general error
  • 2: invalid usage (this is what argparse itself 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:

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

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

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

python
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:

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 and like 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 (it is built on click, and uses your type hints to define the arguments):

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

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, and 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, 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:

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

The last part, exit code 1 visible in $?, 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, and 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 clear 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 deprecated since Python 3.14, so use a manual open.
  • if __name__ == "__main__": wraps your CLI so the module stays importable. See modules.
  • click, typer, rich when you outgrow argparse. For most CLIs, argparse is 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.