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
pip install click
Output: (none — exits 0 on success)
Quick example
# 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()
python greet.py --name Alice --count 3
python greet.py --help
Output:
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
| Feature | click | argparse |
|---|---|---|
| Decorator syntax | ✅ | ❌ (imperative) |
| Subcommand groups | ✅ simple | ⚠️ verbose |
| Prompts / confirmation | ✅ built-in | ❌ manual |
| Env var reading | ✅ built-in | ❌ manual |
| Testing utilities | ✅ CliRunner | ❌ |
| 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.echovsclick.echo()instead ofprint()in click commands. It handles text encoding across platforms correctly and works with theCliRunnertest 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 inpyproject.toml.
Use
@click.option("--verbose", "-v", is_flag=True)for boolean flags. Flags set toTruewhen present andFalsewhen absent.
Richer example — command group
# 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()
python app.py greet Alice --upper
python app.py --help
Output:
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
| Type | Declaration | Notes |
|---|---|---|
| String | type=str (default) | |
| Integer | type=int | |
| Float | type=float | |
| Boolean flag | is_flag=True | --verbose sets True |
| Choice | type=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) | |
| File | type=click.File("r") | Opens a file, handles - for stdin |
Testing with CliRunner
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.
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()
python transfer.py /tmp/a.txt /tmp/b.txt --mode move --force
Output:
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).
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()
python copyall.py a.txt b.txt /tmp/backup --coord 1.5 2.0
Output:
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=Truefor repeatable flags (-I src -I tests) andnargs=Nfor 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.
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()
python build.py -I src -I tests -D DEBUG=1 -D VERSION=2
Output:
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.
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()
python serve.py --env PROD --config app.toml --workers 8 --threshold 0.9
Output:
env=prod workers=8 t=0.9 cfg=/abs/path/app.toml
| Type | Notes |
|---|---|
click.STRING (default) | Pass-through string |
click.INT, click.FLOAT, click.BOOL | Standard 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.UUID | Parsed uuid.UUID |
click.Tuple([int, str, float]) | Fixed-shape tuple of mixed types |
click.Path(path_type=pathlib.Path)returns apathlib.Pathinstead 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.
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()
python run.py -v --no-cache --no-color
Output:
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.
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()
python app.py -vv
Output:
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.
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
API_TOKEN=secret python deploy.py --host myhost
Output:
deploy to myhost debug=False
Boolean env-var values are parsed loosely —
1,true,yes,onmap toTrue;0,false,no,offmap toFalse. 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.
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:
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().
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:
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.
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()
python app.py db migrate --target v3
python app.py --config prod.toml deploy production
Output:
[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.
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={})
python app.py -v status
Output:
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.
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()
python run.py --version
python run.py --email alice
Output:
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.
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:
Processing [####################################] 100%
For non-iterating progress (e.g., known step count, manual advance):
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.
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()
echo "hello, alice" | python transform.py --mode upper
Output:
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.
# pyproject.toml
[project]
name = "mytool"
version = "0.1.0"
dependencies = ["click"]
[project.scripts]
mytool = "mytool.cli:cli"
# mytool/cli.py
import click
@click.group()
def cli():
"""My tool."""
@cli.command()
def hello():
click.echo("hello")
pip install .
mytool hello
Output:
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.
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.
| Feature | argparse | click | typer |
|---|---|---|---|
| Style | Imperative add_argument | Decorator-based | Type-annotation-based |
| Standard library | ✅ | ❌ requires install | ❌ requires install |
| Subcommand groups | ✅ verbose | ✅ ergonomic | ✅ ergonomic |
| Type validation | basic type= callable | rich type system | full Python type hints |
| Prompts / confirm | ❌ manual | ✅ built-in | ✅ built-in (via click) |
| Env-var fallback | manual os.environ.get | ✅ envvar= | ✅ envvar= |
| Tab completion | via argcomplete | ✅ built-in | ✅ built-in (via click) |
| Testing | manual parse_args | ✅ CliRunner | ✅ CliRunner (re-exported) |
| Rich output integration | works manually | works with rich | first-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.
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()
python notes.py -v add "buy milk" -t shopping -t urgent
python notes.py list --limit 5
python notes.py rm 42 --yes
Output:
[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.
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()
cat /etc/hosts | python mygrep.py -p "^127" -i
Output:
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.
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
| Task | Code |
|---|---|
| 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") |
| Choice | type=click.Choice([...]) |
| Path | type=click.Path(exists=True, path_type=Path) |
| Range | type=click.IntRange(1, 100) |
| Repeat | multiple=True |
| Counter | count=True |
| Env var | envvar="NAME" |
| Prompt | prompt=True or click.prompt(...) |
| Confirm | click.confirm(...) or @click.confirmation_option() |
| Context | @click.pass_context → ctx.obj |
| Echo | click.echo() / click.secho() |
| Style | click.style("x", fg="red", bold=True) |
| Progress | with click.progressbar(seq) as bar: |
| Abort | ctx.abort() or raise click.Abort() |
| Exit | ctx.exit(code) or raise click.exceptions.Exit(code) |
| Error | raise click.BadParameter("…") / ctx.fail("…") |
| Test | CliRunner().invoke(cli, [...]) |
| Distribute | pyproject.toml [project.scripts] |