cheat sheet

click

Build command-line interfaces with click using decorators. Covers commands, options, arguments, groups, prompts, and progress bars.

click — CLI Builder

What it is

Click (Command Line Interface Creation Kit) is a Python package by the Pallets project (the team behind Flask) for building CLI applications using function decorators. It handles --options, positional arguments, subcommand groups, prompts, env-var bindings, auto-generated help text, and error messages without any manual argument-parsing boilerplate. Reach for Click over argparse when you want composable, testable commands with minimal code; consider Typer if you prefer type-annotation-based declaration.

Install

bash
pip install click

Output: (none — exits 0 on success)

Quick example

python
# greet.py
import click

@click.command()
@click.option("--name", default="World", help="Name to greet")
@click.option("--count", default=1, type=int, help="How many times")
def hello(name, count):
    for _ in range(count):
        click.echo(f"Hello, {name}!")

if __name__ == "__main__":
    hello()
bash
python greet.py --name Alice --count 3
python greet.py --help

Output:

text
Hello, Alice!
Hello, Alice!
Hello, Alice!

Usage: greet.py [OPTIONS]

Options:
  --name TEXT     Name to greet
  --count INTEGER  How many times
  --help          Show this message and exit.

When / why to use it over argparse

Featureclickargparse
Decorator syntax❌ (imperative)
Subcommand groups✅ simple⚠️ verbose
Prompts / confirmation✅ built-in❌ manual
Env var reading✅ built-in❌ manual
Testing utilitiesCliRunner
Standard library❌ requires install

Use argparse only when zero-dependency is a hard requirement. Otherwise, click is almost always the better choice.

Common pitfalls

click.echo vs print — use click.echo() instead of print() in click commands. It handles text encoding across platforms correctly and works with the CliRunner test utility.

Python's if __name__ == "__main__": main() is required — click commands are not standalone callables. Decorate with @click.command() and then call from the guard block, or use an entry point in pyproject.toml.

Use @click.option("--verbose", "-v", is_flag=True) for boolean flags. Flags set to True when present and False when absent.

Richer example — command group

python
# app.py
import click
import json
from pathlib import Path

@click.group()
def cli():
    """Data management tool."""
    pass

@cli.command()
@click.argument("filename", type=click.Path(exists=True))
@click.option("--field", "-f", required=True, help="JSON field to extract")
def extract(filename, field):
    """Extract a field from a JSON file."""
    data = json.loads(Path(filename).read_text())
    value = data.get(field, "<not found>")
    click.echo(value)

@cli.command()
@click.argument("name")
@click.option("--upper/--no-upper", default=False, help="Uppercase the name")
def greet(name, upper):
    """Greet someone."""
    msg = f"Hello, {name}!"
    click.echo(msg.upper() if upper else msg)

if __name__ == "__main__":
    cli()
bash
python app.py greet Alice --upper
python app.py --help

Output:

text
HELLO, ALICE!

Usage: app.py [COMMAND] [ARGS]...

  Data management tool.

Commands:
  extract  Extract a field from a JSON file.
  greet    Greet someone.

Common option types

TypeDeclarationNotes
Stringtype=str (default)
Integertype=int
Floattype=float
Boolean flagis_flag=True--verbose sets True
Choicetype=click.Choice(["a","b"])Validates and shows in help
Path (exists)type=click.Path(exists=True)Validates file/dir exists
Path (writable)type=click.Path(writable=True)
Filetype=click.File("r")Opens a file, handles - for stdin

Testing with CliRunner

python
from click.testing import CliRunner
from app import greet

def test_greet():
    runner = CliRunner()
    result = runner.invoke(greet, ["Alice", "--upper"])
    assert result.exit_code == 0
    assert "HELLO, ALICE!" in result.output

Arguments vs Options

A Click argument is a positional value identified by order (no -- flag); a Click option is a named flag (--flag value). Arguments are declared with @click.argument(name, ...); options with @click.option("--flag", ...). Arguments are required unless nargs=-1 or required=False; options default to optional unless required=True is set.

python
import click

@click.command()
@click.argument("source")                              # positional, required
@click.argument("dest", required=False, default="-")   # positional, optional
@click.option("--mode", "-m", default="copy",
              type=click.Choice(["copy", "move", "link"]))
