Input & UX

Validating Dependent and Conflicting CLI Options

Enforce option relationships in Click and Typer: conflicts, requires, only-with rules declared as data, parameter sources for defaults vs flags, and tests.

Updated

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

The kinds of relationship

Kinds of option relationship Relationships between command line options, an example of each, and where in a Click application to enforce them. Kinds of option relationship Relationship Example Enforce in Mutually exclusive --json vs --csv a shared validator Requires --cert needs --key a shared validator Only with --output-dir only with --save a shared validator At least one of --all or a NAME the command body Value relation --until after --since the command body Declare the rules as data, check them in one place, report them in the usage format.

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()
Where rules are checked Click parses and converts each option, a single validation step checks the relationships between options, and only then does the command body run. Where rules are checked Parse each option Convert types, choices Check rules relationships Command body rules hold values all params safe After the check, the command body can assume every rule holds.

How it works:

  • Rules are data. Exclusive, Requires and OnlyWith are small frozen dataclasses with a check(ctx) method returning an error message or None. The command's rules are listed in one decorator, readable at a glance next to the options they govern.
  • enforce runs 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 name as_json), so errors match what users type.
  • All violations are reported together. Collecting messages from every rule and raising one UsageError saves 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. "--cert requires --key" and "--key requires --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

Relationship errors Terminal output of a CLI rejecting two conflicting options and an option used without the option it requires. Relationship errors bash $ mytool report --json --csv Error: --json cannot be used together with --csv. $ mytool report --cert client.pem Error: --cert requires --key. Both errors exit with status 2, like any other usage error.
  • Never resolve a conflict silently. If the user typed both --json and --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-dir teaches the rule before anyone breaks it.
  • Prefer a single option when choices are exclusive. --format json|csv|table makes 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_KEY in 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.