Skip to content

Commit 1b5498b

Browse files
fix: fix edge case
1 parent 88a3c81 commit 1b5498b

3 files changed

Lines changed: 271 additions & 13 deletions

File tree

cmd2/annotated.py

Lines changed: 91 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
20382075
def _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+
21312182
def _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

22072270
def _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

docs/features/annotated.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,13 @@ A few rules keep blocks unambiguous:
662662
`Annotated`/`Optional`/a union, or using it as `*args`/`**kwargs`, raises a clear error.
663663
- Because fields expand flat, a field name that collides with another parameter or another block's
664664
field raises an error when the parser is built, rather than silently sharing a destination.
665+
- A field name that matches the block parameter itself (e.g. `opts` on a block received as
666+
`opts: Opts`) is rejected: the field's flat argument and the parameter that receives the block
667+
instance would share one destination.
668+
- A field's default must live on the dataclass field (`= value` or `= field(default_factory=...)`),
669+
not in the `Option`/`Argument` metadata or via an action default (`append`/`count`). The dataclass
670+
owns defaults so each call gets a fresh value, so a metadata- or action-supplied default on a
671+
field that has no dataclass default is rejected.
665672
- A field whose type is itself a block is not expanded (no recursion); it is rejected as an
666673
unsupported type.
667674

@@ -697,6 +704,12 @@ the values an ancestor parsed (`root --verbose show`, not `root show --verbose`)
697704
clear error the first time it runs. This is the typed alternative to forwarding parent-level state
698705
through `ns_provider`.
699706

707+
Each shared block field parses into a destination qualified by its block type, so two
708+
`cmd2_base_args` blocks of different types at different levels of one chain can use the same field
709+
name without colliding. An inherited block always receives its own type's value, regardless of what
710+
a same-named field on another block at an intervening level parsed. Reusing the same block type at
711+
two levels shares one destination, so the nearest level that sets the flag wins.
712+
700713
A subcommand can also declare its own regular block alongside the inherited one. The two are
701714
independent: the inherited block's flags are supplied on the parent, while the subcommand's own
702715
block adds its flags to the subcommand's parser.

0 commit comments

Comments
 (0)