@click.option("--force/--no-force", default=False)     # bool flag pair
def transfer(source, dest, mode, force):
    """Move data from SOURCE to DEST."""
    click.echo(f"{mode}: {source} -> {dest} (force={force})")

if __name__ == "__main__":
    transfer()
bash
python transfer.py /tmp/a.txt /tmp/b.txt --mode move --force

Output:

text
move: /tmp/a.txt -> /tmp/b.txt (force=True)

nargs — variable-length arguments

nargs controls how many values an argument consumes. nargs=N captures exactly N values into a tuple; nargs=-1 captures all remaining positional values (it must be the last argument and cannot be combined with required=True in the usual sense — pass at least one value or set nargs=-1, required=False).

python
import click

@click.command()
@click.argument("files", nargs=-1, type=click.Path(exists=True))
@click.argument("dest", nargs=1, type=click.Path())
@click.option("--coord", nargs=2, type=float, default=(0.0, 0.0))
def copyall(files, dest, coord):
    """Copy FILES... into DEST."""
    click.echo(f"Coord: {coord}")
    for f in files:
        click.echo(f"  {f} -> {dest}")

if __name__ == "__main__":
    copyall()
bash
python copyall.py a.txt b.txt /tmp/backup --coord 1.5 2.0

Output:

text
Coord: (1.5, 2.0)
  a.txt -> /tmp/backup
  b.txt -> /tmp/backup

When you want a Click option that accepts multiple values, prefer multiple=True for repeatable flags (-I src -I tests) and nargs=N for fixed-size tuples (--coord 1.5 2.0). Mixing them is rarely useful.

multiple=True — repeatable options

multiple=True lets an option appear more than once on the command line; Click collects each occurrence into a tuple. This is the Click equivalent of argparse's action="append" and the standard way to build -I/--include-style flags.

python
import click

@click.command()
@click.option("-I", "--include", multiple=True, type=click.Path(),
              help="Add a directory to the search path (repeatable)")
@click.option("-D", "--define", multiple=True,
              help="KEY=VALUE pairs (repeatable)")
def build(include, define):
    click.echo(f"Includes: {list(include)}")
    click.echo(f"Defines:  {list(define)}")

if __name__ == "__main__":
    build()
bash
python build.py -I src -I tests -D DEBUG=1 -D VERSION=2

Output:

text
Includes: ['src', 'tests']
Defines:  ['DEBUG=1', 'VERSION=2']

Type system — click.Choice, click.Path, click.IntRange, click.File

Click ships a small library of value converters in click.types that handle validation, coercion, and --help rendering for common CLI inputs. Pass them via type=. They produce useful error messages when the user supplies the wrong shape of value.

python
import click

@click.command()
@click.option("--env", type=click.Choice(["dev", "staging", "prod"],
                                          case_sensitive=False))
@click.option("--config", type=click.Path(exists=True, dir_okay=False,
                                           readable=True, resolve_path=True))
@click.option("--workers", type=click.IntRange(1, 64), default=4,
              show_default=True)
@click.option("--threshold", type=click.FloatRange(0.0, 1.0), default=0.5)
@click.option("--logfile", type=click.File("a"), default="-")  # '-' = stdout
@click.option("--retries", type=click.IntRange(min=0, clamp=True), default=3)
def serve(env, config, workers, threshold, logfile, retries):
    logfile.write(f"env={env} workers={workers} t={threshold} cfg={config}\n")

if __name__ == "__main__":
    serve()
bash
python serve.py --env PROD --config app.toml --workers 8 --threshold 0.9

Output:

text
env=prod workers=8 t=0.9 cfg=/abs/path/app.toml
TypeNotes
click.STRING (default)Pass-through string
click.INT, click.FLOAT, click.BOOLStandard converters
click.IntRange(min, max, clamp=False)Integer in range; clamp=True silently coerces
click.FloatRange(min, max)Float in range
click.Choice([...], case_sensitive=True)Restricted vocabulary; shown in --help
click.Path(exists, file_okay, dir_okay, writable, readable, resolve_path, path_type)Filesystem path with validation; path_type=pathlib.Path returns a Path
click.File("r"/"w"/"a"/"rb"/"wb")Lazy file open; - maps to stdin/stdout
click.DateTime(formats=["%Y-%m-%d"])Parsed datetime
click.UUIDParsed uuid.UUID
click.Tuple([int, str, float])Fixed-shape tuple of mixed types

