@@ -127,7 +127,20 @@ def do_paint(
127127leaking ``:param:`` directives; and ``prog`` is rejected with ``subcommand_to`` because cmd2
128128rewrites 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
132145Unsupported 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+
18751941def _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