73.2 click: Command Groups, Options, Arguments, and Context
Right, so you’ve graduated from argparse. It’s a solid foundation, but you’re probably feeling the friction. Your script is starting to look like a Rube Goldberg machine of add_argument calls, and adding a second command feels like trying to build a second house on the same foundation. Welcome to click. It’s the library that looks at argparse and says, “What if we did that, but with decorators and actual, sensible organization?”
The core idea behind click is to use decorators to turn your plain old Python functions into command-line interfaces. It’s less “bolt-on configuration” and more “elegant fusion.” The moment you need more than one command in your tool, you’ll need to understand its secret weapon: the Group.
Command Groups: Your New Organizational Overlords
A click.Group is essentially a container for subcommands. Think of it like the git command: git itself doesn’t do anything except group the real actions (commit, push, pull). You create one by decorating a function with @click.group(). This function becomes the parent command, and its sole job is to list and run its children.
import click
@click.group()
def cli():
"""A magnificent CLI tool that does... things."""
pass # The group itself often doesn't need to *do* anything
@cli.command()
def init():
"""Initialize a new repository."""
click.echo("Initializing... nothing, actually. This is a demo.")
@cli.command()
@click.argument('filename')
def add(filename):
"""Add a file to the staging area."""
click.echo(f"Adding {filename} (to your imagination)")
if __name__ == '__main__':
cli()
Running this gives you automatic help and a list of subcommands:
$ python mytool.py
Usage: mytool.py [OPTIONS] COMMAND [ARGS]...
A magnificent CLI tool that does... things.
Options:
--help Show this message and exit.
Commands:
add Add a file to the staging area.
init Initialize a new repository.
The beauty is in the automatic structure. click handles the plumbing for you.
Arguments vs. Options: Know the Difference
This is where people get tripped up, so pay attention. click makes a firm, sensible distinction that argparse blurrs into a mess of add_argument:
- Arguments: Required positional parameters. Their order matters. Think
cp source.txt dest.txt. You define them with@click.argument('name'). - Options: Optional flags, always prefixed with
--(or sometimes-for short). Their order does not matter. Think--verbose,--output=file.txt. You define them with@click.option(...).
Mixing them is straightforward and logical:
@cli.command()
@click.argument('source') # Required, positional
@click.argument('dest') # Required, positional
@click.option('--verbose', '-v', is_flag=True, help='Blather on and on.') # Optional flag
@click.option('--force', '-f', is_flag=True, help='Overwrite without asking.') # Optional flag
def copy(source, dest, verbose, force):
"""Copy a file from SOURCE to DEST."""
if verbose:
click.echo(f"Pretending to copy {source} to {dest}")
# ... actual logic would go here
Notice the is_flag=True? That’s how you tell click this option doesn’t take a value; it’s either present (True) or not (False). This is infinitely cleaner than argparse’s action='store_true' nonsense.
The Mighty Context: Sharing Secrets Between Commands
Here’s the killer feature, the one that makes click scale elegantly: the Context. Every time a command is invoked, click creates a Context object that holds all the parsed parameters and, crucially, an object called obj. This is your own private scratchpad for passing data around.
Why is this a big deal? Let’s say you have multiple commands that all need a database connection or a configuration object. Without the context, you’d be parsing the same --config-file option in every single command and manually instantiating the connection. It’s a nightmare.
With the context, you do it once in the group and all child commands can access it.
@click.group()
@click.option('--verbose', is_flag=True)
@click.pass_context # This decorator gives us the context object
def cli(ctx, verbose):
"""Main CLI with shared config."""
# Ensure ctx.obj exists and is a dict if it isn't already
ctx.ensure_object(dict)
# Stick our shared state in the context object
ctx.obj['verbose'] = verbose
ctx.obj['config'] = load_config() # Imagine this function exists
@cli.command()
@click.pass_context # We ask for the context here too
def status(ctx):
"""Show current status."""
if ctx.obj['verbose']:
click.echo("Loading status... (dramatically)")
# We have direct access to the config loaded in the parent!
click.echo(f"Status: All systems nominal. Verbose mode was {'on' if ctx.obj['verbose'] else 'off'}.")
# This command doesn't care about the context, so it doesn't get it.
@cli.command()
@click.argument('name')
def hello(name):
"""Say hello."""
click.echo(f"Hello, {name}! I'm blissfully unaware of the context.")
if __name__ == '__main__':
cli(obj={}) # We can initialize the obj here
The ctx.ensure_object(dict) is a best practice. It guarantees ctx.obj exists, preventing AttributeError surprises. Use the context for shared resources (configs, API clients, connections). Don’t abuse it for everything; simple command-specific parameters should just be, well, parameters.
Best Practices and Pitfalls
- Don’t Forget
pass_context: If you want thectxobject in your command function, you must decorate it with@click.pass_context. Forgetting this is the most common “why is thisNone?!” error. - Name Your Options: Always provide explicit names for your options (
@click.option('--verbose')). Relying on the automatic variable name (@click.optionalone) is brittle and breaks the second you refactor your function’s parameter name. - Use
click.echooverprint:click.echohandles Unicode and output streams correctly, especially on Windows. It’s the adult way to write to the terminal. - Type Conversion is Your Friend:
clickhas a fantastic system for converting argument strings to Python objects. Usetype=click.Path()for filesystem paths (it handles tilde expansion and path checks) ortype=click.IntRange(1, 10)for a bounded integer. It does the validation for you before your function even runs.
click isn’t without its quirks—the decorator stack can look a bit unwieldy at first—but its design is overwhelmingly logical. It forces you to structure your CLI in a maintainable way, and for that, I forgive it almost everything.