Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
## 4.2.0 (TBD)

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

## 4.1.2 (July 16, 2026)

Expand Down
92 changes: 53 additions & 39 deletions cmd2/annotated.py
Original file line number Diff line number Diff line change
Expand Up @@ -552,25 +552,20 @@ def __init__(
) -> None:
"""Initialise an argument group definition.

:param members: parameter names to place in the group (at least one)
:param members: argument names to place in the group (at least one). A plain parameter's name is its
argument name; for an ``ArgumentBlock`` name its fields.
:param title: group title shown as a help section header
:param description: group description shown under the title
:param required: ``mutually_exclusive_groups`` only -- require exactly one member.
On a ``groups=`` entry it raises ``ValueError``.
"""
if not members:
raise ValueError("Group requires at least one member parameter name")
raise ValueError("Group requires at least one member argument name")
self.members = members
self.title = title
self.description = description
self.required = required

def _validate_members(self, *, all_param_names: set[str], group_type: str) -> None:
"""Validate that every referenced member parameter exists."""
for name in self.members:
if name not in all_param_names:
raise ValueError(f"{group_type} references nonexistent parameter {name!r}")


#: Metadata extracted from ``Annotated[T, meta]``, or ``None`` for plain types.
ArgMetadata = Argument | Option | None
Expand Down Expand Up @@ -2072,15 +2067,47 @@ def _shared_field_dest(dc_type: type, field_name: str) -> str:
return constants.cmd2_private_attr_name(f"shared_{id(dc_type):x}_{field_name}")


def _check_group_members_exist(
by_name: dict[str, _ArgparseArgument],
block_param_names: set[str],
func_qualname: str,
*specs: tuple[Group, ...] | None,
) -> None:
"""Reject a ``Group`` member that names no command-line argument, once every argument is built.

:func:`_validate_group_specs` cannot do this at decoration time: an ArgumentBlock's arguments are its
dataclass fields, which only resolving the block's type hint would reveal, and resolving hints there
would break forward-referenced annotations. So this is where a bad member name is finally caught.

Naming the block parameter itself is the likely mistake -- it reads natural but the parameter is expanded
away and has no argument -- so it gets its own message pointing at the fields to name instead.
"""
for spec_group in specs:
for spec in spec_group or ():
for name in spec.members:
if name in by_name:
continue
if name in block_param_names:
raise ValueError(
f"group in {func_qualname} references {name!r}, which is an ArgumentBlock parameter "
f"and not a command-line argument: a block is expanded into one argument per field, "
f"so name those fields instead."
)
raise ValueError(
f"group in {func_qualname} references {name!r}, which is not a command-line argument. "
f"Members name arguments: a plain parameter is its own argument, and an ArgumentBlock's "
f"arguments are its field names."
)


def _link_mutex_group_membership(
by_name: dict[str, _ArgparseArgument],
mutually_exclusive_groups: tuple[Group, ...] | None,
) -> None:
"""Append each mutex group's 1-based index to its member arguments' ``mutex_group_indices``.

This membership is the fact behind the required-member constraint row. Member references are
validated upstream by :func:`_validate_group_specs` before this runs, so every member name
resolves to a built argument.
resolved to built arguments upstream by :func:`_check_group_members_exist` before this runs.
"""
if not mutually_exclusive_groups:
return
Expand Down Expand Up @@ -2352,6 +2379,7 @@ def _resolve_parameters(
*,
skip_params: frozenset[str] = _SKIP_PARAMS,
base_command: bool = False,
groups: tuple[Group, ...] | None = None,
mutually_exclusive_groups: tuple[Group, ...] | None = None,
) -> tuple[list[_ArgparseArgument], set[type]]:
"""Resolve a function signature into argparse-argument builders and inheritable-block types.
Expand All @@ -2378,6 +2406,7 @@ def _resolve_parameters(
resolved: list[_ArgparseArgument] = []
inherited_field_names: set[str] = set()
base_args_types: set[type] = set()
block_param_names: set[str] = set()

# Skip the first parameter by position (self/cls for methods)
params = list(sig.parameters.items())
Expand All @@ -2398,6 +2427,7 @@ def _resolve_parameters(
f"cannot have a default value; remove the default."
)
inherited_field_names.update(_init_field_names(inherited))
block_param_names.add(name)
continue

# An ArgumentBlock-typed parameter is an argument block: expand its fields in place (flat) instead of
Expand All @@ -2416,6 +2446,7 @@ def _resolve_parameters(
block_hint, func_qualname=func.__qualname__, base_command=base_command, shared=name == NS_ATTR_BASE_ARGS
)
_reject_field_shadowing_block_param(block_hint, name, func.__qualname__)
block_param_names.add(name)
resolved.extend(expanded)
continue
# The magic name requires a bare ArgumentBlock; a non-block annotation on it is a clear mistake.
Expand Down Expand Up @@ -2480,6 +2511,7 @@ def _resolve_parameters(
for arg in positionals[:-1]: # every positional except the last has a following positional
arg.has_following_positional = True
by_name = {arg.name: arg for arg in resolved}
_check_group_members_exist(by_name, block_param_names, func.__qualname__, groups, mutually_exclusive_groups)
_link_mutex_group_membership(by_name, mutually_exclusive_groups)
for arg in resolved:
arg._check_constraints()
Expand Down Expand Up @@ -2539,30 +2571,22 @@ def _filtered_namespace_kwargs(


def _validate_group_specs(
func: Callable[..., Any],
*,
skip_params: frozenset[str],
groups: tuple[Group, ...] | None,
mutually_exclusive_groups: tuple[Group, ...] | None,
) -> None:
"""Validate ``groups=`` / ``mutually_exclusive_groups=`` specs from parameter names alone.