click.Path(path_type=pathlib.Path) returns a pathlib.Path instead of a string — this is almost always what you want. It pairs well with pathlib idioms in the rest of your code.

Boolean flags and flag pairs

A boolean option in Click is declared with is_flag=True (sets True when present) or as a --foo/--no-foo pair (sets explicit True/False). The flag-pair form is preferable when you want users to override a non-default value back to the default.

python
import click

@click.command()
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
@click.option("--cache/--no-cache", default=True, help="Use cache")
@click.option("--color", "color_mode", flag_value="always", default=True)
@click.option("--no-color", "color_mode", flag_value="never")
def run(verbose, cache, color_mode):
    click.echo(f"verbose={verbose} cache={cache} color={color_mode}")

if __name__ == "__main__":
    run()
bash
python run.py -v --no-cache --no-color

Output:

text
verbose=True cache=False color=never

The third pattern (flag_value) creates feature switches: two flags writing different string values into the same destination variable.

count — repeatable verbosity flags

count=True accumulates each occurrence of the flag into an integer. This is the standard idiom for -v, -vv, -vvv log-level escalation.

python
import click
import logging

@click.command()
@click.option("-v", "--verbose", count=True,
              help="Increase verbosity (-v INFO, -vv DEBUG)")
def app(verbose):
    level = logging.WARNING - 10 * verbose
    logging.basicConfig(level=max(level, logging.DEBUG))
    logging.warning("warn message")
    logging.info("info message")
    logging.debug("debug message")

if __name__ == "__main__":
    app()
bash
python app.py -vv

Output:

text
WARNING:root:warn message
INFO:root:info message
DEBUG:root:debug message

Environment-variable fallbacks

Pass envvar="NAME" to any option to read the value from an environment variable when the flag is omitted. Click sets up a default_map lookup chain: explicit flag > env var > default=. This is how production CLIs blend interactive and scripted use.

python
import click

@click.command()
@click.option("--token", envvar="API_TOKEN", required=True,
              help="API token (env: API_TOKEN)")
@click.option("--host", envvar=["APP_HOST", "HOST"], default="localhost",
              show_default=True, show_envvar=True)
@click.option("--debug/--no-debug", envvar="APP_DEBUG", default=False)
def deploy(token, host, debug):
    click.echo(f"deploy to {host} debug={debug}")

if __name__ == "__main__":
    deploy(auto_envvar_prefix="MYAPP")  # also picks up MYAPP_TOKEN, MYAPP_HOST
bash
API_TOKEN=secret python deploy.py --host myhost

Output:

text
deploy to myhost debug=False

Boolean env-var values are parsed loosely — 1, true, yes, on map to True; 0, false, no, off map to False. Anything else raises.

Prompts — click.prompt and click.confirm

Interactive prompts let a CLI ask for missing input rather than failing with an error. click.prompt(text, default=..., hide_input=...) reads a value with optional type coercion; click.confirm(text) asks a yes/no question. You can also wire prompt=True directly into @click.option so Click only prompts when the flag is missing.

python
import click

@click.command()
@click.option("--username", prompt=True)
@click.option("--password", prompt=True, hide_input=True,
              confirmation_prompt=True)
@click.option("--port", prompt="Port", type=int, default=8080)
def configure(username, password, port):
    if not click.confirm(f"Save config for {username}@:{port}?", abort=True):
        return
    click.echo(f"Saved: {username}:{'*' * len(password)} :{port}")

if __name__ == "__main__":
    configure()

Output:

text
Username: alicedev
Password:
Repeat for confirmation:
Port [8080]: 9000
Save config for alicedev@:9000? [y/N]: y
Saved: alicedev:************ :9000

abort=True raises click.Abort (exit code 1) when the user answers "no", which is the standard way to short-circuit destructive commands.

click.echo and click.secho

click.echo() is a cross-platform replacement for print() — it strips ANSI escapes when output is redirected to a file, handles encoding on Windows, and integrates with CliRunner.invoke(). click.secho() is a shortcut that adds styling via the same vocabulary as click.style().

python
import click

click.echo("plain message")
click.echo(click.style("colored", fg="green", bold=True))
click.secho("danger", fg="red", bg="yellow", bold=True)
click.secho("warning", fg="yellow", err=True)  # to stderr
click.echo("no newline", nl=False)
click.echo("...continued")

