Real commands have options that interact. --json and --csv cannot both be used. --cert is useless without --key, and vice versa. --output-dir means nothing unless --save is also given. When these rules are not enforced, the tool silently picks one option or ignores another — the user asked for both JSON and CSV and gets JSON with no explanation, or passes --output-dir and wonders why nothing was written. Click has no built-in syntax for such relationships, and scattering if checks through command bodies produces inconsistent messages and rules that are easy to forget. This guide declares relationships as small rule objects, checks them in one place after parsing, reports every violation as a standard usage error, and uses Click's parameter source to tell a value the user supplied from a default — the detail that makes these rules correct. It belongs to the advanced argument validation strategies topic.
Prerequisites
- Click 8.0+ (for
Context.get_parameter_source) or Typer; the rules module below works with both. - For argparse, the equivalent techniques are in mutually exclusive options in argparse.
The kinds of relationship
Most relationships fall into a few shapes: exclusive (at most one of these), requires (if A then B), only with (A is meaningless without flag B), at least one of, and relationships between values (the end date must follow the start date). The first three are about whether options were given, and are generic enough to express as reusable rules. Value relationships are specific to each command and belong in its body, right after the generic checks.
Given, or just defaulted?
The subtle part of these rules is deciding whether an option was given. Checking if cert is not None works for options defaulting to None, but fails for flags (which default to False), for options with real defaults, and for options that can also come from an environment variable. Click records where each value came from:
from click.core import ParameterSource
ctx.get_parameter_source("key")
# ParameterSource.COMMANDLINE, ENVIRONMENT, DEFAULT_MAP or DEFAULT
Treating command-line and environment values as "given", and defaults as "not given", gives rules the meaning users expect: MYTOOL_KEY=k.pem mytool report --cert c.pem satisfies "--cert requires --key", while an option that merely has a default never triggers a conflict.
The recipe
# src/mytool/rules.py
from __future__ import annotations
import functools
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
import click
GIVEN_SOURCES = {"COMMANDLINE", "ENVIRONMENT"}
def given(ctx: click.Context, name: str) -> bool:
"""True if the user supplied this parameter (flag or environment), not just the default.
Compares the source by name so it works with Click and with Typer's vendored copy of Click.
"""
source = ctx.get_parameter_source(name)
return source is not None and source.name in GIVEN_SOURCES
def flag(ctx: click.Context, name: str) -> str:
param = next(p for p in ctx.command.params if p.name == name)
return param.opts[0] if getattr(param, "opts", None) else name.upper()
@dataclass(frozen=True)
class Exclusive:
names: tuple[str, ...]
def check(self, ctx: click.Context) -> str | None:
used = [n for n in self.names if given(ctx, n)]
if len(used) > 1:
return f"{flag(ctx, used[0])} cannot be used together with {flag(ctx, used[1])}."
return None
@dataclass(frozen=True)
class Requires:
name: str
needs: str
def check(self, ctx: click.Context) -> str | None:
if given(ctx, self.name) and not given(ctx, self.needs):
return f"{flag(ctx, self.name)} requires {flag(ctx, self.needs)}."
return None
@dataclass(frozen=True)
class OnlyWith:
name: str
flag_name: str
def check(self, ctx: click.Context) -> str | None:
if given(ctx, self.name) and not ctx.params.get(self.flag_name):
return f"{flag(ctx, self.name)} is only allowed with {flag(ctx, self.flag_name)}."
return None
def check_rules(ctx: click.Context, *rules: Any) -> None:
"""Raise one UsageError listing every violated rule."""
problems = [msg for rule in rules if (msg := rule.check(ctx))]
if problems:
ctx.fail(" ".join(problems)) # the context's own UsageError: exit status 2
def enforce(*rules: Any) -> Callable:
"""Click decorator: check option relationships after parsing, before the body runs."""
def decorator(f: Callable) -> Callable:
@functools.wraps(f)
def wrapper(*args: Any, **kwargs: Any) -> Any:
check_rules(click.get_current_context(), *rules)
return f(*args, **kwargs)
return wrapper
return decorator
# src/mytool/cli.py
from pathlib import Path
import click
from mytool.rules import Exclusive, OnlyWith, Requires, enforce
@click.group()
def cli() -> None:
"""Reporting tool."""
@cli.command()
@click.option("--json", "as_json", is_flag=True, help="JSON output.")
@click.option("--csv", "as_csv", is_flag=True, help="CSV output.")
@click.option("--save", is_flag=True, help="Save the report instead of printing it.")
@click.option("--output-dir", type=click.Path(path_type=Path), help="Where to save (requires --save).")
@click.option("--cert", type=click.Path(path_type=Path), help="Client certificate.")
@click.option("--key", type=click.Path(path_type=Path), envvar="MYTOOL_KEY", help="Client key.")
@enforce(
Exclusive(("as_json", "as_csv")),
OnlyWith("output_dir", "save"),
Requires("cert", "key"),
Requires("key", "cert"),
)
def report(as_json: bool, as_csv: bool, save: bool, output_dir: Path | None,
cert: Path | None, key: Path | None) -> None:
"""Build a report."""
fmt = "json" if as_json else "csv" if as_csv else "table"
click.echo(f"format={fmt} save={save} dir={output_dir} tls={bool(cert)}")
if __name__ == "__main__":
cli()
How it works:
- Rules are data.
Exclusive,RequiresandOnlyWithare small frozen dataclasses with acheck(ctx)method returning an error message orNone. The command's rules are listed in one decorator, readable at a glance next to the options they govern. enforceruns after parsing and conversion, before the body. Every value is already typed, so rules can inspect them, and the body can assume all rules hold.- Messages use the real flag names.
flag()looks up the option's first spelling (--json, not the parameter nameas_json), so errors match what users type. - All violations are reported together. Collecting messages from every rule and raising one
UsageErrorsaves users the fix-one-rerun loop. ctx.fail()produces the standard format: usage line, "Error: ...", exit status 2 — the same as Click's own errors, as described in choosing exit codes for CLI tools.- Symmetric rules are listed twice. "
--certrequires--key" and "--keyrequires--cert" are separate rules, so each direction gets a precise message.
In Typer
Typer commands can use the same rules by taking a typer.Context parameter and calling check_rules as the first line of the body:
import typer
from mytool.rules import Exclusive, Requires, check_rules
app = typer.Typer()
@app.command()
def report(
ctx: typer.Context,
as_json: bool = typer.Option(False, "--json"),
as_csv: bool = typer.Option(False, "--csv"),
cert: str | None = typer.Option(None, "--cert"),
key: str | None = typer.Option(None, "--key", envvar="MYTOOL_KEY"),
) -> None:
check_rules(ctx, Exclusive(("as_json", "as_csv")), Requires("cert", "key"), Requires("key", "cert"))
...
Use the explicit call rather than the enforce decorator with Typer. Recent Typer releases ship their own vendored copy of Click, so click.get_current_context() from the standalone click package does not see Typer's context, and Typer's ParameterSource enum is a different class. That is also why given() compares sources by name and check_rules raises through ctx.fail(), which uses whichever Click the context belongs to — the same rules module then works unchanged in both frameworks.
Value relationships
For rules about values — a date range, a minimum that must not exceed a maximum — check in the command body right after the decorator has run, and raise click.BadParameter(message, param_hint="--until") so the error names the option. Keeping these few lines at the top of the body, next to the generic rules in the decorator, keeps all validation visible in one place.
UX considerations
- Never resolve a conflict silently. If the user typed both
--jsonand--csv, they meant something by each; an error is kinder than a guess. - State the rule in help text too. "(requires --save)" in the help for
--output-dirteaches the rule before anyone breaks it. - Prefer a single option when choices are exclusive.
--format json|csv|tablemakes the conflict impossible, and is often better design than exclusive flags — keep flags as shortcuts if users like them. - Mind environment values. Because environment variables count as "given", a stray
MYTOOL_KEYin someone's shell can satisfy or trigger rules. Mentioning the source in errors ("--key (from MYTOOL_KEY)") helps in complex setups.
Testing the behaviour
Every rule needs a passing and a failing case, and the environment case deserves its own test:
# tests/test_rules.py
import pytest
from click.testing import CliRunner
from mytool.cli import cli
runner = CliRunner()
def run(*args, env=None):
return runner.invoke(cli, ["report", *args], env=env or {"MYTOOL_KEY": ""})
def test_defaults_are_fine():
assert run().exit_code == 0
@pytest.mark.parametrize("args, message", [
(["--json", "--csv"], "--json cannot be used together with --csv."),
(["--output-dir", "out"], "--output-dir is only allowed with --save."),
(["--cert", "c.pem"], "--cert requires --key."),
(["--key", "k.pem"], "--key requires --cert."),
])
def test_rule_violations_are_usage_errors(args, message):
result = run(*args)
assert result.exit_code == 2
assert message in result.output
def test_valid_combinations():
assert run("--save", "--output-dir", "out", "--json").exit_code == 0
assert run("--cert", "c.pem", "--key", "k.pem").exit_code == 0
def test_environment_counts_as_given():
assert run("--cert", "c.pem", env={"MYTOOL_KEY": "k.pem"}).exit_code == 0
def test_all_problems_reported_together():
result = run("--json", "--csv", "--cert", "c.pem")
assert "--json cannot" in result.output and "--cert requires" in result.output
The parametrised test doubles as documentation of every rule and its exact message. The environment test proves that parameter sources are consulted correctly, and the last test pins the "report everything at once" behaviour. More on structuring such tests is in testing Click commands with CliRunner.
Conclusion
Option relationships are part of a command's interface and deserve the same care as the options themselves. Express conflicts, requirements and only-with rules as small declarative objects, check them all in one decorator after parsing, use get_parameter_source so defaults never count as user input while environment values do, report every violation together as a standard usage error with the real flag names, and test each rule both ways. Commands then fail clearly on contradictory input and never silently ignore something the user typed.
Frequently asked questions
Why not use a third-party package like click-option-group?
Packages such as click-option-group provide grouped and mutually exclusive options with good help output, and are a fine choice for Click-only projects. The small rule set here works with both Click and Typer, handles parameter sources explicitly, and keeps the rules visible next to the command.
Can rules depend on option values rather than presence?
OnlyWith already checks a flag's value. For richer conditions — "--replicas above 10 requires --force" — add a rule class whose check reads ctx.params, or write the check directly in the command body.
Should config-file values count as given?
If you load a config file into ctx.default_map, Click reports those values as DEFAULT_MAP. Whether they should satisfy "requires" rules is a design choice; usually they should, so add ParameterSource.DEFAULT_MAP to the given check. See config precedence: flags, env, files and defaults.
How do I show these rules in --help?
Mention them in each option's help text, and consider an epilog listing the rules for complex commands, as covered in adding examples and epilogs to help output.