Runs at decoration time (from both the regular-command and subcommand decoration paths, and
again from :func:`build_parser_from_function` for direct callers), so a misconfigured group
hard-fails when the class is defined instead of on first command use, where cmd2's runtime
handler turns the error into a printed message. Reads only parameter names and the ``Group``
specs -- never the type hints -- so forward-referenced annotations still decorate. The one
group rule that needs the annotations (a required member in a mutually exclusive group) stays
in :data:`_CONSTRAINTS` and fires when the parser is built.
"""Validate the *shape* of ``groups=`` / ``mutually_exclusive_groups=`` specs.

Runs at decoration time (from both the regular-command and subcommand decoration paths, and again from
:func:`build_parser_from_function` for direct callers), so a misshapen group hard-fails when the class is
defined instead of on first command use, where cmd2's runtime handler turns the error into a printed
message. Reads only the ``Group`` specs -- never the signature or the type hints -- so
forward-referenced annotations still decorate.
"""
if not groups and not mutually_exclusive_groups:
return
params = list(inspect.signature(func).parameters)[1:] # skip self/cls by position
all_param_names = {name for name in params if name not in skip_params}

group_entry_for: dict[str, int] = {}
for index, spec in enumerate(groups or (), start=1):
spec._validate_members(all_param_names=all_param_names, group_type="groups")
if spec.required:
raise ValueError(
"Group(required=True) is only valid in mutually_exclusive_groups; "
Expand All @@ -2580,7 +2604,6 @@ def _validate_group_specs(

mutex_entry_for: dict[str, int] = {}
for index, spec in enumerate(mutually_exclusive_groups or (), start=1):
spec._validate_members(all_param_names=all_param_names, group_type="mutually_exclusive_groups")
for name in spec.members:
previous = mutex_entry_for.get(name)
if previous == index:
Expand Down Expand Up @@ -2721,7 +2744,7 @@ def build_parser_from_function(
from . import argparse_utils

# The decorator already ran this at decoration time; direct callers get the same checks here.
_validate_group_specs(func, skip_params=skip_params, groups=groups, mutually_exclusive_groups=mutually_exclusive_groups)
_validate_group_specs(groups, mutually_exclusive_groups)

parser_cls = parser_class or argparse_utils.DEFAULT_ARGUMENT_PARSER
if "description" not in parser_kwargs:
Expand All @@ -2735,6 +2758,7 @@ def build_parser_from_function(
resolved, base_args_types = _resolve_parameters(
func,
skip_params=skip_params,
groups=groups,
mutually_exclusive_groups=mutually_exclusive_groups,
)

Expand Down Expand Up @@ -2864,12 +2888,7 @@ def _build_subcommand_handler(
# Validate the group specs eagerly (decoration time) so a misconfigured group hard-fails when
# the class is defined; the name-only checks never resolve type hints, so forward-referenced
# annotations still decorate and the parser build stays deferred.
_validate_group_specs(
func,
skip_params=_SKIP_PARAMS,
groups=options.groups,
mutually_exclusive_groups=options.mutually_exclusive_groups,
)
_validate_group_specs(options.groups, options.mutually_exclusive_groups)
if base_command:
# Validate eagerly (decoration time); the base-command rows in _CONSTRAINTS fire here.
# skip_params is spelled out so this call cannot silently diverge from the parser build below.
Expand Down Expand Up @@ -3053,12 +3072,7 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
# Validate the group specs eagerly (decoration time) so a misconfigured group hard-fails when
# the class is defined; the name-only checks never resolve type hints, so forward-referenced
# annotations still decorate and the parser build stays deferred.
_validate_group_specs(
fn,
skip_params=skip_params,
groups=options.groups,
mutually_exclusive_groups=options.mutually_exclusive_groups,
)
_validate_group_specs(options.groups, options.mutually_exclusive_groups)
if base_command:
# Validate eagerly (decoration time); the base-command rows in _CONSTRAINTS fire here.
_resolve_parameters(fn, skip_params=skip_params, base_command=True)
Expand Down
38 changes: 30 additions & 8 deletions docs/features/annotated.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,8 +445,8 @@ token to convert. Both raise `TypeError` at decoration time.
- `help` -- help text for an annotated subcommand (only valid with `subcommand_to`)
- `aliases` -- aliases for an annotated subcommand (only valid with `subcommand_to`)
- `deprecated` -- mark the subcommand as deprecated in `--help` (only valid with `subcommand_to`)
- `groups` -- `Group` instances assigning parameter names to argument groups
- `mutually_exclusive_groups` -- `Group` instances of mutually exclusive parameters
- `groups` -- `Group` instances assigning argument names to argument groups
- `mutually_exclusive_groups` -- `Group` instances of mutually exclusive arguments
- `parser_class` -- a custom parser class (defaults to the configured default)
- `**parser_kwargs` -- every other `Cmd2ArgumentParser` constructor kwarg, forwarded through PEP 692
[`Unpack[Cmd2ParserKwargs]`][cmd2.annotated.Cmd2ParserKwargs]. See
Expand Down Expand Up @@ -549,13 +549,35 @@ both places, a mutex that sits only partly in a `groups=` entry, or one that spa
raises `ValueError`. The other three nestings (an argument group inside another group or a mutex, or
a mutex inside a mutex) are removed in argparse on Python 3.14 and cannot be expressed here.

All of these group-spec rules -- member references, a parameter assigned to two groups,
A `Group` member names a command-line **argument**. For a plain parameter the argument is the
parameter, so the name is the same either way. An `ArgumentBlock` parameter is different: it is
expanded into one argument per field (field name == argument name), so you name its fields rather
than the block:

```python
@dataclass
class Conn(ArgumentBlock):
host: Annotated[str, Option("--host")] = "localhost"
port: Annotated[int, Option("--port")] = 8080

@with_annotated(groups=(Group("host", "port", title="connection"),))
def do_connect(self, conn: Conn) -> None: ...
```

Naming the block parameter itself (`Group("conn")`) raises `ValueError`: the parameter is expanded
away and has no argument of its own. Naming a block's fields individually is also what lets you say
which fields are alternatives -- put `Group("json", "csv")` in `mutually_exclusive_groups` and only
those two exclude each other, rather than every field of the block they happen to live in.

The group-spec **shape** rules -- a member listed twice, a member assigned to two groups,
`required=True` on a plain group, and the mutex nesting rules above -- are validated when the
decorator runs, so a misconfigured group raises `ValueError` at class definition time instead of on
first command use. The checks read only parameter names, never the type hints, so forward-referenced
annotations still decorate cleanly. The one group rule that depends on the annotations (a member of
a mutually exclusive group must be omittable -- have a default or be `T | None`) fires when the
parser is built.
decorator runs, so a misshapen group raises `ValueError` at class definition time instead of on
first command use. Those checks read only the `Group` specs, never the signature or the type hints,
so forward-referenced annotations still decorate cleanly.

The rules that depend on what a member _is_ fire when the parser is built, because a block's field
names cannot be known without resolving its type hint: that every member names a real argument, and
that a member of a mutually exclusive group is omittable (has a default or is `T | None`).

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