Output:

text
plain message
colored
danger
warning
no newline...continued

Available styles: fg=/bg= (red, green, blue, cyan, magenta, yellow, white, black, or bright_*), bold, dim, underline, italic, blink, reverse. Combine freely.

Command groups and subcommands

A @click.group() is a command that dispatches to subcommands. Subcommands are registered with @group.command() on the group object. Groups can be nested arbitrarily — the resulting CLI looks like git remote add or docker compose up.

python
import click

@click.group()
@click.option("--config", type=click.Path(), default="config.toml")
@click.pass_context
def cli(ctx, config):
    """My app — manage projects."""
    ctx.ensure_object(dict)
    ctx.obj["config"] = config

@cli.group()
def db():
    """Database operations."""

@db.command("migrate")
@click.option("--target", default="head")
@click.pass_context
def db_migrate(ctx, target):
    click.echo(f"[{ctx.obj['config']}] migrate to {target}")

@db.command("rollback")
@click.argument("steps", type=int, default=1)
@click.pass_context
def db_rollback(ctx, steps):
    click.echo(f"[{ctx.obj['config']}] rollback {steps} step(s)")

@cli.command()
@click.argument("target")
@click.pass_context
def deploy(ctx, target):
    click.echo(f"[{ctx.obj['config']}] deploy to {target}")

if __name__ == "__main__":
    cli()
bash
python app.py db migrate --target v3
python app.py --config prod.toml deploy production

Output:

text
[config.toml] migrate to v3
[prod.toml] deploy to production

Context — @click.pass_context and ctx.obj

A Context (ctx) is the per-invocation state that Click threads through every command. Decorate a function with @click.pass_context to receive it as the first argument; use ctx.obj (a dict by default — call ctx.ensure_object(dict) first) to pass shared state from a group down into subcommands. Use ctx.exit(code) to terminate, ctx.abort() to raise click.Abort, and ctx.invoke(other_command) to call another command programmatically.

python
import click

@click.group()
@click.option("--verbose", "-v", count=True)
@click.pass_context
def cli(ctx, verbose):
    ctx.ensure_object(dict)
    ctx.obj["verbose"] = verbose

@cli.command()
@click.pass_context
def status(ctx):
    if ctx.obj["verbose"] >= 1:
        click.echo("verbose mode on")
    click.echo("OK")

@cli.command()
@click.pass_context
def fail(ctx):
    ctx.fail("simulated error")     # prints message, exits 1

if __name__ == "__main__":
    cli(obj={})
bash
python app.py -v status

Output:

text
verbose mode on
OK

@click.pass_obj is a shortcut that passes ctx.obj directly (skipping the context wrapper) — useful when you only need the shared state.

Callbacks — eager flags and validation

A callback runs when an option/argument is parsed, before the command body executes. Combined with is_eager=True, the callback runs before any other option (used for --version, --help, --config). Use ctx.exit() inside the callback to short-circuit.

python
import click

def version_callback(ctx, param, value):
    if not value or ctx.resilient_parsing:
        return
    click.echo("myapp 1.4.0")
    ctx.exit()

def validate_email(ctx, param, value):
    if value is None:
        return value
    if "@" not in value:
        raise click.BadParameter(f"{value!r} is not an email")
    return value

@click.command()
@click.option("--version", is_flag=True, callback=version_callback,
              is_eager=True, expose_value=False)
@click.option("--email", callback=validate_email)
def run(email):
    click.echo(f"email={email}")

if __name__ == "__main__":
    run()
bash
python run.py --version
python run.py --email alice

Output:

text
myapp 1.4.0
Usage: run.py [OPTIONS]
Try 'run.py --help' for help.

Error: Invalid value for '--email': 'alice' is not an email

expose_value=False hides the option from the function signature (the callback handles everything).

Progress bars — click.progressbar

A click.progressbar is a lightweight progress display that wraps any iterable; iteration advances the bar automatically. For richer bars (multi-task, ETA, spinners), pair Click with rich.progress — the two libraries coexist cleanly.

python
import click
import time

items = list(range(50))

with click.progressbar(items, label="Processing") as bar:
    for item in bar:
        time.sleep(0.02)

Output:

