Skip to content

Commit 1a56c89

Browse files
Fix with_annotated decorator so using ArgumentBlock works with groups (#1718)
* Fix with_annotated decorator so using ArgumentBlock works with groups --------- Co-authored-by: Kelvin Chung <king.chung@manchester.ac.uk>
1 parent 8c036f7 commit 1a56c89

5 files changed

Lines changed: 282 additions & 81 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,28 @@
11
## 4.2.0 (TBD)
22

3+
- Enhancements
4+
- `@with_annotated` argument groups can now contain an `ArgumentBlock`'s arguments. A `Group`
5+
member names a command-line argument, and a block expands into one argument per field, so its
6+
fields are named: `Group("host", "port")`.
7+
- Breaking Changes
8+
- A `Group` member now names an argument rather than a parameter. The two differ only for an
9+
`ArgumentBlock` parameter, which is expanded away and has no argument of its own:
10+
`Group("conn")` now raises `ValueError` pointing at the block's fields. It previously produced
11+
an empty argument group in `groups=` and a `KeyError` in `mutually_exclusive_groups=`.
12+
- Whether a `Group` member exists is now validated when the parser is built rather than at
13+
decoration time, because a block's field names cannot be known without resolving its type
14+
hint, and resolving hints eagerly would break forward-referenced annotations. A typo in a
15+
member name still raises `ValueError`, but on first use of that command rather than at class
16+
definition. The spec-shape rules (a member listed twice, a member in two groups,
17+
`required=True` on a plain group, the mutex nesting rules) are unaffected and still hard-fail
18+
at decoration time.
319
- Bug Fixes
420
- Fixed `@with_annotated(base_command=True)` not listing its subcommands under the positional
521
arguments section of the parent command's `--help`, unlike `argparse` and
622
`Cmd2ArgumentParser`. They were placed in an untitled section of their own instead. Passing
723
`subcommand_title` or `subcommand_description` still gives the subcommands a dedicated section
824
([#1715](https://github.com/python-cmd2/cmd2/issues/1715)).
25+
- Fix `@with_annotated` decorator so using `ArgumentBlock` works with groups
926

1027
## 4.1.2 (July 16, 2026)
1128

cmd2/annotated.py

Lines changed: 58 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,11 @@ def do_build(self, target: str, common: CommonArgs):
175175
argument. A field whose type is itself a block is not expanded (no recursion) and raises the
176176
unsupported-type error. Because fields expand flat, every field name must be unique across the command's
177177
own 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:
181183
wrapping 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.
576573
ArgMetadata = 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+
20752105
def _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

25412575
def _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)

docs/features/annotated.md

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -445,8 +445,8 @@ token to convert. Both raise `TypeError` at decoration time.
445445
- `help` -- help text for an annotated subcommand (only valid with `subcommand_to`)
446446
- `aliases` -- aliases for an annotated subcommand (only valid with `subcommand_to`)
447447
- `deprecated` -- mark the subcommand as deprecated in `--help` (only valid with `subcommand_to`)
448-
- `groups` -- `Group` instances assigning parameter names to argument groups
449-
- `mutually_exclusive_groups` -- `Group` instances of mutually exclusive parameters
448+
- `groups` -- `Group` instances assigning argument names to argument groups
449+
- `mutually_exclusive_groups` -- `Group` instances of mutually exclusive arguments
450450
- `parser_class` -- a custom parser class (defaults to the configured default)
451451
- `**parser_kwargs` -- every other `Cmd2ArgumentParser` constructor kwarg, forwarded through PEP 692
452452
[`Unpack[Cmd2ParserKwargs]`][cmd2.annotated.Cmd2ParserKwargs]. See
@@ -549,13 +549,35 @@ both places, a mutex that sits only partly in a `groups=` entry, or one that spa
549549
raises `ValueError`. The other three nestings (an argument group inside another group or a mutex, or
550550
a mutex inside a mutex) are removed in argparse on Python 3.14 and cannot be expressed here.
551551

552-
All of these group-spec rules -- member references, a parameter assigned to two groups,
552+
A `Group` member names a command-line **argument**. For a plain parameter the argument is the
553+
parameter, so the name is the same either way. An `ArgumentBlock` parameter is different: it is
554+
expanded into one argument per field (field name == argument name), so you name its fields rather
555+
than the block:
556+
557+
```python
558+
@dataclass
559+
class Conn(ArgumentBlock):
560+
host: Annotated[str, Option("--host")] = "localhost"
561+
port: Annotated[int, Option("--port")] = 8080
562+
563+
@with_annotated(groups=(Group("host", "port", title="connection"),))
564+
def do_connect(self, conn: Conn) -> None: ...
565+
```
566+
567+
Naming the block parameter itself (`Group("conn")`) raises `ValueError`: the parameter is expanded
568+
away and has no argument of its own. Naming a block's fields individually is also what lets you say
569+
which fields are alternatives -- put `Group("json", "csv")` in `mutually_exclusive_groups` and only
570+
those two exclude each other, rather than every field of the block they happen to live in.
571+
572+
The group-spec **shape** rules -- a member listed twice, a member assigned to two groups,
553573
`required=True` on a plain group, and the mutex nesting rules above -- are validated when the
554-
decorator runs, so a misconfigured group raises `ValueError` at class definition time instead of on
555-
first command use. The checks read only parameter names, never the type hints, so forward-referenced
556-
annotations still decorate cleanly. The one group rule that depends on the annotations (a member of
557-
a mutually exclusive group must be omittable -- have a default or be `T | None`) fires when the
558-
parser is built.
574+
decorator runs, so a misshapen group raises `ValueError` at class definition time instead of on
575+
first command use. Those checks read only the `Group` specs, never the signature or the type hints,
576+
so forward-referenced annotations still decorate cleanly.
577+
578+
The rules that depend on what a member _is_ fire when the parser is built, because a block's field
579+
names cannot be known without resolving its type hint: that every member names a real argument, and
580+
that a member of a mutually exclusive group is omittable (has a default or is `T | None`).
559581

560582
`parents=` mirrors argparse's standard parents mechanism for sharing argument definitions across
561583
parsers. `argument_default=argparse.SUPPRESS` is not supported and raises `TypeError`. It removes an

examples/annotated_example.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,19 @@ class RunOpts(ArgumentBlock):
116116
dry_run: Annotated[bool, Option("--dry-run", help_text="don't actually run")] = False
117117

118118

119+
@dataclass
120+
class ConnOpts(ArgumentBlock):
121+
"""A block whose fields are placed in an argument group by name.
122+
123+
A ``Group`` names command-line arguments, and a block expands into one argument
124+
per field -- so a group names the block's *fields* (``host``, ``port``), not the
125+
block parameter (``Group("conn")`` is rejected). See ``bind`` below.
126+
"""
127+
128+
host: Annotated[str, Option("--host", help_text="host to connect to")] = "localhost"
129+
port: Annotated[int, Option("--port", help_text="port to connect on")] = 8080
130+
131+
119132
class AnnotatedExample(Cmd):
120133
"""Demonstrates @with_annotated strengths over @with_argparser."""
121134

@@ -641,6 +654,28 @@ def do_connect(self, host: str, port: int = 22, verbose: bool = False) -> None:
641654
msg = f"Connecting to {host}:{port}"
642655
self.poutput(f"{msg} (verbose)" if verbose else msg)
643656

657+
# An ``ArgumentBlock``'s fields can go in a group too: a ``Group`` names the
658+
# arguments a block expands into (its fields), not the block parameter itself.
659+
660+
@with_annotated(
661+
description="Open a connection whose grouped flags come from an argument block.",
662+
groups=(Group("host", "port", title="connection", description="where to connect"),),
663+
)
664+
@cmd2.with_category(ANNOTATED_CATEGORY)
665+
def do_bind(self, conn: ConnOpts, verbose: bool = False) -> None:
666+
"""Group a block's fields: ``Group("host", "port")`` names the ``ConnOpts`` fields.
667+
668+
``--host`` / ``--port`` come from the block yet still render under the ``connection``
669+
help section, because a group names the arguments a block expands into -- not the
670+
``conn`` parameter (naming that, ``Group("conn")``, is rejected).
671+
672+
Try:
673+
help bind
674+
bind --host example.com --port 2222 --verbose
675+
"""
676+
msg = f"Binding to {conn.host}:{conn.port}"
677+
self.poutput(f"{msg} (verbose)" if verbose else msg)
678+
644679
# -- Mutually exclusive groups -------------------------------------------
645680
# A plain (untitled) mutex rejects combinations of its members; required=True
646681
# makes exactly one of them mandatory.

0 commit comments

Comments
 (0)