@@ -1126,13 +1126,15 @@ def __init__(
11261126 kind : inspect ._ParameterKind ,
11271127 is_base_command : bool ,
11281128 is_block_field : bool = False ,
1129+ dest_override : str | None = None ,
11291130 ) -> None :
11301131 # signature-derived inputs (never emitted):
11311132 self .name = name
11321133 self .func_qualname = func_qualname
11331134 self .has_default = has_default
11341135 self .param_default = param_default # the function's own default, not the argparse `default` slot
11351136 self .is_block_field = is_block_field
1137+ self .dest_override = dest_override
11361138 self .is_kw_only = is_kw_only
11371139 self .is_variadic = is_variadic
11381140 self .inner_type = inner_type # peeled type (after Annotated + Optional)
@@ -1332,6 +1334,15 @@ def _is_inferred_bool_flag(self) -> bool:
13321334 """Whether this is a bool option using the inferred ``BooleanOptionalAction`` (no explicit action)."""
13331335 return self .action is argparse .BooleanOptionalAction
13341336
1337+ @property
1338+ def _consumes_value (self ) -> bool :
1339+ """Whether this option takes a command-line value (so argparse derives/shows a metavar for it).
1340+
1341+ A flag-style action (``store_true``/``count``/``BooleanOptionalAction``/...) takes none, so it has
1342+ no metavar -- and some of those actions even reject a ``metavar`` kwarg.
1343+ """
1344+ return not self ._is_inferred_bool_flag and not (self ._policy is not None and self ._policy .drop_converter )
1345+
13351346 def _apply (self ) -> None :
13361347 """Build this argument by deriving each output slot."""
13371348 self .is_positional = _first_match (_ROLE_RULES , self )
@@ -1495,9 +1506,14 @@ def _emit(self) -> tuple[tuple[Any, ...], dict[str, Any]]:
14951506 kwargs ["default" ] = self .default
14961507 if self .required :
14971508 kwargs ["required" ] = True
1509+ dest = self .dest_override or self .name
14981510 if self .is_positional :
1499- return (self .name ,), kwargs
1500- kwargs ["dest" ] = self .name
1511+ if self .dest_override is not None :
1512+ kwargs .setdefault ("metavar" , self .name )
1513+ return (dest ,), kwargs
1514+ if self .dest_override is not None and self ._consumes_value :
1515+ kwargs .setdefault ("metavar" , self .name .upper ())
1516+ kwargs ["dest" ] = dest
15011517 return tuple (self .flags ), kwargs
15021518
15031519 def add_to (self , target : _ArgumentTarget ) -> None :
@@ -1939,6 +1955,18 @@ def _const_mismatches_type(a: _ArgparseArgument) -> bool:
19391955 f"annotate the type as '{ _type_name (a .inner_type )} | None'."
19401956 ),
19411957 ),
1958+ (
1959+ # A block field's default must live on the dataclass field: a field default emits SUPPRESS (see
1960+ # _DEFAULT_RULES) so the dataclass constructor produces the value fresh on every call.
1961+ lambda a : a .is_block_field and not a .has_default and a .default is not _UNSET and a .default is not None ,
1962+ lambda a : TypeError (
1963+ f"ArgumentBlock field '{ a .name } ' in { a .func_qualname } would take its default ({ a .default !r} ) from "
1964+ f"the option metadata or its action, but a block field's default must live on the dataclass field "
1965+ f"so the constructor produces it fresh on every call. Put it on the field instead -- a plain "
1966+ f"default for an immutable value (e.g. '{ a .name } : ... = False'), or 'field(default_factory=...)' "
1967+ f"for a mutable one (e.g. '= field(default_factory=list)')."
1968+ ),
1969+ ),
19421970 (
19431971 lambda a : (
19441972 a ._effective_has_default
@@ -2028,13 +2056,22 @@ def _base_args_marker_dest(dc_type: type) -> str:
20282056 """Namespace dest of the parse-time presence marker for a ``cmd2_base_args`` block of *dc_type*.
20292057
20302058 Keyed by ``id()`` of the block type: ``cmd2_base_args`` and ``cmd2_parent_args`` referencing the same
2031- block class share the exact type object, so the dest a parent stamps matches the one a child checks.
2032- Distinct types get distinct dests, so multiple inheritable blocks in one command chain never collide
2059+ block class share the exact type object, so the dest a parent stamps matches the one a child checks,
20332060 and a child inheriting the wrong type sees its marker absent (a reliable mismatch error).
20342061 """
20352062 return constants .cmd2_private_attr_name (f"base_args_{ id (dc_type ):x} " )
20362063
20372064
2065+ def _shared_field_dest (dc_type : type , field_name : str ) -> str :
2066+ """Namespace dest for a shared-block (``cmd2_base_args``/``cmd2_parent_args``) field, qualified by type.
2067+
2068+ Keyed by ``id()`` of the block type like the presence marker, so a parent's ``cmd2_base_args`` and a
2069+ child's ``cmd2_parent_args`` of the same class agree on the dest while two different block types that
2070+ share a field name get distinct dests -- they never collide on one attribute in the shared namespace.
2071+ """
2072+ return constants .cmd2_private_attr_name (f"shared_{ id (dc_type ):x} _{ field_name } " )
2073+
2074+
20382075def _link_mutex_group_membership (
20392076 by_name : dict [str , _ArgparseArgument ],
20402077 mutually_exclusive_groups : tuple [Group , ...] | None ,
@@ -2128,17 +2165,36 @@ def _init_field_names(dc_type: type) -> list[str]:
21282165 return [f .name for f in fields (dc_type ) if f .init ]
21292166
21302167
2168+ def _reject_field_shadowing_block_param (dc_type : type , param_name : str , func_qualname : str ) -> None :
2169+ """Reject a block whose field name equals the block parameter name.
2170+
2171+ The field's flat argument and the parameter that receives the block instance would share one
2172+ keyword-argument key, so a parsed value and a directly-supplied instance become indistinguishable.
2173+ """
2174+ if param_name in _init_field_names (dc_type ):
2175+ raise TypeError (
2176+ f"ArgumentBlock '{ dc_type .__name__ } ' field { param_name !r} collides with the block parameter "
2177+ f"name '{ param_name } ' in { func_qualname } ; rename the field so its command-line argument does not "
2178+ f"shadow the parameter that receives the block instance."
2179+ )
2180+
2181+
21312182def _expand_dataclass_block (
21322183 dc_type : type ,
21332184 * ,
21342185 func_qualname : str ,
21352186 base_command : bool ,
2187+ shared : bool = False ,
21362188) -> list [_ArgparseArgument ]:
21372189 """Expand a dataclass block's ``init`` fields into flat ``_ArgparseArgument`` builders.
21382190
21392191 Each field maps to one argument named after the field (flat: field name == argument name). The
21402192 field's ``Annotated[T, Option/Argument]`` metadata and its default (``default`` or ``default_factory``)
21412193 drive the argument exactly as a top-level parameter of the same shape would.
2194+
2195+ ``shared`` marks a ``cmd2_base_args`` block: its fields parse into a type-qualified dest (the flag is
2196+ unchanged) so blocks of different types never collide on one attribute in the shared subcommand
2197+ namespace; the matching ``cmd2_parent_args`` reads the same qualified dest.
21422198 """
21432199 if not is_dataclass (dc_type ):
21442200 raise TypeError (
@@ -2191,6 +2247,7 @@ def _expand_dataclass_block(
21912247 kind = inspect .Parameter .POSITIONAL_OR_KEYWORD ,
21922248 is_base_command = base_command ,
21932249 is_block_field = True ,
2250+ dest_override = _shared_field_dest (dc_type , f .name ) if shared else None ,
21942251 )
21952252 )
21962253 return expanded
@@ -2202,6 +2259,12 @@ class _BlockSpec(NamedTuple):
22022259 dc_type : type
22032260 field_names : list [str ]
22042261 inherited : bool # True for a cmd2_parent_args block (inherited from an ancestor's cmd2_base_args)
2262+ shared : bool = False # cmd2_base_args/cmd2_parent_args: fields live under a type-qualified dest
2263+
2264+
2265+ def _block_field_dest (spec : _BlockSpec , field_name : str ) -> str :
2266+ """Namespace dest a block field lands under: type-qualified for a shared block, else the field name."""
2267+ return _shared_field_dest (spec .dc_type , field_name ) if spec .shared else field_name
22052268
22062269
22072270def _dataclass_blocks (func : Callable [..., Any ], * , skip_params : frozenset [str ] = _SKIP_PARAMS ) -> dict [str , _BlockSpec ]:
@@ -2218,7 +2281,12 @@ def _dataclass_blocks(func: Callable[..., Any], *, skip_params: frozenset[str] =
22182281 continue
22192282 if _is_argument_block (hints .get (name ), param ):
22202283 hint = hints [name ]
2221- blocks [name ] = _BlockSpec (hint , _init_field_names (hint ), inherited = name == NS_ATTR_PARENT_ARGS )
2284+ blocks [name ] = _BlockSpec (
2285+ hint ,
2286+ _init_field_names (hint ),
2287+ inherited = name == NS_ATTR_PARENT_ARGS ,
2288+ shared = name in (NS_ATTR_BASE_ARGS , NS_ATTR_PARENT_ARGS ),
2289+ )
22222290 return blocks
22232291
22242292
@@ -2242,7 +2310,7 @@ def resolve() -> tuple[dict[str, _BlockSpec], set[str]]:
22422310 accepted = set (base_accepted )
22432311 for block_name , spec in blocks .items ():
22442312 accepted .discard (block_name )
2245- accepted .update (spec .field_names )
2313+ accepted .update (_block_field_dest ( spec , name ) for name in spec .field_names )
22462314 return blocks , accepted
22472315
22482316 return resolve
@@ -2260,11 +2328,13 @@ def _reconstruct_dataclass_blocks(func_kwargs: dict[str, Any], blocks: dict[str,
22602328 all-defaults instance and hide the misconfiguration, so raise instead.
22612329 """
22622330 for block_name , spec in blocks .items ():
2263- # The expanded field values always come out of func_kwargs -- they are not parameters of the command
2264- # function (the block instance is), so leaving them in would crash the call with an unexpected keyword.
2265- # Pop them up front, before the already-supplied/inherited checks, so a directly-supplied instance does
2266- # not strand command-line field values in func_kwargs.
2267- field_values = {name : func_kwargs .pop (name ) for name in spec .field_names if name in func_kwargs }
2331+ # The expanded field values always come out of func_kwargs (keyed by their namespace dest, which is
2332+ # type-qualified for a shared block)
2333+ field_values = {
2334+ field : func_kwargs .pop (dest )
2335+ for field in spec .field_names
2336+ if (dest := _block_field_dest (spec , field )) in func_kwargs
2337+ }
22682338 if block_name in func_kwargs :
22692339 # A block instance is already present (e.g. a programmatic call passing it directly); use it as-is.
22702340 continue
@@ -2321,6 +2391,7 @@ def _resolve_parameters(
23212391 block_hint = hints .get (name )
23222392 if name == NS_ATTR_PARENT_ARGS :
23232393 inherited = _require_magic_block (name , block_hint , param , func .__qualname__ )
2394+ _reject_field_shadowing_block_param (inherited , name , func .__qualname__ )
23242395 if param .default is not inspect .Parameter .empty :
23252396 raise TypeError (
23262397 f"Parameter '{ name } ' in { func .__qualname__ } inherits its block from a parent command and "
@@ -2339,7 +2410,13 @@ def _resolve_parameters(
23392410 )
23402411 if name == NS_ATTR_BASE_ARGS :
23412412 base_args_types .add (block_hint )
2342- resolved .extend (_expand_dataclass_block (block_hint , func_qualname = func .__qualname__ , base_command = base_command ))
2413+ # Expand first so its @dataclass-ness check runs before we read field names for the next guard.
2414+ # A cmd2_base_args block is shared down the chain: its fields use a type-qualified dest.
2415+ expanded = _expand_dataclass_block (
2416+ block_hint , func_qualname = func .__qualname__ , base_command = base_command , shared = name == NS_ATTR_BASE_ARGS
2417+ )
2418+ _reject_field_shadowing_block_param (block_hint , name , func .__qualname__ )
2419+ resolved .extend (expanded )
23432420 continue
23442421 # The magic name requires a bare ArgumentBlock; a non-block annotation on it is a clear mistake.
23452422 if name == NS_ATTR_BASE_ARGS :
@@ -2685,7 +2762,8 @@ def build_parser_from_function(
26852762 for arg in resolved :
26862763 arg .add_to (target_for .get (arg .name , parser ))
26872764
2688- # A cmd2_base_args block is inheritable: stamp a parse-time presence marker
2765+ # A cmd2_base_args block is inheritable: stamp a parse-time presence marker so a descendant's
2766+ # cmd2_parent_args can confirm an ancestor actually declared the block.
26892767 for base_args_type in base_args_types :
26902768 parser .set_defaults (** {_base_args_marker_dest (base_args_type ): True })
26912769
0 commit comments