Skip to content

Commit 45cc118

Browse files
tleonhardtKelvinChung2000
authored andcommitted
Fix with_annotated decorator so using ArgumentBlock works with groups
1 parent 8c036f7 commit 45cc118

2 files changed

Lines changed: 56 additions & 5 deletions

File tree

‎cmd2/annotated.py‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,6 +1126,7 @@ def __init__(
11261126
kind: inspect._ParameterKind,
11271127
is_base_command: bool,
11281128
is_block_field: bool = False,
1129+
block_param_name: str | None = None,
11291130
dest_override: str | None = None,
11301131
) -> None:
11311132
# signature-derived inputs (never emitted):
@@ -1134,6 +1135,7 @@ def __init__(
11341135
self.has_default = has_default
11351136
self.param_default = param_default # the function's own default, not the argparse `default` slot
11361137
self.is_block_field = is_block_field
1138+
self.block_param_name = block_param_name
11371139
self.dest_override = dest_override
11381140
self.is_kw_only = is_kw_only
11391141
self.is_variadic = is_variadic
@@ -2074,19 +2076,24 @@ def _shared_field_dest(dc_type: type, field_name: str) -> str:
20742076

20752077
def _link_mutex_group_membership(
20762078
by_name: dict[str, _ArgparseArgument],
2079+
by_block_param_name: dict[str, list[_ArgparseArgument]],
20772080
mutually_exclusive_groups: tuple[Group, ...] | None,
20782081
) -> None:
20792082
"""Append each mutex group's 1-based index to its member arguments' ``mutex_group_indices``.
20802083
20812084
This membership is the fact behind the required-member constraint row. Member references are
20822085
validated upstream by :func:`_validate_group_specs` before this runs, so every member name
2083-
resolves to a built argument.
2086+
resolves to a built argument or block parameter.
20842087
"""
20852088
if not mutually_exclusive_groups:
20862089
return
20872090
for index, spec in enumerate(mutually_exclusive_groups, start=1):
20882091
for name in spec.members:
2089-
by_name[name].mutex_group_indices.append(index)
2092+
if name in by_block_param_name:
2093+
for arg in by_block_param_name[name]:
2094+
arg.mutex_group_indices.append(index)
2095+
else:
2096+
by_name[name].mutex_group_indices.append(index)
20902097

20912098

20922099
def _resolve_func_hints(func: Callable[..., Any], *, skip_params: frozenset[str] = _SKIP_PARAMS) -> dict[str, Any]:
@@ -2184,6 +2191,7 @@ def _expand_dataclass_block(
21842191
*,
21852192
func_qualname: str,
21862193
base_command: bool,
2194+
block_param_name: str,
21872195
shared: bool = False,
21882196
) -> list[_ArgparseArgument]:
21892197
"""Expand a dataclass block's ``init`` fields into flat ``_ArgparseArgument`` builders.
@@ -2247,6 +2255,7 @@ def _expand_dataclass_block(
22472255
kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
22482256
is_base_command=base_command,
22492257
is_block_field=True,
2258+
block_param_name=block_param_name,
22502259
dest_override=_shared_field_dest(dc_type, f.name) if shared else None,
22512260
)
22522261
)
@@ -2413,7 +2422,11 @@ def _resolve_parameters(
24132422
# Expand first so its @dataclass-ness check runs before we read field names for the next guard.
24142423
# A cmd2_base_args block is shared down the chain: its fields use a type-qualified dest.
24152424
expanded = _expand_dataclass_block(
2416-
block_hint, func_qualname=func.__qualname__, base_command=base_command, shared=name == NS_ATTR_BASE_ARGS
2425+
block_hint,
2426+
func_qualname=func.__qualname__,
2427+
base_command=base_command,
2428+
block_param_name=name,
2429+
shared=name == NS_ATTR_BASE_ARGS,
24172430
)
24182431
_reject_field_shadowing_block_param(block_hint, name, func.__qualname__)
24192432
resolved.extend(expanded)
@@ -2480,7 +2493,11 @@ def _resolve_parameters(
24802493
for arg in positionals[:-1]: # every positional except the last has a following positional
24812494
arg.has_following_positional = True
24822495
by_name = {arg.name: arg for arg in resolved}
2483-
_link_mutex_group_membership(by_name, mutually_exclusive_groups)
2496+
by_block_param_name: dict[str, list[_ArgparseArgument]] = {}
2497+
for arg in resolved:
2498+
if arg.block_param_name is not None:
2499+
by_block_param_name.setdefault(arg.block_param_name, []).append(arg)
2500+
_link_mutex_group_membership(by_name, by_block_param_name, mutually_exclusive_groups)
24842501
for arg in resolved:
24852502
arg._check_constraints()
24862503
return resolved, base_args_types
@@ -2760,7 +2777,10 @@ def build_parser_from_function(
27602777

27612778
# Add each argument to its target (its group/mutex group if assigned, else the parser).
27622779
for arg in resolved:
2763-
arg.add_to(target_for.get(arg.name, parser))
2780+
target = target_for.get(arg.name)
2781+
if target is None and arg.block_param_name is not None:
2782+
target = target_for.get(arg.block_param_name)
2783+
arg.add_to(target if target is not None else parser)
27642784

27652785
# A cmd2_base_args block is inheritable: stamp a parse-time presence marker so a descendant's
27662786
# cmd2_parent_args can confirm an ancestor actually declared the block.

‎tests/test_annotated.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1193,6 +1193,37 @@ def func(self, src: str, dst: str, recursive: bool = False, verbose: bool = Fals
11931193
all_custom_dests = {a.dest for g in custom_groups for a in g._group_actions}
11941194
assert {"src", "dst"} <= all_custom_dests
11951195

1196+
def test_group_with_argument_block(self) -> None:
1197+
"""An ArgumentBlock in a Group expands to all its fields."""
1198+
1199+
@dataclass
1200+
class MyBlock(ArgumentBlock):
1201+
host: Annotated[str, Option("--host")] = "localhost"
1202+
port: Annotated[int, Option("--port")] = 8080
1203+
1204+
def func(self, block: MyBlock, verbose: bool = False) -> None: ...
1205+
1206+
parser = build_parser_from_function(func, groups=(Group("block", title="Connection"),))
1207+
custom_groups = [g for g in parser._action_groups if g.title == "Connection"]
1208+
assert len(custom_groups) == 1
1209+
dests = {a.dest for a in custom_groups[0]._group_actions}
1210+
assert dests == {"host", "port"}
1211+
1212+
def test_mutex_group_with_argument_block(self) -> None:
1213+
"""An ArgumentBlock in a MutuallyExclusiveGroup expands to all its fields."""
1214+
1215+
@dataclass
1216+
class MyBlock(ArgumentBlock):
1217+
fast: Annotated[bool, Option("--fast")] = False
1218+
slow: Annotated[bool, Option("--slow")] = False
1219+
1220+
def func(self, block: MyBlock, verbose: bool = False) -> None: ...
1221+
1222+
parser = build_parser_from_function(func, mutually_exclusive_groups=(Group("block"),))
1223+
assert len(parser._mutually_exclusive_groups) == 1
1224+
dests = {a.dest for a in parser._mutually_exclusive_groups[0]._group_actions}
1225+
assert dests == {"fast", "slow"}
1226+
11961227
def test_mutually_exclusive_via_decorator(self) -> None:
11971228
"""@with_annotated(mutually_exclusive_groups=...) works end-to-end."""
11981229

0 commit comments

Comments
 (0)