Python Click: Command-Line Interface Framework Guide
Click is Python's most popular CLI framework — it uses decorators to compose commands, options, and arguments, generates help text automatically, handles type coercion and validation, and ships with progress bars, prompts, and coloured output. The Flask project maintains Click, and it underpins tools like Flask CLI, dbt, and AWS SAM. This guide builds a real-world CLI tool from a simple command to a multi-group application with testing.
Table of Contents
Commands, Options, and Arguments
pip install click
#!/usr/bin/env python
# cli.py
import click
@click.command()
@click.argument("name") # positional, required
@click.option("--count", "-c", default=1, help="How many times to greet")
@click.option("--shout/--no-shout", default=False, help="Uppercase output")
@click.option("--output", type=click.Path(), help="Write output to file")
def greet(name: str, count: int, shout: bool, output: str | None):
"""Greet NAME the specified number of times."""
message = f"Hello, {name}!"
if shout:
message = message.upper()
for _ in range(count):
if output:
with open(output, "a") as f:
f.write(message + "\n")
else:
click.echo(message)
if __name__ == "__main__":
greet()
python cli.py Alice # Hello, Alice!
python cli.py Alice --count 3 # Hello, Alice! (3 times)
python cli.py Alice --shout # HELLO, ALICE!
python cli.py --help # auto-generated help text
@click.argument) are positional and required by default — good for the primary "subject" of the command. Options (@click.option) are named with --flag syntax — good for modifiers and optional parameters. Follow Unix convention: use arguments for file paths, use options for everything else.
Types and Validation
Click's type system coerces string inputs from the command line into Python types with meaningful error messages. Use built-in types for files, paths, integers, floats, UUIDs, and dates, or define custom types by subclassing click.ParamType.
import click
import re
class EmailType(click.ParamType):
"""Custom type that validates email addresses."""
name = "EMAIL"
def convert(self, value, param, ctx):
if not re.match(r"[^@]+@[^@]+\.[^@]+", value):
self.fail(f"{value!r} is not a valid email address", param, ctx)
return value.lower()
EMAIL = EmailType()
@click.command()
@click.argument("src", type=click.File("r")) # open file for reading
@click.argument("dst", type=click.File("w")) # open file for writing
@click.option("--limit", type=click.IntRange(1, 10000), default=100)
@click.option("--format", type=click.Choice(["json", "csv", "tsv"]), default="json")
@click.option("--email", type=EMAIL, required=True)
@click.option("--date", type=click.DateTime(formats=["%Y-%m-%d"]))
@click.option("--config", type=click.Path(exists=True, file_okay=True, dir_okay=False))
@click.option("--verbose", count=True) # -v, -vv, -vvv
def process(src, dst, limit, format, email, date, config, verbose):
"""Process SRC and write to DST."""
if verbose > 0:
click.echo(f"Verbosity: {verbose}", err=True)
click.echo(f"Processing {format} for {email}", err=True)
data = src.read()[:limit]
dst.write(data)
Command Groups (Sub-commands)
Use @click.group() to build a multi-command CLI like git, docker, or aws. Groups can be nested for deeper hierarchies, and each sub-command is an independent function decorated with @group.command().
import click
import json
@click.group()
@click.option("--config", envvar="APP_CONFIG", default="config.json",
type=click.Path(), show_default=True)
@click.option("--debug/--no-debug", envvar="APP_DEBUG", default=False)
@click.pass_context
def cli(ctx: click.Context, config: str, debug: bool):
"""Techoral deployment tool."""
ctx.ensure_object(dict)
ctx.obj["debug"] = debug
ctx.obj["config_path"] = config
if debug:
click.echo(f"Debug mode: ON (config: {config})", err=True)
@cli.group()
def db():
"""Database management commands."""
@db.command("migrate")
@click.option("--target", default="head", help="Migration target revision")
@click.pass_context
def db_migrate(ctx, target):
"""Run database migrations."""
click.echo(f"Running migrations to {target}...")
@db.command("seed")
@click.option("--fixtures", type=click.Path(exists=True), multiple=True)
@click.pass_context
def db_seed(ctx, fixtures):
"""Load seed data."""
for fixture in fixtures:
click.echo(f"Loading {fixture}")
@cli.command()
@click.argument("service", type=click.Choice(["api", "worker", "scheduler"]))
@click.option("--replicas", type=int, default=1)
@click.pass_context
def deploy(ctx, service, replicas):
"""Deploy a service."""
debug = ctx.obj["debug"]
click.echo(f"Deploying {service} x{replicas}" + (" [DEBUG]" if debug else ""))
@cli.command()
def status():
"""Show deployment status."""
click.echo("All services running")
if __name__ == "__main__":
cli()
python cli.py --debug db migrate --target v2.3
python cli.py deploy api --replicas 3
python cli.py db seed --fixtures data/users.json --fixtures data/products.json
Context and Shared State
Pass state between parent groups and sub-commands using @click.pass_context and ctx.obj. The context also provides utilities like ctx.invoke() to call other commands programmatically and ctx.ensure_object() for lazy initialisation.
import click
from dataclasses import dataclass, field
@dataclass
class AppConfig:
verbose: bool = False
api_url: str = "https://api.techoral.com"
auth_token: str = ""
@click.group()
@click.option("--verbose", is_flag=True)
@click.option("--api-url", default="https://api.techoral.com", envvar="API_URL")
@click.option("--token", envvar="API_TOKEN", required=True, hide_input=True)
@click.pass_context
def cli(ctx, verbose, api_url, token):
ctx.obj = AppConfig(verbose=verbose, api_url=api_url, auth_token=token)
@cli.command()
@click.argument("user_id", type=int)
@click.pass_obj
def get_user(config: AppConfig, user_id: int):
"""Fetch user by ID."""
if config.verbose:
click.echo(f"GET {config.api_url}/users/{user_id}")
import httpx
r = httpx.get(f"{config.api_url}/users/{user_id}",
headers={"Authorization": f"Bearer {config.auth_token}"})
r.raise_for_status()
click.echo(json.dumps(r.json(), indent=2))
Output: Echo, Style, and Progress
Click provides coloured output via click.style(), structured tables via click.echo(), and progress bars via click.progressbar(). All automatically degrade gracefully when output is piped to a file or a non-TTY terminal.
import click
import time
@click.command()
@click.argument("items", nargs=-1)
def process(items):
"""Process items with a progress bar."""
# Progress bar with iterable
with click.progressbar(items, label="Processing", width=60) as bar:
for item in bar:
time.sleep(0.05) # simulate work
# Coloured output
click.echo(click.style("✓ Success", fg="green", bold=True))
click.echo(click.style("⚠ Warning", fg="yellow"))
click.echo(click.style("✗ Error", fg="red"), err=True) # write to stderr
# Pager for long output
long_output = "\n".join(f"Line {i}" for i in range(200))
click.echo_via_pager(long_output)
@click.command()
def show_table():
"""Display a formatted table."""
headers = ["Name", "Status", "Replicas"]
rows = [
("api", "running", "3"),
("worker", "running", "2"),
("scheduler", "stopped", "0"),
]
# Simple table with fixed widths
fmt = "{:<20} {:<12} {:<10}"
click.echo(fmt.format(*headers))
click.echo("-" * 42)
for row in rows:
status_color = "green" if row[1] == "running" else "red"
click.echo(
f"{row[0]:<20} " +
click.style(f"{row[1]:<12}", fg=status_color) +
f" {row[2]:<10}"
)
Prompts and Confirmation
Click can interactively prompt for required values and confirm dangerous operations. Prompts can be hidden (for passwords), validated, and given defaults. The --yes / -y flag pattern lets scripts bypass confirmation for automation.
import click
@click.command()
@click.option("--username", prompt="Username", help="Your username")
@click.option("--password", prompt=True, hide_input=True, confirmation_prompt=True)
def create_user(username, password):
click.echo(f"Creating user {username}")
@click.command()
@click.argument("environment", type=click.Choice(["staging", "production"]))
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
def destroy(environment, yes):
"""Destroy all resources in ENVIRONMENT."""
if not yes:
click.confirm(
f"Are you sure you want to destroy {click.style(environment, fg='red', bold=True)}?",
abort=True, # raises Abort (exits 1) if user says no
)
click.echo(f"Destroying {environment}...")
# Prompt with validation
@click.command()
def setup():
port = click.prompt(
"Port number",
default=8080,
type=click.IntRange(1024, 65535),
show_default=True,
)
click.echo(f"Using port {port}")
Testing Click Commands
Click's CliRunner invokes commands in isolation without spawning subprocesses, capturing output and exit codes for assertions. It supports mix-ins for stdin, environment variables, and filesystem isolation.
from click.testing import CliRunner
import pytest
from cli import cli, greet
def test_greet_basic():
runner = CliRunner()
result = runner.invoke(greet, ["Alice"])
assert result.exit_code == 0
assert "Hello, Alice!" in result.output
def test_greet_shout():
runner = CliRunner()
result = runner.invoke(greet, ["Alice", "--shout"])
assert "HELLO, ALICE!" in result.output
def test_greet_missing_name():
runner = CliRunner()
result = runner.invoke(greet, [])
assert result.exit_code != 0
assert "Missing argument" in result.output
def test_deploy_with_env():
runner = CliRunner()
result = runner.invoke(cli, ["deploy", "api"], env={"API_TOKEN": "test-token"})
assert result.exit_code == 0
def test_file_processing():
runner = CliRunner()
with runner.isolated_filesystem():
with open("input.txt", "w") as f:
f.write("test data")
result = runner.invoke(process, ["input.txt", "output.txt"])
assert result.exit_code == 0
with open("output.txt") as f:
assert f.read() == "test data"
Frequently Asked Questions
- Click vs Typer vs argparse — which to use?
- Use Click for feature-rich CLIs and projects that already use Click (Flask apps). Use Typer for FastAPI-style type-hint-driven CLIs with less boilerplate — it is built on Click. Use argparse only when you need zero dependencies. For new projects, Typer is often the easiest starting point.
- How do I read environment variables as option defaults?
- Add
envvar="MY_ENV_VAR"to@click.option(). Click reads the environment variable when the option is not provided on the command line. Useauto_envvar_prefix="MYAPP"on@click.group()to automatically map all options toMYAPP_OPTION_NAMEenvironment variables. - How do I package a Click CLI as an installable command?
- Add an entry point in
pyproject.toml:[project.scripts] mytool = "mypackage.cli:cli". Afterpip install ., users runmytool deploy apidirectly without invoking Python.