Architecture

Sharing Common Options Across Python CLI Commands

Stop copy-pasting the same options onto every command: shared Click decorators that bundle values into one object, Typer Annotated aliases, and tests for consistency.

Updated

Every listing command in your CLI takes --format, --limit and --sort. Every command that talks to the API takes --timeout and --retries. The first few commands got these options by copy-and-paste, and now there are fourteen copies, three different help texts for --limit, one command where --format still defaults to text instead of table, and a new contributor who added --output instead. Shared options are the most common kind of duplication in a multi-command CLI, and the fix is to define each group of options once and apply it everywhere. This guide shows how with Click — a decorator that adds several options and hands the command a single typed object — and with Typer, using reusable Annotated aliases, then adds a test that keeps them consistent. It belongs to the structuring multi-command Python CLIs topic.

Prerequisites

  • A Click or Typer CLI with several commands that share options.
  • If the shared options are global — verbosity, profile, config file — read global options vs per-command options first; this guide is about options that belong on each command but repeat across many.

The approaches

Ways to share options across commands Approaches for reusing the same options across many Click or Typer commands, compared by duplication and how values reach the command. Ways to share options across commands Approach Duplication Value arrives as Copy-paste the decorators high separate parameters Group-level option none ctx.obj (position-sensitive) Stacked decorator one line per command parameters or an object Typer Annotated alias one line per param typed parameters A shared decorator or type alias keeps flags consistent without hiding them from help.

Copy-paste is where everyone starts and where drift comes from. Moving options to the group avoids duplication but changes their meaning and position — they must then appear before the subcommand name, and they apply to commands that do not want them. The two approaches that scale are a stacked decorator in Click and a type alias in Typer; both keep each option on the commands that use it, visible in each command's --help, while defining it only once.

The recipe: a Click decorator that bundles options

A decorator can add several options to a command and, crucially, replace their separate keyword arguments with one object. Commands then take a single parameter, and adding a fourth shared option later does not touch any command signature:

A decorator that adds and bundles options A shared decorator adds several options to a command, removes their values from the keyword arguments, and passes the command one bundled settings object. A decorator that adds and bundles options @output_options adds 3 options Click parses as usual Bundle kwargs into OutputOpts Command body one parameter decorates values passes Commands receive one typed object instead of the same three parameters everywhere.
# src/mytool/shared.py
from __future__ import annotations

import functools
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any

import click


@dataclass(frozen=True)
class ListOptions:
    format: str
    limit: int
    sort: str | None


def list_options(default_limit: int = 20, sort_fields: tuple[str, ...] = ("name",)) -> Callable:
    """Add --format/--limit/--sort to a command and pass them as one ListOptions."""

    def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
        @click.option("--format", "-o", "fmt", type=click.Choice(["table", "json", "csv"]),
                      default="table", show_default=True, help="Output format.")
        @click.option("--limit", "-n", type=click.IntRange(min=1), default=default_limit,
                      show_default=True, help="Show at most N items.")
        @click.option("--sort", type=click.Choice(sort_fields), default=None,
                      help="Sort by this field.")
        @functools.wraps(f)
        def wrapper(*args: Any, fmt: str, limit: int, sort: str | None, **kwargs: Any) -> Any:
            return f(*args, opts=ListOptions(fmt, limit, sort), **kwargs)

        return wrapper

    return decorator
# src/mytool/cli.py
import json

import click

from mytool.shared import ListOptions, list_options

USERS = [{"name": "ben", "team": "ops"}, {"name": "ana", "team": "platform"}]
TEAMS = [{"name": "platform", "size": 6}, {"name": "ops", "size": 4}]


def render(rows: list[dict], opts: ListOptions) -> None:
    if opts.sort:
        rows = sorted(rows, key=lambda r: r[opts.sort])
    rows = rows[: opts.limit]
    if opts.format == "json":
        click.echo(json.dumps(rows))
    elif opts.format == "csv":
        click.echo(",".join(rows[0]) if rows else "")
        for r in rows:
            click.echo(",".join(str(v) for v in r.values()))
    else:
        for r in rows:
            click.echo("  ".join(f"{v}" for v in r.values()))


@click.group()
def cli() -> None:
    """Directory tool."""


@cli.group()
def users() -> None:
    """Manage users."""


@users.command("list")
@list_options(sort_fields=("name", "team"))
def users_list(opts: ListOptions) -> None:
    """List users."""
    render(USERS, opts)


@cli.group()
def teams() -> None:
    """Manage teams."""


@teams.command("list")
@list_options(default_limit=50, sort_fields=("name", "size"))
def teams_list(opts: ListOptions) -> None:
    """List teams."""
    render(TEAMS, opts)


if __name__ == "__main__":
    cli()

Why this shape

One object, not three parameters. The wrapper consumes fmt, limit and sort and passes opts=ListOptions(...). When you add --reverse next month, you edit the decorator and the dataclass; no command signature changes, and every list command gains the flag at once.

Parameterised where it must differ. default_limit and sort_fields vary per command; the option names, types, short flags and help text do not. The decorator factory expresses exactly that split.

