click-option-group
click-option-group is a small Click extension that puts related options under labeled sections in `--help` and can validate relationships among those options. Its built-in group classes cover at-least-one, all-or-none, all-required, mutually-exclusive, and exactly-one-required rules. It keeps Click's decorator style and passes ordinary option declarations through to Click, so it is best viewed as missing presentation and constraint glue for an existing Click command.
Install it when a mature Click command needs readable help sections and a few local option constraints. For a new typed CLI or one small command, it is extra decorator machinery you can avoid.
Use it if
- Your Click command has enough options that flat help output is hard to scan
- You need declarative exactly-one, at-least-one, all-or-none, or all-required option rules
- You want constraints checked during Click parsing with standard usage errors
- You already use Click decorators and do not want to adopt a new CLI framework
- You only need mutual exclusion and can use Python's standard `argparse`, which the project README notes has option groups built in
- You are starting a typed CLI and prefer parameters inferred from function annotations; this extension keeps Click's decorator-heavy model while Typer or Cyclopts owns the whole interface
- Your command has only a handful of flags: adding an extension and decorator-order rules buys little over clear option names and help text
- You need constraints spanning options on different commands or mixed with positional arguments; the documented model validates options collected into one local group
- You want a large support base: the repository has 121 stars and 6 open issues and pull requests, so this is a focused community extension rather than core Click
Setup reality
`pip install -U click-option-group` installs a pure Python package requiring Python 3.7 or later and Click 7 or later. There are no services, generated files, or native builds. The learning cost is decorator placement. `@optgroup.group(...)` collects the contiguous `@optgroup.option(...)` decorators beneath it because Python applies decorators from the function outward; place another group marker at the wrong point and options appear under the wrong heading or outside the intended constraint. These are grouped options, not ordinary `@click.option` declarations, so use `optgroup.option` for every member a rule must inspect. Pick the semantic class carefully: `MutuallyExclusiveOptionGroup` permits none, while `RequiredMutuallyExclusiveOptionGroup` requires exactly one; `AllOptionGroup` allows all or none, while `RequiredAllOptionGroup` requires all. The extension still inherits Click's parsing, type conversion, callbacks, environment values, and testing behavior, so Click remains a required skill and dependency. Constraints fire at parse time and produce `UsageError` output, which means automated tests should cover empty, partial, conflicting, defaulted, and valid combinations. Defaults can make an option count as present in ways that are easy to miss, so avoid clever defaults inside constrained groups unless tests prove the behavior you intend.
Patterns
Put related options under one help headinggroup-help-options
import click
from click_option_group import optgroup
@click.command()
@optgroup.group('Server', help='Connection settings')
@optgroup.option('--host', default='localhost', show_default=True)
@optgroup.option('--port', type=click.IntRange(1, 65535), default=8000, show_default=True)
def cli(host, port):
click.echo(f'{host}:{port}')Keep each group's `optgroup.option` decorators contiguous directly below its group decorator.
Require at least one optionrequire-any-option
import click
from click_option_group import RequiredAnyOptionGroup, optgroup
@click.command()
@optgroup.group('Output', cls=RequiredAnyOptionGroup)
@optgroup.option('--json-file', type=click.Path())
@optgroup.option('--stdout', is_flag=True)
def export(json_file, stdout):
passThis group accepts one or both options; use a required mutually-exclusive group when exactly one is allowed.
Require exactly one input sourcerequire-exactly-one
import click
from click_option_group import RequiredMutuallyExclusiveOptionGroup, optgroup
@click.command()
@optgroup.group('Input', cls=RequiredMutuallyExclusiveOptionGroup)
@optgroup.option('--json-file', type=click.File('r'))
@optgroup.option('--csv-file', type=click.File('r'))
def load(json_file, csv_file):
source = json_file or csv_fileThe group rejects both zero selections and multiple selections during command-line parsing.
Allow at most one output formatallow-zero-or-one
import click
from click_option_group import MutuallyExclusiveOptionGroup, optgroup
@click.command()
@optgroup.group('Format', cls=MutuallyExclusiveOptionGroup)
@optgroup.option('--json', 'format_', flag_value='json')
@optgroup.option('--yaml', 'format_', flag_value='yaml')
def report(format_):
click.echo(format_ or 'text')This group permits no choice; do not use it when one format must always be explicit.
Require credentials together or not at allrequire-all-or-none
import click
from click_option_group import AllOptionGroup, optgroup
@click.command()
@optgroup.group('Proxy credentials', cls=AllOptionGroup)
@optgroup.option('--proxy-user')
@optgroup.option('--proxy-password', hide_input=True)
def fetch(proxy_user, proxy_password):
passAllOptionGroup accepts neither option or every option, preventing half-configured credentials.
Require every option in a grouprequire-all-options
import click
from click_option_group import RequiredAllOptionGroup, optgroup
@click.command()
@optgroup.group('Database', cls=RequiredAllOptionGroup)
@optgroup.option('--host', required=False)
@optgroup.option('--database', required=False)
def migrate(host, database):
passThe group supplies the collective required rule; adding individual `required=True` flags would obscure why the group class is present.
Use multiple groups on one commandcombine-option-groups
import click
from click_option_group import RequiredMutuallyExclusiveOptionGroup, optgroup
@click.command()
@optgroup.group('Server')
@optgroup.option('--host', default='localhost')
@optgroup.option('--port', type=int, default=8000)
@optgroup.group('Input', cls=RequiredMutuallyExclusiveOptionGroup)
@optgroup.option('--file', type=click.Path(exists=True))
@optgroup.option('--stdin', is_flag=True)
@click.option('--verbose', is_flag=True)
def cli(host, port, file, stdin, verbose):
passOrdinary Click options can coexist, but only options declared with `optgroup.option` participate in a group's validation.
Use Click types, prompts, and environment valuesreuse-click-option-features
import click
from click_option_group import optgroup
@click.command()
@optgroup.group('Authentication')
@optgroup.option('--token', envvar='APP_TOKEN', hide_input=True)
@optgroup.option('--timeout', type=click.FloatRange(min=0.1), default=5.0)
def cli(token, timeout):
passGrouped options are still Click options, so environment values and defaults may affect whether a constraint sees an option as supplied.
Hide an internal option group from helphide-option-group
import click
from click_option_group import optgroup
@click.command()
@optgroup.group('Internal', hidden=True)
@optgroup.option('--trace-parser', is_flag=True, hidden=True)
def cli(trace_parser):
passHidden options remain parseable; hiding is not access control and should not protect unsafe behavior.
Test valid and conflicting combinationstest-group-constraints
from click.testing import CliRunner
def test_input_group():
runner = CliRunner()
assert runner.invoke(load, []).exit_code != 0
assert runner.invoke(load, ['--json-file', 'a.json']).exit_code == 0
result = runner.invoke(load, ['--json-file', 'a.json', '--csv-file', 'a.csv'])
assert result.exit_code != 0
assert 'mutually exclusive' in result.output.lower()Test empty, partial, conflicting, and valid cases because defaults and callbacks can change what the parser considers present.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| click | PyPI | Simple commands only need core options, commands, callbacks, and custom validation |
| typer | PyPI | You want Click underneath but prefer type-annotation-driven command definitions |
| cyclopts | PyPI | You want modern annotation-driven parsing with parameter groups and validation |
| cloup | PyPI | You want a broader Click extension covering option groups, constraints, sections, and styled help |