text
Processing  [####################################]  100%

For non-iterating progress (e.g., known step count, manual advance):

python
with click.progressbar(length=100, label="Uploading") as bar:
    for chunk in upload_in_chunks(...):
        bar.update(len(chunk))

Lazy file handling

type=click.File("r") opens the file when the argument is parsed and closes it at the end of the command. Pass - for stdin/stdout (the standard Unix convention). For long-running work, prefer type=click.Path(path_type=Path) and a with block.

python
import click

@click.command()
@click.argument("infile",  type=click.File("r"), default="-")
@click.option("--out",    type=click.File("w"), default="-")
@click.option("--mode",   type=click.Choice(["upper", "lower"]),
              default="upper")
def transform(infile, out, mode):
    fn = str.upper if mode == "upper" else str.lower
    for line in infile:
        out.write(fn(line))

if __name__ == "__main__":
    transform()
bash
echo "hello, alice" | python transform.py --mode upper

Output:

text
HELLO, ALICE

Distribution via entry_points

To ship a Click CLI as a real mytool command (not python mytool.py), declare an entry point in pyproject.toml. After pip install . the function is exposed on PATH.

toml
# pyproject.toml
[project]
name = "mytool"
version = "0.1.0"
dependencies = ["click"]

[project.scripts]
mytool = "mytool.cli:cli"
python
# mytool/cli.py
import click

@click.group()
def cli():
    """My tool."""

@cli.command()
def hello():
    click.echo("hello")
bash
pip install .
mytool hello

Output:

text
hello

This is also the standard way to integrate with pipx for global tool installs.

Testing with CliRunner — full reference

CliRunner.invoke(cli, args, input=...) runs a Click command without spawning a subprocess. The returned Result has exit_code, output, stdout/stderr (with mix_stderr=False), and exception for asserting on failures.

python
from click.testing import CliRunner
from mytool.cli import cli

def test_success():
    runner = CliRunner()
    result = runner.invoke(cli, ["hello"])
    assert result.exit_code == 0
    assert result.output == "hello\n"

def test_missing_required():
    runner = CliRunner()
    result = runner.invoke(cli, ["deploy"])
    assert result.exit_code != 0
    assert "Missing argument" in result.output

def test_with_stdin():
    runner = CliRunner()
    result = runner.invoke(cli, ["transform", "--mode", "upper", "-"],
                           input="hello\n")
    assert result.exit_code == 0
    assert result.output == "HELLO\n"

def test_with_env():
    runner = CliRunner()
    result = runner.invoke(cli, ["deploy", "prod"],
                           env={"API_TOKEN": "secret"})
    assert result.exit_code == 0

def test_isolated_filesystem():
    runner = CliRunner()
    with runner.isolated_filesystem():
        from pathlib import Path
        Path("config.toml").write_text("[app]\nhost='myhost'\n")
        result = runner.invoke(cli, ["status"])
        assert result.exit_code == 0

isolated_filesystem() creates a temp directory and chdirs into it for the duration of the with block — invaluable for testing commands that touch the filesystem.

Comparison with argparse and typer

click, typer, and argparse all parse command-line arguments, but the ergonomics differ sharply.

Featureargparseclicktyper
StyleImperative add_argumentDecorator-basedType-annotation-based
Standard library❌ requires install❌ requires install
Subcommand groups✅ verbose✅ ergonomic✅ ergonomic
Type validationbasic type= callablerich type systemfull Python type hints
Prompts / confirm❌ manual✅ built-in✅ built-in (via click)
Env-var fallbackmanual os.environ.getenvvar=envvar=
Tab completionvia argcomplete✅ built-in✅ built-in (via click)
Testingmanual parse_argsCliRunnerCliRunner (re-exported)
Rich output integrationworks manuallyworks with richfirst-class via typer[all]
Boilerplate to start~5 lines~5 lines (decorators)~3 lines

Pick argparse for zero-dependency scripts; pick click for full control over imperative-feeling decorators, mature ecosystem (Flask community), and rich type system; pick typer when you already use type hints everywhere and want the FastAPI ergonomics. Typer is implemented on top of Click, so any Click skill transfers directly.

Real-world recipes

A multi-command CLI with shared config

A skeleton that combines a group with shared --config/--verbose flags, env-var fallback, and per-subcommand context, suitable for a real internal tool.

python
import click
import logging
from pathlib import Path

@click.group()
@click.option("--config", type=click.Path(path_type=Path, exists=True),
              envvar="MYAPP_CONFIG", default="config.toml", show_envvar=True)
@click.option("-v", "--verbose", count=True)
@click.pass_context
def cli(ctx, config, verbose):
    """MyApp — manage Alice's notes."""
    ctx.ensure_object(dict)
    level = logging.WARNING - 10 * verbose
    logging.basicConfig(level=max(level, logging.DEBUG))
    ctx.obj["config"] = config

@cli.command()
@click.argument("text")
@click.option("-t", "--tag", multiple=True)
@click.pass_obj
def add(obj, text, tag):
    """Add a note."""
    click.echo(f"[{obj['config']}] add: {text} tags={list(tag)}")

@cli.command(name="list")
@click.option("--limit", type=click.IntRange(1, 100), default=10)
@click.pass_obj
def list_notes(obj, limit):
    """List notes."""
    click.echo(f"[{obj['config']}] listing {limit} notes")

@cli.command()
@click.argument("note_id", type=int)
@click.confirmation_option(prompt="Really delete?")
@click.pass_obj
def rm(obj, note_id):
    """Delete a note."""
    click.echo(f"[{obj['config']}] deleted #{note_id}")

if __name__ == "__main__":
    cli()
bash
python notes.py -v add "buy milk" -t shopping -t urgent
python notes.py list --limit 5
python notes.py rm 42 --yes

Output:

text
[config.toml] add: buy milk tags=['shopping', 'urgent']
[config.toml] listing 5 notes
[config.toml] deleted #42

A stdin/stdout filter with --

A classic Unix-style filter: read from - (stdin) or a file, transform line-by-line, write to - (stdout) or a file. Click's click.File handles the - convention automatically.

python
import click
import re

@click.command()
@click.argument("infile",  type=click.File("r"), default="-")
@click.option("-o", "--out", type=click.File("w"), default="-")
@click.option("-p", "--pattern", required=True,
              help="Regex pattern; matches printed to OUT")
@click.option("-i", "--ignore-case", is_flag=True)
def grep(infile, out, pattern, ignore_case):
    """Tiny grep implementation."""
    flags = re.IGNORECASE if ignore_case else 0
    regex = re.compile(pattern, flags)
    for line in infile:
        if regex.search(line):
            out.write(line)

if __name__ == "__main__":
    grep()
bash
cat /etc/hosts | python mygrep.py -p "^127" -i

Output:

text
127.0.0.1   localhost

Combining Click with Rich

Click handles parsing; rich handles output. The two libraries integrate cleanly — use Rich's Console and Table from inside any Click command.

python
import click
from rich.console import Console
from rich.table import Table

console = Console()

@click.command()
@click.option("--limit", type=int, default=5)
def users(limit):
    """List users in a Rich table."""
    table = Table(title="Users")
    table.add_column("ID", style="cyan", justify="right")
    table.add_column("Name", style="magenta")
    table.add_column("Role", style="green")
    for i in range(1, limit + 1):
        table.add_row(str(i), f"user{i}", "admin" if i == 1 else "member")
    console.print(table)

if __name__ == "__main__":
    users()

Quick reference

TaskCode
Single command@click.command()
Group@click.group() + @cli.command()
Required argument@click.argument("name")
Optional argument@click.argument("name", required=False, default=...)
Variadic argument@click.argument("files", nargs=-1)
Flag@click.option("-v", is_flag=True)
Bool pair@click.option("--foo/--no-foo")
Choicetype=click.Choice([...])
Pathtype=click.Path(exists=True, path_type=Path)
Rangetype=click.IntRange(1, 100)
Repeatmultiple=True
Countercount=True
Env varenvvar="NAME"
Promptprompt=True or click.prompt(...)
Confirmclick.confirm(...) or @click.confirmation_option()
Context@click.pass_contextctx.obj
Echoclick.echo() / click.secho()
Styleclick.style("x", fg="red", bold=True)
Progresswith click.progressbar(seq) as bar:
Abortctx.abort() or raise click.Abort()
Exitctx.exit(code) or raise click.exceptions.Exit(code)
Errorraise click.BadParameter("…") / ctx.fail("…")
TestCliRunner().invoke(cli, [...])
Distributepyproject.toml [project.scripts]