Still visible in each command's help. Unlike group-level options, these appear in mytool users list --help, with the right defaults for that command, because they are real options on that command.

functools.wraps preserves the docstring — which Click uses as help text — and the function name.

Same options, every list command Terminal output showing two different list commands accepting the same shared output options produced by one decorator. Same options, every list command bash $ mytool users list --format json --limit 2 --sort name [{"name": "ana"}, {"name": "ben"}] $ mytool teams list --format json --limit 1 --sort name [{"name": "platform"}] Consistency that users feel comes from a single definition in code.

The recipe: Typer aliases

Typer builds options from annotations, so the natural unit of reuse is an annotated type alias:

# src/mytool/typer_options.py
from enum import Enum
from typing import Annotated

import typer


class Format(str, Enum):
    table = "table"
    json = "json"
    csv = "csv"


FormatOpt = Annotated[Format, typer.Option("--format", "-o", help="Output format.")]
LimitOpt = Annotated[int, typer.Option("--limit", "-n", min=1, help="Show at most N items.")]
SortOpt = Annotated[str | None, typer.Option("--sort", help="Sort by this field.")]
@users_app.command("list")
def users_list(fmt: FormatOpt = Format.table, limit: LimitOpt = 20, sort: SortOpt = None) -> None:
    """List users."""

Each command still lists three parameters, but their names, flags, help and validation come from one place, and defaults can differ per command. To bundle them into an object as the Click decorator does, build the dataclass on the first line of the command, or use a small wrapper; the Annotated technique itself is covered in using Annotated options in Typer.

UX considerations

  • Identical flags everywhere. The whole point is that -o json and -n 5 work the same on every list command; users learn them once. This is the practical enforcement of naming commands and flags consistently.
  • Per-command defaults where they make sense. A command listing thousands of audit events may default to --limit 50; one listing a dozen teams may show all. The shared definition should allow that without forking.
  • Validate per command. --sort choices depend on the fields a command returns, so the decorator takes them as a parameter and Click rejects --sort size on users list with a clear error.
  • Keep bundles small and cohesive. A decorator for "list options" and another for "network options" are easy to reason about; one decorator that adds twelve unrelated options becomes the new source of confusion.

Testing the behaviour

Test the decorator's behaviour through commands, and add one test that asserts every command using the bundle exposes identical options — the regression that shared definitions are meant to prevent:

# tests/test_shared.py
import json

import click
from click.testing import CliRunner

from mytool.cli import cli

runner = CliRunner()


def test_same_options_on_every_list_command():
    for path in (["users", "list"], ["teams", "list"]):
        result = runner.invoke(cli, [*path, "--format", "json", "--limit", "1", "--sort", "name"])
        assert result.exit_code == 0, result.output
        assert len(json.loads(result.output)) == 1


def test_per_command_sort_choices():
    result = runner.invoke(cli, ["users", "list", "--sort", "size"])
    assert result.exit_code == 2 and "Invalid value for '--sort'" in result.output


def test_shared_option_definitions_are_identical():
    def opts(group: str) -> dict[str, tuple]:
        cmd = cli.commands[group].commands["list"]
        return {p.name: (tuple(p.opts), p.help) for p in cmd.params if isinstance(p, click.Option)}

    users, teams = opts("users"), opts("teams")
    for name in ("fmt", "limit", "sort"):
        assert users[name] == teams[name], name

The last test compares flag spellings and help text between commands, so nobody can quietly change one copy — because there are no copies. The broader patterns for CLI tests are in testing Click commands with CliRunner.

Conclusion

Options that repeat across commands should be defined once. In Click, a decorator factory adds a cohesive bundle of options and passes the command one typed object, with per-command parameters for the parts that legitimately differ; in Typer, Annotated aliases carry flag names, help and validation into every command that uses them. Both keep options visible in each command's help, make interface consistency the default rather than a convention, and turn adding a new shared option into a one-place change.

Frequently asked questions

Can I stack several shared decorators on one command?

Yes — @list_options() and @network_options() on the same command work as long as their option names do not collide. Keep each bundle's names distinct and pass each as its own object.

Why not use ctx.obj for these options?

ctx.obj suits values set once per invocation at the group level. Per-command options belong to the command's own parameters; routing them through ctx.obj hides them from the command's signature and help.

Where should shared option definitions live?

In a small module next to the command layer — mytool/shared.py or mytool/cli/options.py — never in core logic. The options are part of the command-line interface, so they belong with it; the dataclasses they produce (such as ListOptions) can live alongside them or in core if core functions accept them. Keeping them together makes the module the single place a reviewer checks when a new command claims to "use the standard list options".

Does the decorator break shell completion?

No. The options are ordinary Click options on the command, so completion, --help and validation work exactly as if they were written inline.

How do I share arguments, not just options?

The same way: a decorator can add click.argument(...) too. Be careful with order — Click applies decorators bottom-up, so arguments added by the decorator appear before or after the command's own arguments depending on where the decorator sits.