@@ -175,9 +175,11 @@ def do_build(self, target: str, common: CommonArgs):
175175argument. A field whose type is itself a block is not expanded (no recursion) and raises the
176176unsupported-type error. Because fields expand flat, every field name must be unique across the command's
177177own parameters and every other block's fields -- a collision (which would put two argparse actions on one
178- namespace dest) raises ``TypeError`` when the parser is built. Block fields cannot participate in
179- ``groups=`` / ``mutually_exclusive_groups=`` (those reference the function's own parameter names, which are
180- validated without resolving type hints). A block must be the *bare* annotation of a regular parameter:
178+ namespace dest) raises ``TypeError`` when the parser is built. Block fields can participate in
179+ ``groups=`` / ``mutually_exclusive_groups=``: a group names arguments, and because a block expands to one
180+ argument per field, you name its *field* names. Since resolving those field names needs the block's
181+ type hints, this membership is checked when the parser is built rather than at decoration time. A block
182+ must be the *bare* annotation of a regular parameter:
181183wrapping it in ``Annotated``/``Optional``/a union, or using it as ``*args`` / ``**kwargs``, raises
182184``TypeError`` (to use a dataclass as a single value instead, make it a plain ``@dataclass`` with an
183185``Argument(converter=...)``). An ``ArgumentBlock`` subclass that is not a ``@dataclass`` has no fields and
@@ -552,25 +554,20 @@ def __init__(
552554 ) -> None :
553555 """Initialise an argument group definition.
554556
555- :param members: parameter names to place in the group (at least one)
557+ :param members: argument names to place in the group (at least one). A plain parameter's name is its
558+ argument name; for an ``ArgumentBlock`` name its fields.
556559 :param title: group title shown as a help section header
557560 :param description: group description shown under the title
558561 :param required: ``mutually_exclusive_groups`` only -- require exactly one member.
559562 On a ``groups=`` entry it raises ``ValueError``.
560563 """
561564 if not members :
562- raise ValueError ("Group requires at least one member parameter name" )
565+ raise ValueError ("Group requires at least one member argument name" )
563566 self .members = members
564567 self .title = title
565568 self .description = description
566569 self .required = required
567570
568- def _validate_members (self , * , all_param_names : set [str ], group_type : str ) -> None :
569- """Validate that every referenced member parameter exists."""
570- for name in self .members :
571- if name not in all_param_names :
572- raise ValueError (f"{ group_type } references nonexistent parameter { name !r} " )
573-
574571
575572#: Metadata extracted from ``Annotated[T, meta]``, or ``None`` for plain types.
576573ArgMetadata = Argument | Option | None
@@ -2072,15 +2069,47 @@ def _shared_field_dest(dc_type: type, field_name: str) -> str:
20722069 return constants .cmd2_private_attr_name (f"shared_{ id (dc_type ):x} _{ field_name } " )
20732070
20742071
2072+ def _check_group_members_exist (
2073+ by_name : dict [str , _ArgparseArgument ],
2074+ block_param_names : set [str ],
2075+ func_qualname : str ,
2076+ * specs : tuple [Group , ...] | None ,
2077+ ) -> None :
2078+ """Reject a ``Group`` member that names no command-line argument, once every argument is built.
2079+
2080+ :func:`_validate_group_specs` cannot do this at decoration time: an ArgumentBlock's arguments are its
2081+ dataclass fields, which only resolving the block's type hint would reveal, and resolving hints there
2082+ would break forward-referenced annotations. So this is where a bad member name is finally caught.
2083+
2084+ Naming the block parameter itself is the likely mistake -- it reads natural but the parameter is expanded
2085+ away and has no argument -- so it gets its own message pointing at the fields to name instead.
2086+ """
2087+ for spec_group in specs :
2088+ for spec in spec_group or ():
2089+ for name in spec .members :
2090+ if name in by_name :
2091+ continue
2092+ if name in block_param_names :
2093+ raise ValueError (
2094+ f"group in { func_qualname } references { name !r} , which is an ArgumentBlock parameter "
2095+ f"and not a command-line argument: a block is expanded into one argument per field, "
2096+ f"so name those fields instead."
2097+ )
2098+ raise ValueError (
2099+ f"group in { func_qualname } references { name !r} , which is not a command-line argument. "
2100+ f"Members name arguments: a plain parameter is its own argument, and an ArgumentBlock's "
2101+ f"arguments are its field names."
2102+ )
2103+
2104+
20752105def _link_mutex_group_membership (
20762106 by_name : dict [str , _ArgparseArgument ],
20772107 mutually_exclusive_groups : tuple [Group , ...] | None ,
20782108) -> None :
20792109 """Append each mutex group's 1-based index to its member arguments' ``mutex_group_indices``.
20802110
20812111 This membership is the fact behind the required-member constraint row. Member references are
2082- validated upstream by :func:`_validate_group_specs` before this runs, so every member name
2083- resolves to a built argument.
2112+ resolved to built arguments upstream by :func:`_check_group_members_exist` before this runs.
20842113 """
20852114 if not mutually_exclusive_groups :
20862115 return
@@ -2352,6 +2381,7 @@ def _resolve_parameters(
23522381 * ,
23532382 skip_params : frozenset [str ] = _SKIP_PARAMS ,
23542383 base_command : bool = False ,
2384+ groups : tuple [Group , ...] | None = None ,
23552385 mutually_exclusive_groups : tuple [Group , ...] | None = None ,
23562386) -> tuple [list [_ArgparseArgument ], set [type ]]:
23572387 """Resolve a function signature into argparse-argument builders and inheritable-block types.
@@ -2378,6 +2408,7 @@ def _resolve_parameters(
23782408 resolved : list [_ArgparseArgument ] = []
23792409 inherited_field_names : set [str ] = set ()
23802410 base_args_types : set [type ] = set ()
2411+ block_param_names : set [str ] = set ()
23812412
23822413 # Skip the first parameter by position (self/cls for methods)
23832414 params = list (sig .parameters .items ())
@@ -2398,6 +2429,7 @@ def _resolve_parameters(
23982429 f"cannot have a default value; remove the default."
23992430 )
24002431 inherited_field_names .update (_init_field_names (inherited ))
2432+ block_param_names .add (name )
24012433 continue
24022434
24032435 # An ArgumentBlock-typed parameter is an argument block: expand its fields in place (flat) instead of
@@ -2416,6 +2448,7 @@ def _resolve_parameters(
24162448 block_hint , func_qualname = func .__qualname__ , base_command = base_command , shared = name == NS_ATTR_BASE_ARGS
24172449 )
24182450 _reject_field_shadowing_block_param (block_hint , name , func .__qualname__ )
2451+ block_param_names .add (name )
24192452 resolved .extend (expanded )
24202453 continue
24212454 # The magic name requires a bare ArgumentBlock; a non-block annotation on it is a clear mistake.
@@ -2480,6 +2513,7 @@ def _resolve_parameters(
24802513 for arg in positionals [:- 1 ]: # every positional except the last has a following positional
24812514 arg .has_following_positional = True
24822515 by_name = {arg .name : arg for arg in resolved }
2516+ _check_group_members_exist (by_name , block_param_names , func .__qualname__ , groups , mutually_exclusive_groups )
24832517 _link_mutex_group_membership (by_name , mutually_exclusive_groups )
24842518 for arg in resolved :
24852519 arg ._check_constraints ()
@@ -2539,30 +2573,22 @@ def _filtered_namespace_kwargs(
25392573
25402574
25412575def _validate_group_specs (
2542- func : Callable [..., Any ],
2543- * ,
2544- skip_params : frozenset [str ],
25452576 groups : tuple [Group , ...] | None ,
25462577 mutually_exclusive_groups : tuple [Group , ...] | None ,
25472578) -> None :
2548- """Validate ``groups=`` / ``mutually_exclusive_groups=`` specs from parameter names alone.
2549-
2550- Runs at decoration time (from both the regular-command and subcommand decoration paths, and
2551- again from :func:`build_parser_from_function` for direct callers), so a misconfigured group
2552- hard-fails when the class is defined instead of on first command use, where cmd2's runtime
2553- handler turns the error into a printed message. Reads only parameter names and the ``Group``
2554- specs -- never the type hints -- so forward-referenced annotations still decorate. The one
2555- group rule that needs the annotations (a required member in a mutually exclusive group) stays
2556- in :data:`_CONSTRAINTS` and fires when the parser is built.
2579+ """Validate the *shape* of ``groups=`` / ``mutually_exclusive_groups=`` specs.
2580+
2581+ Runs at decoration time (from both the regular-command and subcommand decoration paths, and again from
2582+ :func:`build_parser_from_function` for direct callers), so a misshapen group hard-fails when the class is
2583+ defined instead of on first command use, where cmd2's runtime handler turns the error into a printed
2584+ message. Reads only the ``Group`` specs -- never the signature or the type hints -- so
2585+ forward-referenced annotations still decorate.
25572586 """
25582587 if not groups and not mutually_exclusive_groups :
25592588 return
2560- params = list (inspect .signature (func ).parameters )[1 :] # skip self/cls by position
2561- all_param_names = {name for name in params if name not in skip_params }
25622589
25632590 group_entry_for : dict [str , int ] = {}
25642591 for index , spec in enumerate (groups or (), start = 1 ):
2565- spec ._validate_members (all_param_names = all_param_names , group_type = "groups" )
25662592 if spec .required :
25672593 raise ValueError (
25682594 "Group(required=True) is only valid in mutually_exclusive_groups; "
@@ -2580,7 +2606,6 @@ def _validate_group_specs(
25802606
25812607 mutex_entry_for : dict [str , int ] = {}
25822608 for index , spec in enumerate (mutually_exclusive_groups or (), start = 1 ):
2583- spec ._validate_members (all_param_names = all_param_names , group_type = "mutually_exclusive_groups" )
25842609 for name in spec .members :
25852610 previous = mutex_entry_for .get (name )
25862611 if previous == index :
@@ -2721,7 +2746,7 @@ def build_parser_from_function(
27212746 from . import argparse_utils
27222747
27232748 # The decorator already ran this at decoration time; direct callers get the same checks here.
2724- _validate_group_specs (func , skip_params = skip_params , groups = groups , mutually_exclusive_groups = mutually_exclusive_groups )
2749+ _validate_group_specs (groups , mutually_exclusive_groups )
27252750
27262751 parser_cls = parser_class or argparse_utils .DEFAULT_ARGUMENT_PARSER
27272752 if "description" not in parser_kwargs :
@@ -2735,6 +2760,7 @@ def build_parser_from_function(
27352760 resolved , base_args_types = _resolve_parameters (
27362761 func ,
27372762 skip_params = skip_params ,
2763+ groups = groups ,
27382764 mutually_exclusive_groups = mutually_exclusive_groups ,
27392765 )
27402766
@@ -2864,12 +2890,7 @@ def _build_subcommand_handler(
28642890 # Validate the group specs eagerly (decoration time) so a misconfigured group hard-fails when
28652891 # the class is defined; the name-only checks never resolve type hints, so forward-referenced
28662892 # annotations still decorate and the parser build stays deferred.
2867- _validate_group_specs (
2868- func ,
2869- skip_params = _SKIP_PARAMS ,
2870- groups = options .groups ,
2871- mutually_exclusive_groups = options .mutually_exclusive_groups ,
2872- )
2893+ _validate_group_specs (options .groups , options .mutually_exclusive_groups )
28732894 if base_command :
28742895 # Validate eagerly (decoration time); the base-command rows in _CONSTRAINTS fire here.
28752896 # skip_params is spelled out so this call cannot silently diverge from the parser build below.
@@ -3053,12 +3074,7 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
30533074 # Validate the group specs eagerly (decoration time) so a misconfigured group hard-fails when
30543075 # the class is defined; the name-only checks never resolve type hints, so forward-referenced
30553076 # annotations still decorate and the parser build stays deferred.
3056- _validate_group_specs (
3057- fn ,
3058- skip_params = skip_params ,
3059- groups = options .groups ,
3060- mutually_exclusive_groups = options .mutually_exclusive_groups ,
3061- )
3077+ _validate_group_specs (options .groups , options .mutually_exclusive_groups )
30623078 if base_command :
30633079 # Validate eagerly (decoration time); the base-command rows in _CONSTRAINTS fire here.
30643080 _resolve_parameters (fn , skip_params = skip_params , base_command = True )
0 commit comments