Skip to content

Commit c77f55b

Browse files
feat(annotated): let a mutually exclusive group declare its own titled section
1 parent 2a9eee9 commit c77f55b

5 files changed

Lines changed: 412 additions & 79 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@
1313
- Experimental features
1414
- `@with_annotated` now supports `frozenset[T]` collection parameters, alongside the existing
1515
`list[T]`, `set[T]`, and `tuple[T, ...]` collection types.
16+
- `@with_annotated` mutually exclusive groups now accept a `title`/`description` to render the
17+
group as a titled help section (argparse's one supported nesting, a mutex inside an argument
18+
group), declared in one place with no paired `groups=` entry.
19+
- `@with_annotated` now validates `groups` / `mutually_exclusive_groups` specs eagerly at
20+
decoration time, so a misconfigured group (a member that names no parameter, a parameter
21+
placed in two groups, a mutex group spanning or partially overlapping argument groups, a
22+
titled section declared in two places, or `Group(required=True)` on a plain group) hard-fails
23+
when the class is defined instead of being deferred to first command use where the error was
24+
swallowed. The checks read parameter names only, so forward-referenced annotations still
25+
decorate cleanly.
1626

1727
## 4.0.0 (June 5, 2026)
1828

cmd2/annotated.py

Lines changed: 141 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,20 @@ def do_paint(
127127
leaking ``:param:`` directives; and ``prog`` is rejected with ``subcommand_to`` because cmd2
128128
rewrites it from the parent command's hierarchy. Mutually exclusive groups accept
129129
``Group(required=True)`` to require exactly one member; the same flag on a plain ``groups=`` entry
130-
raises ``ValueError`` (argparse's ``add_argument_group`` has no ``required``).
130+
raises ``ValueError`` (argparse's ``add_argument_group`` has no ``required``). Give a
131+
``mutually_exclusive_groups`` entry a ``title``/``description`` to render it as a titled help section
132+
(argparse's one supported nesting -- a mutex *inside* an argument group), and use
133+
``Option(action='store_true')`` for any ``bool`` member so the mutex reads as ``[--foo | --bar]``
134+
instead of expanding to ``--no-*`` variants. To put non-mutex parameters in the same section, list
135+
its members in a ``groups=`` entry instead and leave the title off the mutex; declaring the section in
136+
both places, a mutex that sits only partly in a ``groups=`` entry, or one that spans two of them all
137+
raise ``ValueError``. The other three nesting directions (an argument group in an argument group or
138+
in a mutex, and a mutex in a mutex) are removed in argparse on Python 3.14 and cannot be expressed
139+
here. These group-spec rules (and member references, double-assignment, and the ``required=True``
140+
rejection) are checked at decoration time from parameter names alone -- type hints are not resolved,
141+
so forward-referenced annotations still decorate -- meaning a misconfigured group raises when the
142+
class is defined rather than on first command use. The one group rule that needs the annotations
143+
(a required member in a mutually exclusive group) fires when the parser is built.
131144
132145
Unsupported patterns (raise ``TypeError``):
133146
@@ -896,8 +909,8 @@ def __init__(
896909
self.build_error: Exception | None = None
897910
# cross-argument facts, linked by _resolve_parameters once the whole list is built:
898911
self.has_following_positional = False
899-
# 1-based indices of the groups=/mutually_exclusive_groups= this parameter belongs to:
900-
self.argument_group_indices: list[int] = []
912+
# 1-based indices of the mutually_exclusive_groups= entries this parameter belongs to
913+
# (spec-shaped rules live in _validate_group_specs; this fact feeds the required-member row):
901914
self.mutex_group_indices: list[int] = []
902915
# Derive every output slot now; validation stays deferred to _check_constraints.
903916
self._apply()
@@ -1662,20 +1675,6 @@ def _const_mismatches_type(a: _ArgparseArgument) -> bool:
16621675
f"which creates a positional argument that conflicts with subcommand parsing."
16631676
),
16641677
),
1665-
(
1666-
# Cross-config: a parameter assigned to two argument groups is ambiguous. The membership
1667-
# indices are linked by _resolve_parameters from the decorator's groups= before this runs.
1668-
lambda a: len(a.argument_group_indices) > 1,
1669-
lambda a: ValueError(
1670-
f"parameter {a.name!r} cannot be assigned to both argument "
1671-
f"group {a.argument_group_indices[0]} and argument group {a.argument_group_indices[1]}"
1672-
),
1673-
),
1674-
(
1675-
# Cross-config: a parameter cannot belong to two mutually exclusive groups.
1676-
lambda a: len(a.mutex_group_indices) > 1,
1677-
lambda a: ValueError(f"parameter {a.name!r} cannot be assigned to multiple mutually exclusive groups"),
1678-
),
16791678
(
16801679
# Cross-config: a required member is incompatible with a mutex group -- only one member is
16811680
# supplied, so the others arrive as None (violating its non-Optional type), and argparse forbids
@@ -1716,8 +1715,8 @@ def _link_group_membership(
17161715
) -> None:
17171716
"""Append each spec's 1-based index to the *select*-ed membership list of each member argument.
17181717
1719-
:func:`_resolve_parameters` validates member references via :meth:`Group._validate_members`
1720-
before calling this, so every member name resolves to a built argument.
1718+
Member references are validated upstream by :func:`_validate_group_specs` before this runs,
1719+
so every member name resolves to a built argument.
17211720
"""
17221721
if not specs:
17231722
return
@@ -1731,14 +1730,14 @@ def _resolve_parameters(
17311730
*,
17321731
skip_params: frozenset[str] = _SKIP_PARAMS,
17331732
base_command: bool = False,
1734-
groups: tuple[Group, ...] | None = None,
17351733
mutually_exclusive_groups: tuple[Group, ...] | None = None,
17361734
) -> list[_ArgparseArgument]:
17371735
"""Resolve a function signature into a list of argparse-argument builders.
17381736
17391737
``base_command`` marks each argument's context for the base-command :data:`_CONSTRAINTS` rows and
1740-
drives the function-level ``cmd2_subcommand_func`` check below. ``groups``/``mutually_exclusive_groups``
1741-
are linked onto each argument as membership facts for the cross-config constraint rows.
1738+
drives the function-level ``cmd2_subcommand_func`` check below. ``mutually_exclusive_groups``
1739+
membership is linked onto each argument as the fact behind the required-member constraint row;
1740+
the spec-shaped group rules live in :func:`_validate_group_specs`, which runs before this.
17421741
"""
17431742
sig = inspect.signature(func)
17441743
# Function-level check (not a per-argument _CONSTRAINTS row): a base command dispatches through
@@ -1807,13 +1806,6 @@ def _resolve_parameters(
18071806
for arg in positionals[:-1]: # every positional except the last has a following positional
18081807
arg.has_following_positional = True
18091808
by_name = {arg.name: arg for arg in resolved}
1810-
# Reject group references to nonexistent parameters before the constraint table runs.
1811-
all_param_names = set(by_name)
1812-
for spec in groups or ():
1813-
spec._validate_members(all_param_names=all_param_names, group_type="groups")
1814-
for spec in mutually_exclusive_groups or ():
1815-
spec._validate_members(all_param_names=all_param_names, group_type="mutually_exclusive_groups")
1816-
_link_group_membership(by_name, groups, lambda a: a.argument_group_indices)
18171809
_link_group_membership(by_name, mutually_exclusive_groups, lambda a: a.mutex_group_indices)
18181810
for arg in resolved:
18191811
arg._check_constraints()
@@ -1872,16 +1864,90 @@ def _filtered_namespace_kwargs(
18721864
return filtered
18731865

18741866

1867+
def _validate_group_specs(
1868+
func: Callable[..., Any],
1869+
*,
1870+
skip_params: frozenset[str],
1871+
groups: tuple[Group, ...] | None,
1872+
mutually_exclusive_groups: tuple[Group, ...] | None,
1873+
) -> None:
1874+
"""Validate ``groups=`` / ``mutually_exclusive_groups=`` specs from parameter names alone.
1875+
1876+
Runs at decoration time (from both the regular-command and subcommand decoration paths, and
1877+
again from :func:`build_parser_from_function` for direct callers), so a misconfigured group
1878+
hard-fails when the class is defined instead of on first command use, where cmd2's runtime
1879+
handler turns the error into a printed message. Reads only parameter names and the ``Group``
1880+
specs -- never the type hints -- so forward-referenced annotations still decorate. The one
1881+
group rule that needs the annotations (a required member in a mutually exclusive group) stays
1882+
in :data:`_CONSTRAINTS` and fires when the parser is built.
1883+
"""
1884+
if not groups and not mutually_exclusive_groups:
1885+
return
1886+
params = list(inspect.signature(func).parameters)[1:] # skip self/cls by position
1887+
all_param_names = {name for name in params if name not in skip_params}
1888+
1889+
group_entry_for: dict[str, int] = {}
1890+
for index, spec in enumerate(groups or (), start=1):
1891+
spec._validate_members(all_param_names=all_param_names, group_type="groups")
1892+
if spec.required:
1893+
raise ValueError(
1894+
"Group(required=True) is only valid in mutually_exclusive_groups; "
1895+
"argparse's add_argument_group has no 'required' flag"
1896+
)
1897+
for name in spec.members:
1898+
previous = group_entry_for.get(name)
1899+
if previous == index:
1900+
raise ValueError(f"parameter {name!r} is listed more than once in argument group {index}")
1901+
if previous is not None:
1902+
raise ValueError(
1903+
f"parameter {name!r} cannot be assigned to both argument group {previous} and argument group {index}"
1904+
)
1905+
group_entry_for[name] = index
1906+
1907+
mutex_entry_for: dict[str, int] = {}
1908+
for index, spec in enumerate(mutually_exclusive_groups or (), start=1):
1909+
spec._validate_members(all_param_names=all_param_names, group_type="mutually_exclusive_groups")
1910+
for name in spec.members:
1911+
previous = mutex_entry_for.get(name)
1912+
if previous == index:
1913+
raise ValueError(f"parameter {name!r} is listed more than once in mutually exclusive group {index}")
1914+
if previous is not None:
1915+
raise ValueError(f"parameter {name!r} cannot be assigned to multiple mutually exclusive groups")
1916+
mutex_entry_for[name] = index
1917+
parent_entries = {group_entry_for[name] for name in spec.members if name in group_entry_for}
1918+
if len(parent_entries) > 1:
1919+
raise ValueError(
1920+
f"mutually exclusive group {index} spans parameters in different argument groups, "
1921+
"which argparse cannot represent cleanly"
1922+
)
1923+
if parent_entries:
1924+
# Members already sit in a titled groups= entry, so the mutex nests there. A section
1925+
# declared on both sides is ambiguous, and nesting a mutex that only partly overlaps the
1926+
# group would pull the ungrouped members into that group's help section.
1927+
if spec.title is not None or spec.description is not None:
1928+
raise ValueError(
1929+
f"mutually exclusive group {index} sets title/description, but its members already "
1930+
"belong to a groups= entry; declare the titled section in one place only"
1931+
)
1932+
ungrouped = [name for name in spec.members if name not in group_entry_for]
1933+
if ungrouped:
1934+
raise ValueError(
1935+
f"mutually exclusive group {index} mixes members in a titled argument group with "
1936+
f"members that are not ({ungrouped!r}); list all of its members in the same groups= "
1937+
"entry to nest the mutex inside that group, or none of them to keep it top-level"
1938+
)
1939+
1940+
18751941
def _build_argument_group_targets(
18761942
parser: argparse.ArgumentParser,
18771943
*,
18781944
groups: tuple[Group, ...] | None,
18791945
) -> tuple[dict[str, _ArgumentTarget], dict[str, argparse._ArgumentGroup]]:
18801946
"""Build argument groups and return add_argument targets for their members.
18811947
1882-
Member references and double-assignment are validated upstream by :func:`_resolve_parameters`
1883-
(via :meth:`Group._validate_members`) and :data:`_CONSTRAINTS` (the ``argument_group_indices``
1884-
fact), so construction can assign each member unconditionally.
1948+
The specs are validated upstream by :func:`_validate_group_specs` (member references,
1949+
double-assignment, ``required=True`` rejection), so construction can assign each member
1950+
unconditionally.
18851951
"""
18861952
target_for: dict[str, _ArgumentTarget] = {}
18871953
argument_group_for: dict[str, argparse._ArgumentGroup] = {}
@@ -1890,11 +1956,6 @@ def _build_argument_group_targets(
18901956
return target_for, argument_group_for
18911957

18921958
for spec in groups:
1893-
if spec.required:
1894-
raise ValueError(
1895-
"Group(required=True) is only valid in mutually_exclusive_groups; "
1896-
"argparse's add_argument_group has no 'required' flag"
1897-
)
18981959
group = parser.add_argument_group(title=spec.title, description=spec.description)
18991960
for name in spec.members:
19001961
argument_group_for[name] = group
@@ -1912,27 +1973,29 @@ def _apply_mutex_group_targets(
19121973
) -> None:
19131974
"""Build mutually exclusive groups and update add_argument targets for their members.
19141975
1915-
Member references, double-assignment, and required-member rejections are validated upstream by
1916-
:func:`_resolve_parameters` and :data:`_CONSTRAINTS` (the ``mutex_group_indices`` fact); the
1917-
remaining check -- a mutex group spanning different argument groups -- stays here because its
1918-
subject is the group, not an argument.
1976+
The specs are validated upstream by :func:`_validate_group_specs` (member references,
1977+
double-assignment, and the group-shaped rules: spanning, partial overlap, a section declared in
1978+
two places) and :data:`_CONSTRAINTS` (the required-member rule), so construction only chooses
1979+
each mutex group's parent: the argument group all its members share, a new titled section when
1980+
the spec carries ``title``/``description``, or the parser itself.
19191981
"""
19201982
if not mutually_exclusive_groups:
19211983
return
19221984

1923-
for index, spec in enumerate(mutually_exclusive_groups, start=1):
1924-
member_names = spec.members
1925-
1926-
parent_groups = {argument_group_for[name] for name in member_names if name in argument_group_for}
1927-
if len(parent_groups) > 1:
1928-
raise ValueError(
1929-
f"mutually exclusive group {index} spans parameters in different argument groups, "
1930-
"which argparse cannot represent cleanly"
1931-
)
1985+
for spec in mutually_exclusive_groups:
1986+
parent_groups = {argument_group_for[name] for name in spec.members if name in argument_group_for}
1987+
if parent_groups:
1988+
# All members sit in one titled groups= entry, so the mutex nests there.
1989+
mutex_parent: _ArgumentTarget = next(iter(parent_groups))
1990+
elif spec.title is not None or spec.description is not None:
1991+
# title/description on the mutex create the titled section and nest the mutex inside it,
1992+
# so a titled exclusive group needs only its own declaration -- no paired groups= entry.
1993+
mutex_parent = parser.add_argument_group(title=spec.title, description=spec.description)
1994+
else:
1995+
mutex_parent = parser
19321996

1933-
mutex_parent: _ArgumentTarget = next(iter(parent_groups)) if parent_groups else parser
19341997
mutex_group = mutex_parent.add_mutually_exclusive_group(required=spec.required)
1935-
for name in member_names:
1998+
for name in spec.members:
19361999
target_for[name] = mutex_group
19372000

19382001

@@ -1983,20 +2046,21 @@ def build_parser_from_function(
19832046
"""
19842047
from . import argparse_utils
19852048

2049+
# The decorator already ran this at decoration time; direct callers get the same checks here.
2050+
_validate_group_specs(func, skip_params=skip_params, groups=groups, mutually_exclusive_groups=mutually_exclusive_groups)
2051+
19862052
parser_cls = parser_class or argparse_utils.DEFAULT_ARGUMENT_PARSER
19872053
if "description" not in parser_kwargs:
19882054
auto_description = _docstring_first_paragraph(func.__doc__)
19892055
if auto_description is not None:
19902056
parser_kwargs["description"] = auto_description
19912057
parser = parser_cls(**parser_kwargs)
19922058

1993-
# _resolve_parameters validates each argument and the cross-argument/cross-config rules (e.g. a
1994-
# variable-arity positional must be last; double-assignment and required-mutex-member) once the
1995-
# whole list is built and the group memberships are linked.
2059+
# _resolve_parameters validates each argument and the cross-argument rules (e.g. a variable-arity
2060+
# positional must be last; a required member in a mutex group) once the whole list is built.
19962061
resolved = _resolve_parameters(
19972062
func,
19982063
skip_params=skip_params,
1999-
groups=groups,
20002064
mutually_exclusive_groups=mutually_exclusive_groups,
20012065
)
20022066

@@ -2011,7 +2075,7 @@ def build_parser_from_function(
20112075
f"signature is expected at invocation. Drop argument_default=argparse.SUPPRESS."
20122076
)
20132077

2014-
# Build the group lookup (member references already validated by _resolve_parameters).
2078+
# Build the group lookup (specs already validated by _validate_group_specs above).
20152079
target_for, argument_group_for = _build_argument_group_targets(parser, groups=groups)
20162080
_apply_mutex_group_targets(
20172081
parser,
@@ -2116,9 +2180,19 @@ def _build_subcommand_handler(
21162180
"""
21172181
subcmd_name = _derive_subcommand_name(func, subcommand_to)
21182182

2183+
# Validate the group specs eagerly (decoration time) so a misconfigured group hard-fails when
2184+
# the class is defined; the name-only checks never resolve type hints, so forward-referenced
2185+
# annotations still decorate and the parser build stays deferred.
2186+
_validate_group_specs(
2187+
func,
2188+
skip_params=_SKIP_PARAMS,
2189+
groups=options.groups,
2190+
mutually_exclusive_groups=options.mutually_exclusive_groups,
2191+
)
21192192
if base_command:
21202193
# Validate eagerly (decoration time); the base-command rows in _CONSTRAINTS fire here.
2121-
_resolve_parameters(func, base_command=True)
2194+
# skip_params is spelled out so this call cannot silently diverge from the parser build below.
2195+
_resolve_parameters(func, skip_params=_SKIP_PARAMS, base_command=True)
21222196

21232197
_accepted = set(list(inspect.signature(func).parameters.keys())[1:])
21242198
_leading_names, _var_positional_name = _var_positional_call_plan(func)
@@ -2291,6 +2365,15 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
22912365
command_name = fn.__name__[len(constants.COMMAND_FUNC_PREFIX) :]
22922366

22932367
skip_params = _SKIP_PARAMS | ({"_unknown"} if with_unknown_args else frozenset())
2368+
# Validate the group specs eagerly (decoration time) so a misconfigured group hard-fails when
2369+
# the class is defined; the name-only checks never resolve type hints, so forward-referenced
2370+
# annotations still decorate and the parser build stays deferred.
2371+
_validate_group_specs(
2372+
fn,
2373+
skip_params=skip_params,
2374+
groups=options.groups,
2375+
mutually_exclusive_groups=options.mutually_exclusive_groups,
2376+
)
22942377
if base_command:
22952378
# Validate eagerly (decoration time); the base-command rows in _CONSTRAINTS fire here.
22962379
_resolve_parameters(fn, skip_params=skip_params, base_command=True)

0 commit comments

Comments
 (0)