diff --git a/CHANGELOG.md b/CHANGELOG.md index 1331b3dcb..c165ee055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,13 @@ transforming the raw token while keeping the inferred `type=`/`choices`/completer (e.g. `preprocess=str.lower` on an `Enum`). The two are mutually exclusive on one parameter and neither may be combined with a value-less action. + - `@with_annotated` now supports reusable argument blocks: a parameter typed with a `@dataclass` + that subclasses the new `cmd2.ArgumentBlock` trait expands each field into a flat command-line + argument, and the parsed values are reconstructed into a dataclass instance passed to the + command, letting several commands reuse the same fields without duplication. See the + [annotated](docs/features/annotated.md) documentation. + - A command can share an argument block with its subcommands via `cmd2_base_args` / + `cmd2_parent_args` parameters, passing parent-level options down without redeclaring them. ## 4.0.0 (June 5, 2026) diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 6d1443143..c4aafcb1d 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -11,7 +11,10 @@ rich_utils, string_utils, ) -from .annotated import with_annotated +from .annotated import ( + ArgumentBlock, + with_annotated, +) from .argparse_completer import set_default_argparse_completer from .argparse_utils import ( Cmd2ArgumentParser, @@ -88,6 +91,8 @@ "Choices", "CompletionItem", "Completions", + # Annotated commands + "ArgumentBlock", # Decorators "with_annotated", "with_argument_list", diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 5c97b21c9..04767bb83 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -145,6 +145,44 @@ def do_paint( class is defined rather than on first command use. The one group rule that needs the annotations (a required member in a mutually exclusive group) fires when the parser is built. +A parameter annotated with a ``@dataclass`` that subclasses [`ArgumentBlock`][cmd2.annotated.ArgumentBlock] is a +reusable *argument block*: each of the dataclass's ``init`` fields is expanded into a flat command-line +argument (field name == argument name) at the parameter's position, and the parsed values are +reconstructed into a dataclass instance passed to the command. This lets several commands share one +block without duplicating parameters, and a base block shared by inheritance is the reuse mechanism +(a subclass of a block is itself a block): + + @dataclass + class CommonArgs(cmd2.ArgumentBlock): + verbose: Annotated[bool, Option("-v", "--verbose")] = False + output: Annotated[Path | None, Option("--output")] = None + + + class MyApp(cmd2.Cmd): + @cmd2.with_annotated + def do_build(self, target: str, common: CommonArgs): + if common.verbose: + self.poutput(common.output) + +The [`ArgumentBlock`][cmd2.annotated.ArgumentBlock] trait -- not "is a dataclass" -- is the trigger, so a plain +``@dataclass`` is never expanded and can still be used as an ordinary single argument value (e.g. with an +``Argument(converter=...)``). Each field carries the usual ``Annotated[T, Option(...)]`` / +``Annotated[T, Argument(...)]`` metadata and behaves exactly as a top-level parameter of the same shape +(type inference, completion, choices). The dataclass is the single source of truth for defaults: a field +with a default (``default`` or ``default_factory``) is emitted with ``argparse.SUPPRESS`` and filled by the +dataclass constructor at reconstruction time, so a ``default_factory`` yields a fresh value per invocation +(no shared-mutable default) and ``__post_init__`` runs. A field with no default becomes a required +argument. A field whose type is itself a block is not expanded (no recursion) and raises the +unsupported-type error. Because fields expand flat, every field name must be unique across the command's +own parameters and every other block's fields -- a collision (which would put two argparse actions on one +namespace dest) raises ``TypeError`` when the parser is built. Block fields cannot participate in +``groups=`` / ``mutually_exclusive_groups=`` (those reference the function's own parameter names, which are +validated without resolving type hints). A block must be the *bare* annotation of a regular parameter: +wrapping it in ``Annotated``/``Optional``/a union, or using it as ``*args`` / ``**kwargs``, raises +``TypeError`` (to use a dataclass as a single value instead, make it a plain ``@dataclass`` with an +``Argument(converter=...)``). An ``ArgumentBlock`` subclass that is not a ``@dataclass`` has no fields and +also raises ``TypeError``. + Unsupported patterns (raise ``TypeError``): - a non-Optional type with a ``None`` default (e.g. ``name: str = None``); annotate it @@ -229,8 +267,11 @@ class is defined rather than on first command use. The one group rule that need Sequence, ) from dataclasses import ( + MISSING, dataclass, field, + fields, + is_dataclass, ) from pathlib import Path from typing import ( @@ -238,9 +279,11 @@ class is defined rather than on first command use. The one group rule that need Any, ClassVar, Literal, + NamedTuple, ParamSpec, Protocol, TypedDict, + TypeGuard, TypeVar, Union, Unpack, @@ -451,6 +494,52 @@ def to_kwargs(self) -> dict[str, Any]: return kwargs +class ArgumentBlock: + """Marker base class for a reusable ``@with_annotated`` argument block. + + Subclass it on a ``@dataclass`` to have [`with_annotated`][cmd2.annotated.with_annotated] expand the + dataclass's fields into flat command-line arguments and reconstruct an instance at call time:: + + @dataclass + class CommonArgs(ArgumentBlock): + verbose: Annotated[bool, Option("-v", "--verbose")] = False + + + class MyApp(cmd2.Cmd): + @cmd2.with_annotated + def do_build(self, target: str, common: CommonArgs): ... + + Only a class that inherits ``ArgumentBlock`` is treated as a block, so a plain ``@dataclass`` is left + alone and can still be used as an ordinary single argument value (e.g. via an ``Argument(converter=...)``). + Inheritance doubles as the reuse mechanism: a subclass of a block is itself a block, so a shared base + block can be extended per command without repeating its fields. A block must be the *bare* annotation + of a regular parameter -- wrapping it in ``Annotated``/``Optional``/a union, or using it as ``*args`` / + ``**kwargs``, raises ``TypeError``. + + To share a block between a command and its subcommands, name the parameter ``cmd2_base_args`` on the + command and ``cmd2_parent_args`` on each subcommand that should receive it:: + + @dataclass + class SharedOpts(ArgumentBlock): + verbose: Annotated[bool, Option("-v", "--verbose")] = False + + + class MyApp(cmd2.Cmd): + @cmd2.with_annotated(base_command=True) + def do_root(self, cmd2_subcommand_func, cmd2_base_args: SharedOpts): ... + + @cmd2.with_annotated(subcommand_to="root") + def root_show(self, cmd2_parent_args: SharedOpts): + self.poutput(cmd2_parent_args.verbose) # parsed on `root`, read here + + A base command and its subcommands parse into one shared ``argparse.Namespace``. ``cmd2_base_args`` + adds the block's flags to the command's *own* parser; ``cmd2_parent_args`` adds *no* arguments and is + reconstructed from the values an ancestor parsed (``root --verbose show``, not ``root show --verbose``). + A ``cmd2_parent_args`` subcommand whose ancestors never declare a matching ``cmd2_base_args`` raises a + clear error the first time it runs. + """ + + class Group: """Argument-group definition for ``with_annotated(groups=...)`` / ``mutually_exclusive_groups=...``.""" @@ -1036,12 +1125,16 @@ def __init__( is_optional: bool, kind: inspect._ParameterKind, is_base_command: bool, + is_block_field: bool = False, + dest_override: str | None = None, ) -> None: # signature-derived inputs (never emitted): self.name = name self.func_qualname = func_qualname self.has_default = has_default self.param_default = param_default # the function's own default, not the argparse `default` slot + self.is_block_field = is_block_field + self.dest_override = dest_override self.is_kw_only = is_kw_only self.is_variadic = is_variadic self.inner_type = inner_type # peeled type (after Annotated + Optional) @@ -1241,6 +1334,15 @@ def _is_inferred_bool_flag(self) -> bool: """Whether this is a bool option using the inferred ``BooleanOptionalAction`` (no explicit action).""" return self.action is argparse.BooleanOptionalAction + @property + def _consumes_value(self) -> bool: + """Whether this option takes a command-line value (so argparse derives/shows a metavar for it). + + A flag-style action (``store_true``/``count``/``BooleanOptionalAction``/...) takes none, so it has + no metavar -- and some of those actions even reject a ``metavar`` kwarg. + """ + return not self._is_inferred_bool_flag and not (self._policy is not None and self._policy.drop_converter) + def _apply(self) -> None: """Build this argument by deriving each output slot.""" self.is_positional = _first_match(_ROLE_RULES, self) @@ -1404,9 +1506,14 @@ def _emit(self) -> tuple[tuple[Any, ...], dict[str, Any]]: kwargs["default"] = self.default if self.required: kwargs["required"] = True + dest = self.dest_override or self.name if self.is_positional: - return (self.name,), kwargs - kwargs["dest"] = self.name + if self.dest_override is not None: + kwargs.setdefault("metavar", self.name) + return (dest,), kwargs + if self.dest_override is not None and self._consumes_value: + kwargs.setdefault("metavar", self.name.upper()) + kwargs["dest"] = dest return tuple(self.flags), kwargs def add_to(self, target: _ArgumentTarget) -> None: @@ -1503,6 +1610,9 @@ def add_to(self, target: _ArgumentTarget) -> None: #: Default-value table. Either source (signature or metadata) feeds the parser default; #: explicit-action defaults (count -> 0, append/extend -> []) are added later by the action phase. _DEFAULT_RULES: list[_Rule[_ArgparseArgument, Any]] = [ + # A dataclass-block field with a field default emits SUPPRESS so the absent field stays out of the + # namespace and the dataclass constructor supplies the default (fresh per call for default_factory). + (lambda a: a.is_block_field and a.has_default, _const(argparse.SUPPRESS)), (lambda a: a._effective_has_default, lambda a: a._effective_param_default), # A bool option is a flag: when absent it means ``False`` (not a missing value), so -- like # store_true -- it carries its own default. ``bool | None`` keeps the catch-all ``_UNSET`` @@ -1829,8 +1939,9 @@ def _const_mismatches_type(a: _ArgparseArgument) -> bool: # sources of truth for the same value; refuse rather than silently pick a winner. lambda a: a.has_default and a._has_meta_default, lambda a: TypeError( - f"parameter '{a.name}' in {a.func_qualname} has a default in both the function signature " - f"({a.param_default!r}) and the metadata ({a._meta_default!r}); specify it in only one place." + f"parameter '{a.name}' in {a.func_qualname} has a default in both the " + f"{'dataclass field' if a.is_block_field else f'function signature ({a.param_default!r})'} " + f"and the metadata ({a._meta_default!r}); specify it in only one place." ), ), ( @@ -1844,11 +1955,26 @@ def _const_mismatches_type(a: _ArgparseArgument) -> bool: f"annotate the type as '{_type_name(a.inner_type)} | None'." ), ), + ( + # A block field's default must live on the dataclass field: a field default emits SUPPRESS (see + # _DEFAULT_RULES) so the dataclass constructor produces the value fresh on every call. + lambda a: a.is_block_field and not a.has_default and a.default is not _UNSET and a.default is not None, + lambda a: TypeError( + f"ArgumentBlock field '{a.name}' in {a.func_qualname} would take its default ({a.default!r}) from " + f"the option metadata or its action, but a block field's default must live on the dataclass field " + f"so the constructor produces it fresh on every call. Put it on the field instead -- a plain " + f"default for an immutable value (e.g. '{a.name}: ... = False'), or 'field(default_factory=...)' " + f"for a mutable one (e.g. '= field(default_factory=list)')." + ), + ), ( lambda a: ( a._effective_has_default and a._effective_param_default is None and not a.is_optional + # A block field carries a placeholder None default here; its real default lives in the dataclass + # (emitted as SUPPRESS), so this signature-default check does not apply. + and not a.is_block_field and a.inner_type not in (object, Any, inspect.Parameter.empty) ), lambda a: TypeError( @@ -1922,6 +2048,29 @@ def _const_mismatches_type(a: _ArgparseArgument) -> bool: # parameter (self/cls) is always skipped by position; these cover additional decorator-managed names. _SKIP_PARAMS = frozenset({constants.NS_ATTR_SUBCOMMAND_FUNC, constants.NS_ATTR_STATEMENT}) +NS_ATTR_BASE_ARGS = constants.cmd2_public_attr_name("base_args") +NS_ATTR_PARENT_ARGS = constants.cmd2_public_attr_name("parent_args") + + +def _base_args_marker_dest(dc_type: type) -> str: + """Namespace dest of the parse-time presence marker for a ``cmd2_base_args`` block of *dc_type*. + + Keyed by ``id()`` of the block type: ``cmd2_base_args`` and ``cmd2_parent_args`` referencing the same + block class share the exact type object, so the dest a parent stamps matches the one a child checks, + and a child inheriting the wrong type sees its marker absent (a reliable mismatch error). + """ + return constants.cmd2_private_attr_name(f"base_args_{id(dc_type):x}") + + +def _shared_field_dest(dc_type: type, field_name: str) -> str: + """Namespace dest for a shared-block (``cmd2_base_args``/``cmd2_parent_args``) field, qualified by type. + + Keyed by ``id()`` of the block type like the presence marker, so a parent's ``cmd2_base_args`` and a + child's ``cmd2_parent_args`` of the same class agree on the dest while two different block types that + share a field name get distinct dests -- they never collide on one attribute in the shared namespace. + """ + return constants.cmd2_private_attr_name(f"shared_{id(dc_type):x}_{field_name}") + def _link_mutex_group_membership( by_name: dict[str, _ArgparseArgument], @@ -1940,14 +2089,276 @@ def _link_mutex_group_membership( by_name[name].mutex_group_indices.append(index) +def _resolve_func_hints(func: Callable[..., Any], *, skip_params: frozenset[str] = _SKIP_PARAMS) -> dict[str, Any]: + """Resolve the type hints for the parameters that become arguments. + + The bound first parameter (self/cls), the injected ``skip_params``, and the ``return`` annotation + never become arguments, so they are dropped before resolution. Forward references resolve against + the *original* function's module so a ``functools.wraps`` wrapper still resolves correctly. + """ + sig = inspect.signature(func) + ignored = {next(iter(sig.parameters), None), "return", *skip_params} + ignored.discard(None) + relevant_annotations = {name: ann for name, ann in getattr(func, "__annotations__", {}).items() if name not in ignored} + unwrapped = inspect.unwrap(func) + try: + return get_type_hints( + types.SimpleNamespace(__annotations__=relevant_annotations), + globalns=getattr(unwrapped, "__globals__", {}), + include_extras=True, + ) + except (NameError, AttributeError, TypeError) as exc: + raise TypeError( + f"Failed to resolve type hints for {func.__qualname__}. Ensure all annotations use valid, importable types." + ) from exc + + +def _is_argument_block(hint: Any, param: inspect.Parameter) -> TypeGuard[type]: + """Whether a parameter is a bare [`ArgumentBlock`][cmd2.annotated.ArgumentBlock] whose fields expand flat. + + Only a by-keyword-passable parameter (positional-or-keyword or keyword-only) annotated with a bare + ``ArgumentBlock`` subclass qualifies. Membership -- not "is a dataclass" -- is the trigger, so a plain + ``@dataclass`` is never captured; the block must still be a ``@dataclass`` to have fields to expand, + which :func:`_expand_dataclass_block` enforces with a clear message. + """ + return ( + param.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY) + and isinstance(hint, type) + and issubclass(hint, ArgumentBlock) + ) + + +def _require_magic_block(name: str, hint: Any, param: inspect.Parameter, func_qualname: str) -> type: + """Return the ``ArgumentBlock`` subclass annotating a ``cmd2_base_args``/``cmd2_parent_args`` parameter. + + Both magic parameters must be a bare ``ArgumentBlock`` subclass, the same form a regular block uses. A + wrong annotation (a plain value, or a block wrapped in ``Annotated``/``Optional``/a union) is rejected + here with a clear message rather than being silently treated as an ordinary argument. + """ + if _is_argument_block(hint, param): + return hint + raise TypeError( + f"Parameter '{name}' in {func_qualname} must be annotated with a bare ArgumentBlock subclass " + f"(e.g. '{name}: SharedOpts'); it shares a block between a command and its subcommands." + ) + + +def _find_argument_block(hint: Any) -> type | None: + """Return an [`ArgumentBlock`][cmd2.annotated.ArgumentBlock] subclass found anywhere within *hint*, else ``None``. + + Used to reject a block that is not the bare annotation: an ``ArgumentBlock`` nested in ``Annotated`` / + ``Optional`` / a union is ambiguous (an optional block? a union of blocks? a single value?), so it is + rejected rather than silently mishandled. A bare block is detected by :func:`_is_argument_block` first, + so this only fires for the wrapped/misused forms. + """ + if isinstance(hint, type) and issubclass(hint, ArgumentBlock): + return hint + for arg in get_args(hint): + found = _find_argument_block(arg) + if found is not None: + return found + return None + + +def _init_field_names(dc_type: type) -> list[str]: + """Names of a dataclass's ``init`` fields in definition order (the flat argument names of a block).""" + return [f.name for f in fields(dc_type) if f.init] + + +def _reject_field_shadowing_block_param(dc_type: type, param_name: str, func_qualname: str) -> None: + """Reject a block whose field name equals the block parameter name. + + The field's flat argument and the parameter that receives the block instance would share one + keyword-argument key, so a parsed value and a directly-supplied instance become indistinguishable. + """ + if param_name in _init_field_names(dc_type): + raise TypeError( + f"ArgumentBlock '{dc_type.__name__}' field {param_name!r} collides with the block parameter " + f"name '{param_name}' in {func_qualname}; rename the field so its command-line argument does not " + f"shadow the parameter that receives the block instance." + ) + + +def _expand_dataclass_block( + dc_type: type, + *, + func_qualname: str, + base_command: bool, + shared: bool = False, +) -> list[_ArgparseArgument]: + """Expand a dataclass block's ``init`` fields into flat ``_ArgparseArgument`` builders. + + Each field maps to one argument named after the field (flat: field name == argument name). The + field's ``Annotated[T, Option/Argument]`` metadata and its default (``default`` or ``default_factory``) + drive the argument exactly as a top-level parameter of the same shape would. + + ``shared`` marks a ``cmd2_base_args`` block: its fields parse into a type-qualified dest (the flag is + unchanged) so blocks of different types never collide on one attribute in the shared subcommand + namespace; the matching ``cmd2_parent_args`` reads the same qualified dest. + """ + if not is_dataclass(dc_type): + raise TypeError( + f"ArgumentBlock subclass '{dc_type.__name__}' must be decorated with @dataclass so it has fields " + f"to expand into command-line arguments." + ) + try: + field_hints = get_type_hints(dc_type, include_extras=True) + except (NameError, AttributeError, TypeError) as exc: + raise TypeError( + f"Failed to resolve type hints for ArgumentBlock '{dc_type.__name__}'. " + f"Ensure all field annotations use valid, importable types." + ) from exc + # An InitVar is a constructor parameter that dataclasses.fields() omits, so it would never become a + # command-line argument yet the constructor still requires it. Reject it up front with a clear message + # rather than letting reconstruction fail later with an opaque "missing argument" TypeError. + init_field_names = set(_init_field_names(dc_type)) + init_only = [name for name in list(inspect.signature(dc_type.__init__).parameters)[1:] if name not in init_field_names] + if init_only: + raise TypeError( + f"ArgumentBlock '{dc_type.__name__}' has InitVar field(s) {sorted(init_only)}, which @with_annotated " + f"cannot expand into command-line arguments; use a regular field instead." + ) + expanded: list[_ArgparseArgument] = [] + for f in fields(dc_type): + if not f.init: + continue # field(init=False) is not a constructor argument, so it has no command-line value + # A field whose name matches a cmd2-injected namespace attribute (e.g. cmd2_statement) would be + # silently overwritten by cmd2 at parse time, so its value could never reach the constructor. + if f.name in _SKIP_PARAMS: + raise TypeError( + f"ArgumentBlock '{dc_type.__name__}' field {f.name!r} collides with a reserved cmd2 namespace " + f"attribute; rename the field." + ) + inner_type, metadata, is_optional = _normalize_annotation(field_hints[f.name]) + # The dataclass owns its own defaults: a field with any default (default or default_factory) emits + # SUPPRESS (see _DEFAULT_RULES) and is filled by the constructor, so the value is never read here. + has_default = f.default is not MISSING or f.default_factory is not MISSING + expanded.append( + _ArgparseArgument( + name=f.name, + func_qualname=func_qualname, + has_default=has_default, + param_default=None, + is_kw_only=False, + is_variadic=False, + inner_type=inner_type, + metadata=metadata, + is_optional=is_optional, + kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, + is_base_command=base_command, + is_block_field=True, + dest_override=_shared_field_dest(dc_type, f.name) if shared else None, + ) + ) + return expanded + + +class _BlockSpec(NamedTuple): + """How to reconstruct one dataclass-block parameter from the parsed namespace at call time.""" + + dc_type: type + field_names: list[str] + inherited: bool # True for a cmd2_parent_args block (inherited from an ancestor's cmd2_base_args) + shared: bool = False # cmd2_base_args/cmd2_parent_args: fields live under a type-qualified dest + + +def _block_field_dest(spec: _BlockSpec, field_name: str) -> str: + """Namespace dest a block field lands under: type-qualified for a shared block, else the field name.""" + return _shared_field_dest(spec.dc_type, field_name) if spec.shared else field_name + + +def _dataclass_blocks(func: Callable[..., Any], *, skip_params: frozenset[str] = _SKIP_PARAMS) -> dict[str, _BlockSpec]: + """Map each dataclass-block parameter name to its :class:`_BlockSpec`. + + Used by the runtime handler to reconstruct the dataclass instance from the parsed namespace. A + ``cmd2_parent_args`` parameter is a bare block too, so it is detected the same way and flagged + ``inherited`` (its fields live in the shared namespace, populated by an ancestor's ``cmd2_base_args``). + """ + hints = _resolve_func_hints(func, skip_params=skip_params) + blocks: dict[str, _BlockSpec] = {} + for name, param in list(inspect.signature(func).parameters.items())[1:]: + if name in skip_params: + continue + if _is_argument_block(hints.get(name), param): + hint = hints[name] + blocks[name] = _BlockSpec( + hint, + _init_field_names(hint), + inherited=name == NS_ATTR_PARENT_ARGS, + shared=name in (NS_ATTR_BASE_ARGS, NS_ATTR_PARENT_ARGS), + ) + return blocks + + +def _lazy_block_resolver( + func: Callable[..., Any], + *, + base_accepted: set[str], + skip_params: frozenset[str], +) -> Callable[[], tuple[dict[str, _BlockSpec], set[str]]]: + """Return a zero-arg callable yielding ``(blocks, accepted)``, computed once and cached. + + Detecting dataclass blocks resolves type hints, which the decorator defers so forward-referenced + annotations still decorate. The first invocation runs after the class is fully defined, so the + hints resolve safely; the result (the blocks and the namespace ``accepted`` set widened to the + expanded field names) is cached for subsequent calls. + """ + + @functools.cache + def resolve() -> tuple[dict[str, _BlockSpec], set[str]]: + blocks = _dataclass_blocks(func, skip_params=skip_params) + accepted = set(base_accepted) + for block_name, spec in blocks.items(): + accepted.discard(block_name) + accepted.update(_block_field_dest(spec, name) for name in spec.field_names) + return blocks, accepted + + return resolve + + +def _reconstruct_dataclass_blocks(func_kwargs: dict[str, Any], blocks: dict[str, _BlockSpec], ns: Any) -> None: + """Fold expanded field values in ``func_kwargs`` back into their dataclass-block instances, in place. + + A block already present in ``func_kwargs`` (e.g. a direct method call passing the instance) is left + untouched -- only the namespace-derived field values are gathered and the instance is built. + + An inherited (``cmd2_parent_args``) block is only valid when an ancestor command declared a matching + ``cmd2_base_args`` block: that parent stamps a presence marker into the shared namespace at parse time, + so its absence here means no ancestor declared the block. Reconstructing anyway would hand back an + all-defaults instance and hide the misconfiguration, so raise instead. + """ + for block_name, spec in blocks.items(): + # The expanded field values always come out of func_kwargs (keyed by their namespace dest, which is + # type-qualified for a shared block) + field_values = { + field: func_kwargs.pop(dest) + for field in spec.field_names + if (dest := _block_field_dest(spec, field)) in func_kwargs + } + if block_name in func_kwargs: + # A block instance is already present (e.g. a programmatic call passing it directly); use it as-is. + continue + if spec.inherited and not getattr(ns, _base_args_marker_dest(spec.dc_type), False): + raise TypeError( + f"Parameter '{block_name}' inherits the ArgumentBlock '{spec.dc_type.__name__}' from a parent " + f"command, but no ancestor command declares a matching 'cmd2_base_args: {spec.dc_type.__name__}'. " + f"Add that parameter to the parent command so its fields are parsed into the shared namespace." + ) + func_kwargs[block_name] = spec.dc_type(**field_values) + + def _resolve_parameters( func: Callable[..., Any], *, skip_params: frozenset[str] = _SKIP_PARAMS, base_command: bool = False, mutually_exclusive_groups: tuple[Group, ...] | None = None, -) -> list[_ArgparseArgument]: - """Resolve a function signature into a list of argparse-argument builders. +) -> tuple[list[_ArgparseArgument], set[type]]: + """Resolve a function signature into argparse-argument builders and inheritable-block types. + + Returns ``(resolved, base_args_types)`` where ``base_args_types`` are the ``ArgumentBlock`` types of + any ``cmd2_base_args`` parameter. The build path stamps a parse-time presence marker for each so a + descendant's ``cmd2_parent_args`` can tell, at call time, that the block was actually declared. ``base_command`` marks each argument's context for the base-command :data:`_CONSTRAINTS` rows and drives the function-level ``cmd2_subcommand_func`` check below. ``mutually_exclusive_groups`` @@ -1962,25 +2373,11 @@ def _resolve_parameters( f"with_annotated(base_command=True) requires a '{constants.NS_ATTR_SUBCOMMAND_FUNC}' " f"parameter in {func.__qualname__}" ) - # Resolve hints only for the parameters that become arguments: the bound first parameter - # (self/cls), the injected skip_params, and the "return" annotation never become arguments - ignored = {next(iter(sig.parameters), None), "return", *skip_params} - ignored.discard(None) - relevant_annotations = {name: ann for name, ann in getattr(func, "__annotations__", {}).items() if name not in ignored} - # Forward references resolve against the *original* function's module during functools.wraps wrapper. - unwrapped = inspect.unwrap(func) - try: - hints = get_type_hints( - types.SimpleNamespace(__annotations__=relevant_annotations), - globalns=getattr(unwrapped, "__globals__", {}), - include_extras=True, - ) - except (NameError, AttributeError, TypeError) as exc: - raise TypeError( - f"Failed to resolve type hints for {func.__qualname__}. Ensure all annotations use valid, importable types." - ) from exc + hints = _resolve_func_hints(func, skip_params=skip_params) resolved: list[_ArgparseArgument] = [] + inherited_field_names: set[str] = set() + base_args_types: set[type] = set() # Skip the first parameter by position (self/cls for methods) params = list(sig.parameters.items()) @@ -1991,6 +2388,49 @@ def _resolve_parameters( if name in skip_params: continue + block_hint = hints.get(name) + if name == NS_ATTR_PARENT_ARGS: + inherited = _require_magic_block(name, block_hint, param, func.__qualname__) + _reject_field_shadowing_block_param(inherited, name, func.__qualname__) + if param.default is not inspect.Parameter.empty: + raise TypeError( + f"Parameter '{name}' in {func.__qualname__} inherits its block from a parent command and " + f"cannot have a default value; remove the default." + ) + inherited_field_names.update(_init_field_names(inherited)) + continue + + # An ArgumentBlock-typed parameter is an argument block: expand its fields in place (flat) instead of + # building a single argument for the container, so the fields keep their signature-order position. + if _is_argument_block(block_hint, param): + if param.default is not inspect.Parameter.empty: + raise TypeError( + f"Parameter '{name}' in {func.__qualname__} is an ArgumentBlock and cannot have a default " + f"value; the dataclass is the single source of truth for its fields' defaults. Remove the default." + ) + if name == NS_ATTR_BASE_ARGS: + base_args_types.add(block_hint) + # Expand first so its @dataclass-ness check runs before we read field names for the next guard. + # A cmd2_base_args block is shared down the chain: its fields use a type-qualified dest. + expanded = _expand_dataclass_block( + 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__) + resolved.extend(expanded) + continue + # The magic name requires a bare ArgumentBlock; a non-block annotation on it is a clear mistake. + if name == NS_ATTR_BASE_ARGS: + _require_magic_block(name, block_hint, param, func.__qualname__) + wrapped_block = _find_argument_block(block_hint) + if wrapped_block is not None: + raise TypeError( + f"Parameter '{name}' in {func.__qualname__} uses the ArgumentBlock '{wrapped_block.__name__}' in a " + f"wrapped position (Annotated/Optional/union, or *args/**kwargs), which @with_annotated does not " + f"support: a block must be the bare annotation of a regular parameter (e.g. " + f"'{name}: {wrapped_block.__name__}') so its fields can expand. To use a dataclass as a single " + f"value instead, make it a plain @dataclass (not an ArgumentBlock) with an Argument(converter=...)." + ) + # *args has no default and is never keyword-only; its hint is the element type (default str). is_variadic = param.kind == inspect.Parameter.VAR_POSITIONAL has_default = param.default is not inspect.Parameter.empty @@ -2015,6 +2455,25 @@ def _resolve_parameters( ) resolved.append(arg) + seen: dict[str, int] = {} + for arg in resolved: + seen[arg.name] = seen.get(arg.name, 0) + 1 + duplicates = sorted(name for name, count in seen.items() if count > 1) + if duplicates: + raise TypeError( + f"{func.__qualname__} declares the argument name(s) {duplicates} more than once. A dataclass " + f"block expands its fields into flat arguments (field name == argument name), so each field " + f"name must be unique across the command's own parameters and every other block's fields." + ) + + inherited_collisions = sorted(inherited_field_names & seen.keys()) + if inherited_collisions: + raise TypeError( + f"{func.__qualname__} has inherited ArgumentBlock field(s) {inherited_collisions} that collide with the " + f"command's own argument(s). A cmd2_parent_args block reuses the parent's fields by name, so those " + f"names must not also be declared on the subcommand." + ) + # Validate the whole list at once (per-argument + cross-argument rules) now that every # argument is built and its cross-argument facts can be linked. positionals = [arg for arg in resolved if arg.is_positional] @@ -2024,7 +2483,7 @@ def _resolve_parameters( _link_mutex_group_membership(by_name, mutually_exclusive_groups) for arg in resolved: arg._check_constraints() - return resolved + return resolved, base_args_types def _var_positional_call_plan(func: Callable[..., Any]) -> tuple[list[str], str | None]: @@ -2273,7 +2732,7 @@ def build_parser_from_function( # _resolve_parameters validates each argument and the cross-argument rules (e.g. a variable-arity # positional must be last; a required member in a mutex group) once the whole list is built. - resolved = _resolve_parameters( + resolved, base_args_types = _resolve_parameters( func, skip_params=skip_params, mutually_exclusive_groups=mutually_exclusive_groups, @@ -2303,6 +2762,11 @@ def build_parser_from_function( for arg in resolved: arg.add_to(target_for.get(arg.name, parser)) + # A cmd2_base_args block is inheritable: stamp a parse-time presence marker so a descendant's + # cmd2_parent_args can confirm an ancestor actually declared the block. + for base_args_type in base_args_types: + parser.set_defaults(**{_base_args_marker_dest(base_args_type): True}) + return parser @@ -2410,16 +2874,20 @@ def _build_subcommand_handler( _resolve_parameters(func, skip_params=_SKIP_PARAMS, base_command=True) _accepted = set(list(inspect.signature(func).parameters.keys())[1:]) + # A dataclass-block parameter's expanded fields land in the namespace + _resolve_blocks = _lazy_block_resolver(func, base_accepted=_accepted, skip_params=_SKIP_PARAMS) _leading_names, _var_positional_name = _var_positional_call_plan(func) @functools.wraps(func) def handler(self_arg: Any, ns: Any) -> Any: """Unpack Namespace into typed kwargs for the subcommand handler.""" - filtered = _filtered_namespace_kwargs(ns, accepted=_accepted) + _blocks, _eff_accepted = _resolve_blocks() + filtered = _filtered_namespace_kwargs(ns, accepted=_eff_accepted) if constants.NS_ATTR_SUBCOMMAND_FUNC in filtered: cmd2_h = filtered[constants.NS_ATTR_SUBCOMMAND_FUNC] if isinstance(cmd2_h, functools.partial) and getattr(cmd2_h.func, "__func__", cmd2_h.func) is handler: filtered[constants.NS_ATTR_SUBCOMMAND_FUNC] = None + _reconstruct_dataclass_blocks(filtered, _blocks, ns) return _invoke_command_func( func, self_arg, filtered, leading_names=_leading_names, var_positional_name=_var_positional_name ) @@ -2595,6 +3063,7 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: # Cache signature introspection at decoration time, not per-invocation accepted = set(list(inspect.signature(fn).parameters.keys())[1:]) + resolve_blocks = _lazy_block_resolver(fn, base_accepted=accepted, skip_params=skip_params) leading_names, var_positional_name = _var_positional_call_plan(fn) parser_builder = _make_parser_builder(fn, skip_params=skip_params, base_command=base_command, options=options) @@ -2632,12 +3101,14 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: handler = functools.partial(handler, ns) setattr(ns, constants.NS_ATTR_SUBCOMMAND_FUNC, handler) - func_kwargs = _filtered_namespace_kwargs(ns, accepted=accepted, exclude_subcommand=base_command) + blocks, eff_accepted = resolve_blocks() + func_kwargs = _filtered_namespace_kwargs(ns, accepted=eff_accepted, exclude_subcommand=base_command) if with_unknown_args: func_kwargs["_unknown"] = unknown func_kwargs.update(kwargs) + _reconstruct_dataclass_blocks(func_kwargs, blocks, ns) result: bool | None = _invoke_command_func( fn, owner, func_kwargs, leading_names=leading_names, var_positional_name=var_positional_name ) diff --git a/docs/features/annotated.md b/docs/features/annotated.md index b8e8fde93..e769a44c7 100644 --- a/docs/features/annotated.md +++ b/docs/features/annotated.md @@ -606,6 +606,134 @@ def manage_project_add(self, name: str): self.poutput(f"added {name}") ``` +## Argument blocks + +When several commands share the same group of arguments, a reusable _argument block_ removes the +duplication. Subclass the `cmd2.ArgumentBlock` trait on a `@dataclass` and annotate a parameter with +it. Each field becomes a flat command-line argument (field name == argument name), and the parsed +values are reconstructed into an instance of the dataclass that is passed to the command: + +```py +from dataclasses import dataclass +from typing import Annotated +from pathlib import Path + +import cmd2 +from cmd2 import with_annotated +from cmd2.annotated import Option + + +@dataclass +class CommonArgs(cmd2.ArgumentBlock): + verbose: Annotated[bool, Option("-v", "--verbose")] = False + output: Annotated[Path | None, Option("--output")] = None + + +class App(cmd2.Cmd): + @with_annotated + def do_build(self, target: str, common: CommonArgs): + self.poutput(f"{target} verbose={common.verbose} output={common.output}") +``` + +`build app --verbose --output /tmp/x` reconstructs `CommonArgs(verbose=True, output=Path("/tmp/x"))` +and passes it as `common`. The block parameter itself is never an argument, only its fields are. + +A field carries the usual `Annotated[T, Option(...)]` / `Annotated[T, Argument(...)]` metadata and +behaves exactly as a top-level parameter of the same shape would. The dataclass is the single source +of truth for defaults: a field with a default (`default` or `default_factory`) is filled by the +dataclass constructor at call time, so `default_factory` yields a fresh value per invocation and +`__post_init__` runs. A field with no default becomes a required argument. + +Inheritance is the reuse mechanism: a subclass of a block is itself a block, so a shared base block +can be extended per command without repeating its fields. + +```py +@dataclass +class TracedArgs(CommonArgs): + trace: bool = False # do_test gets verbose, output, and trace +``` + +A few rules keep blocks unambiguous: + +- The `ArgumentBlock` trait, not "is a dataclass", is the trigger. A plain `@dataclass` is left + alone and can still be used as an ordinary single value (for example via + `Argument(converter=...)`). +- A block must be the _bare_ annotation of a regular parameter. Wrapping it in + `Annotated`/`Optional`/a union, or using it as `*args`/`**kwargs`, raises a clear error. +- Because fields expand flat, a field name that collides with another parameter or another block's + field raises an error when the parser is built, rather than silently sharing a destination. +- A field name that matches the block parameter itself (e.g. `opts` on a block received as + `opts: Opts`) is rejected: the field's flat argument and the parameter that receives the block + instance would share one destination. +- A field's default must live on the dataclass field (`= value` or `= field(default_factory=...)`), + not in the `Option`/`Argument` metadata or via an action default (`append`/`count`). The dataclass + owns defaults so each call gets a fresh value, so a metadata- or action-supplied default on a + field that has no dataclass default is rejected. +- A field whose type is itself a block is not expanded (no recursion); it is rejected as an + unsupported type. + +### Sharing a block with subcommands (`cmd2_base_args` / `cmd2_parent_args`) + +A base command and its subcommands parse into one shared namespace. To share a block down the chain, +name the parameter `cmd2_base_args` on the command that owns the flags and `cmd2_parent_args` on +each subcommand that should receive it, annotating both with the same block type: + +```py +@dataclass +class SharedOpts(cmd2.ArgumentBlock): + verbose: Annotated[bool, Option("-v", "--verbose")] = False + level: Annotated[int, Option("--level")] = 1 + + +class App(cmd2.Cmd): + @with_annotated(base_command=True) + def do_root(self, cmd2_subcommand_func, cmd2_base_args: SharedOpts): + if cmd2_subcommand_func: + cmd2_subcommand_func() + + @with_annotated(subcommand_to="root", help="show the inherited block") + def root_show(self, cmd2_parent_args: SharedOpts): + self.poutput(f"verbose={cmd2_parent_args.verbose} level={cmd2_parent_args.level}") +``` + +`root --verbose --level 5 show` prints `verbose=True level=5`: the options parsed on `root` flow +into the subcommand in a typed way without being redeclared. `cmd2_base_args` adds the block's flags +to its own command's parser, while `cmd2_parent_args` adds _no_ arguments and is reconstructed from +the values an ancestor parsed (`root --verbose show`, not `root show --verbose`). A +`cmd2_parent_args` subcommand whose ancestors never declare a matching `cmd2_base_args` raises a +clear error the first time it runs. This is the typed alternative to forwarding parent-level state +through `ns_provider`. + +Each shared block field parses into a destination qualified by its block type, so two +`cmd2_base_args` blocks of different types at different levels of one chain can use the same field +name without colliding. An inherited block always receives its own type's value, regardless of what +a same-named field on another block at an intervening level parsed. Reusing the same block type at +two levels shares one destination, so the nearest level that sets the flag wins. + +A subcommand can also declare its own regular block alongside the inherited one. The two are +independent: the inherited block's flags are supplied on the parent, while the subcommand's own +block adds its flags to the subcommand's parser. + +```py +@dataclass +class RunOpts(cmd2.ArgumentBlock): + retries: Annotated[int, Option("--retries")] = 0 + + +class App(cmd2.Cmd): + @with_annotated(base_command=True) + def do_root(self, cmd2_subcommand_func, cmd2_base_args: SharedOpts): + if cmd2_subcommand_func: + cmd2_subcommand_func() + + @with_annotated(subcommand_to="root") + def root_run(self, name: str, cmd2_parent_args: SharedOpts, run: RunOpts): + # --verbose/--level come from `root`; --retries is this subcommand's own flag + self.poutput(f"run {name} verbose={cmd2_parent_args.verbose} retries={run.retries}") +``` + +`root --verbose run job --retries 3` parses `--verbose` on `root` and `--retries` on `run`. + ## Lower-level parser building [cmd2.annotated.build_parser_from_function][cmd2.annotated.build_parser_from_function] builds the diff --git a/examples/annotated_example.py b/examples/annotated_example.py index 8b10933a7..b1d98a1ed 100755 --- a/examples/annotated_example.py +++ b/examples/annotated_example.py @@ -22,6 +22,7 @@ import sys from argparse import Namespace from collections.abc import Callable +from dataclasses import dataclass from decimal import Decimal from enum import StrEnum from pathlib import Path @@ -39,6 +40,7 @@ ) from cmd2.annotated import ( Argument, + ArgumentBlock, Group, Option, with_annotated, @@ -88,6 +90,32 @@ def parse_iso(value: str) -> datetime.datetime: return datetime.datetime.fromisoformat(value) +@dataclass +class OutputOpts(ArgumentBlock): + """A reusable argument block: subclass ``ArgumentBlock`` on a ``@dataclass``. + + Each field becomes a flat command-line argument and the parsed values arrive + reconstructed as an ``OutputOpts`` instance. Several commands share these output + flags without repeating them, and a subcommand can inherit them from its parent + (see ``trace`` / ``cmd2_parent_args``). + """ + + verbose: Annotated[bool, Option("-v", "--verbose", help_text="show detail")] = False + indent: Annotated[int, Option("--indent", help_text="indent width")] = 0 + + +@dataclass +class RunOpts(ArgumentBlock): + """A second block, declared *directly* on a subcommand alongside an inherited one. + + A subcommand can combine its own block (whose flags live on the subcommand) with a + ``cmd2_parent_args`` block inherited from its parent -- see ``trace_run``. + """ + + retries: Annotated[int, Option("--retries", help_text="retry attempts on failure")] = 0 + dry_run: Annotated[bool, Option("--dry-run", help_text="don't actually run")] = False + + class AnnotatedExample(Cmd): """Demonstrates @with_annotated strengths over @with_argparser.""" @@ -528,6 +556,71 @@ def manage_project_add(self, name: str) -> None: def manage_project_list(self) -> None: self.poutput("project list: demo") + # -- Argument blocks: reuse a shared set of flags ------------------------ + # A parameter typed as an ``ArgumentBlock`` dataclass expands its fields into + # flat arguments and arrives reconstructed as an instance. ``describe`` and + # ``dump`` reuse the same ``OutputOpts`` block instead of redeclaring its flags. + + @with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_describe(self, item: str, out: OutputOpts) -> None: + """Describe an item. ``out: OutputOpts`` expands a reusable argument block. + + The block's fields (``--verbose``, ``--indent``) become flat options and + arrive as an ``OutputOpts`` instance -- the same block ``dump`` reuses. + + Try: + describe widget --verbose --indent 4 + """ + self.poutput(" " * out.indent + item + (" (verbose)" if out.verbose else "")) + + @with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_dump(self, path: str, out: OutputOpts) -> None: + """Dump a path. Reuses the same ``OutputOpts`` block as ``describe`` -- no duplicated flags. + + Try: + dump /etc/hosts --verbose + """ + self.poutput(" " * out.indent + f"dumping {path}" + (" (verbose)" if out.verbose else "")) + + # -- Sharing a block with subcommands (cmd2_base_args / cmd2_parent_args) - + # A base command and its subcommands share one namespace. The parent names the + # inheritable block ``cmd2_base_args`` (its flags land on the parent parser); a + # subcommand receives the same block, reconstructed from what the parent parsed, + # by naming its parameter ``cmd2_parent_args`` -- without redeclaring the flags. + # The flags are supplied on the parent: ``trace --verbose run job``. The subcommand + # can also declare its own block (``RunOpts`` below), whose flags live on it. + + @with_annotated(base_command=True) + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_trace(self, cmd2_base_args: OutputOpts, *, cmd2_subcommand_func: Callable[[], Any] | None = None) -> None: + """Base command whose subcommands inherit its ``OutputOpts`` block via ``cmd2_parent_args``. + + Try: + help trace + trace --verbose --indent 2 run nightly + """ + if cmd2_base_args.verbose: + self.poutput("tracing enabled") + if cmd2_subcommand_func: + cmd2_subcommand_func() + + @with_annotated(subcommand_to="trace", help="run a traced job") + def trace_run(self, name: str, cmd2_parent_args: OutputOpts, run: RunOpts) -> None: + """Run a job, combining an inherited block with the subcommand's own ``RunOpts`` block. + + ``--verbose`` / ``--indent`` are parsed on ``trace`` (inherited via ``cmd2_parent_args``); + ``--retries`` / ``--dry-run`` are this subcommand's own flags (the ``run`` block). + + Try: + trace --verbose run nightly --retries 3 + trace --indent 2 run nightly --dry-run + """ + mode = "dry-run" if run.dry_run else f"{run.retries} retries" + suffix = " (verbose)" if cmd2_parent_args.verbose else "" + self.poutput(" " * cmd2_parent_args.indent + f"run {name} [{mode}]" + suffix) + # -- Parser customization ------------------------------------------------ # The generated parser's help text and argument grouping are configurable # without dropping down to a hand-built parser. diff --git a/tests/test_annotated.py b/tests/test_annotated.py index c4df85e12..5a6b2f728 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -13,6 +13,11 @@ import inspect import types import uuid +from dataclasses import ( + InitVar, + dataclass, + field, +) from pathlib import Path from typing import ( Annotated, @@ -30,10 +35,12 @@ ) from cmd2.annotated import ( Argument, + ArgumentBlock, Group, Option, _apply_mutex_group_targets, _ArgparseArgument, + _BlockSpec, _build_argument_group_targets, _CollectionCastingAction, _invoke_command_func, @@ -41,6 +48,7 @@ _make_literal_type, _normalize_annotation, _parse_bool, + _reconstruct_dataclass_blocks, _validate_group_specs, build_parser_from_function, with_annotated, @@ -172,7 +180,8 @@ def _arg_names_via_base_command(func: Any) -> set[str]: """Resolve a base command's argument names (``base_command`` is not exposed on the public builder).""" from cmd2.annotated import _resolve_parameters - return {arg.name for arg in _resolve_parameters(func, base_command=True)} + resolved, _base_args_types = _resolve_parameters(func, base_command=True) + return {arg.name for arg in resolved} def _wrap_in_foreign_module(func: Any) -> Any: @@ -4239,3 +4248,885 @@ def test_reserved_type_hint_points_at_converter(self) -> None: """A raw ``type=`` is still rejected, now pointing the user at ``converter=``.""" with pytest.raises(TypeError, match="converter="): Argument(type=int) + + +# --------------------------------------------------------------------------- +# Dataclass argument blocks (#1689): a dataclass-typed parameter expands its +# fields into the parser (flat: field name == arg name) and is reconstructed +# into an instance at call time. +# --------------------------------------------------------------------------- + + +@dataclass +class _CommonArgs(ArgumentBlock): + verbose: Annotated[bool, Option("-v", "--verbose")] = False + output: Annotated[Path | None, Option("--output")] = None + + +@dataclass +class _TracedArgs(_CommonArgs): + """Inheritance is the "shared base block" reuse mechanism (and carries the ArgumentBlock trait).""" + + trace: bool = False + + +class TestDataclassBlockParser: + """A dataclass-typed parameter expands its fields into flat parser arguments.""" + + def test_block_fields_become_arguments(self) -> None: + def do_build(self, target: str, common: _CommonArgs) -> None: ... + + parser = build_parser_from_function(do_build) + dests = {action.dest for action in parser._actions} + assert {"target", "verbose", "output"} <= dests + # The block parameter itself is a container, not an argument. + assert "common" not in dests + + def test_block_field_option_strings_preserved(self) -> None: + def do_build(self, target: str, common: _CommonArgs) -> None: ... + + parser = build_parser_from_function(do_build) + # A field's Annotated Option metadata drives its flags exactly as a top-level option would + # (a bool option expands to --verbose/--no-verbose via BooleanOptionalAction). + verbose = next(a for a in parser._actions if a.dest == "verbose") + assert "-v" in verbose.option_strings + assert "--verbose" in verbose.option_strings + output = next(a for a in parser._actions if a.dest == "output") + assert output.option_strings == ["--output"] + + def test_inherited_block_fields_expand(self) -> None: + def do_build(self, target: str, opts: _TracedArgs) -> None: ... + + parser = build_parser_from_function(do_build) + dests = {action.dest for action in parser._actions} + assert {"target", "verbose", "output", "trace"} <= dests + + +class _DataclassBlockApp(cmd2.Cmd): + def __init__(self) -> None: + super().__init__() + self.last_block: object = None + + @with_annotated + def do_build(self, target: str, common: _CommonArgs) -> None: + self.last_block = common + self.poutput(f"target={target} verbose={common.verbose} output={common.output}") + + @with_annotated + def do_test(self, suite: str, opts: _TracedArgs) -> None: + self.poutput(f"suite={suite} verbose={opts.verbose} trace={opts.trace}") + + +@pytest.fixture +def block_app() -> _DataclassBlockApp: + app = _DataclassBlockApp() + app.stdout = cmd2.utils.StdSim(app.stdout) + return app + + +class TestDataclassBlockRuntime: + """The block is reconstructed into a dataclass instance and passed to the command.""" + + def test_block_reconstructed_with_defaults(self, block_app) -> None: + out, _err = run_cmd(block_app, "build app") + assert out == ["target=app verbose=False output=None"] + + def test_block_field_from_command_line(self, block_app) -> None: + out, _err = run_cmd(block_app, "build app --verbose --output /tmp/x") + assert out == [f"target=app verbose=True output={Path('/tmp/x')}"] + + def test_block_instance_type(self, block_app) -> None: + """The reconstructed argument is an actual instance of the declared dataclass.""" + run_cmd(block_app, "build app --verbose") + assert isinstance(block_app.last_block, _CommonArgs) + assert block_app.last_block.verbose is True + + def test_block_field_values_and_types(self, block_app) -> None: + """Each field on the reconstructed instance holds the converted value at its declared type.""" + run_cmd(block_app, "build app --verbose --output /tmp/x") + block = block_app.last_block + assert block.verbose is True + assert isinstance(block.output, Path) # converted from str to Path, not left as a string + assert block.output == Path("/tmp/x") + + def test_block_field_defaults_on_instance(self, block_app) -> None: + """An omitted field is filled by the dataclass constructor, not left absent on the instance.""" + run_cmd(block_app, "build app") + block = block_app.last_block + assert block.verbose is False + assert block.output is None + + def test_inherited_block_runtime(self, block_app) -> None: + out, _err = run_cmd(block_app, "test smoke --trace") + assert out == ["suite=smoke verbose=False trace=True"] + + +@dataclass +class _PositionalBlock(ArgumentBlock): + """A field with no default becomes a positional argument.""" + + name: str + count: int = 1 + + +@dataclass +class _FactoryBlock(ArgumentBlock): + tags: Annotated[list[str], Option("--tag", action="append")] = field(default_factory=list) + + +@dataclass +class _NestedBlock(ArgumentBlock): + inner: _CommonArgs = field(default_factory=_CommonArgs) + + +@dataclass +class _ForwardFieldBlock(ArgumentBlock): + # Stringized field annotations exercise the same get_type_hints() resolution path as a dataclass + # defined in a module using ``from __future__ import annotations``. + verbose: "Annotated[bool, Option('-v', '--verbose')]" = False + tags: "Annotated[list[str], Option('--tag', action='append')]" = field(default_factory=list) + + +class TestDataclassBlockEdgeCases: + def test_block_positional_field(self) -> None: + def do_x(self, common: _PositionalBlock) -> None: ... + + parser = build_parser_from_function(do_x) + name = next(a for a in parser._actions if a.dest == "name") + assert name.option_strings == [] # positional + count = next(a for a in parser._actions if a.dest == "count") + assert count.option_strings == ["--count"] + + def test_block_positional_field_runtime(self) -> None: + class App(cmd2.Cmd): + @with_annotated + def do_x(self, common: _PositionalBlock) -> None: + self.poutput(f"{common.name}:{common.count}") + + app = App() + app.stdout = cmd2.utils.StdSim(app.stdout) + assert run_cmd(app, "x bob --count 3")[0] == ["bob:3"] + + def test_block_default_factory(self) -> None: + class App(cmd2.Cmd): + @with_annotated + def do_x(self, opts: _FactoryBlock) -> None: + self.poutput(f"tags={opts.tags}") + + app = App() + app.stdout = cmd2.utils.StdSim(app.stdout) + assert run_cmd(app, "x")[0] == ["tags=[]"] + assert run_cmd(app, "x --tag a --tag b")[0] == ["tags=['a', 'b']"] + + def test_default_factory_not_shared_across_calls(self) -> None: + """Each invocation gets a fresh default_factory value (no shared-mutable-default bug).""" + + class App(cmd2.Cmd): + @with_annotated + def do_x(self, opts: _FactoryBlock) -> None: + opts.tags.append("mutated") + self.poutput(f"tags={opts.tags}") + + app = App() + app.stdout = cmd2.utils.StdSim(app.stdout) + # First call mutates its (default) list; the second call must not see that mutation. + assert run_cmd(app, "x")[0] == ["tags=['mutated']"] + assert run_cmd(app, "x")[0] == ["tags=['mutated']"] + + def test_block_field_default_emits_suppress(self) -> None: + """A field-default block field emits SUPPRESS so the dataclass constructor fills the default.""" + + def do_x(self, common: _CommonArgs) -> None: ... + + parser = build_parser_from_function(do_x) + verbose = next(a for a in parser._actions if a.dest == "verbose") + assert verbose.default is argparse.SUPPRESS + # An absent field stays out of the parsed namespace entirely. + assert not hasattr(parser.parse_args([]), "verbose") + + def test_nested_dataclass_field_rejected(self) -> None: + """A dataclass field whose type is itself a dataclass is not a supported scalar (no recursion).""" + + def do_x(self, opts: _NestedBlock) -> None: ... + + with pytest.raises(TypeError, match="Unsupported parameter type"): + build_parser_from_function(do_x) + + def test_field_name_collides_with_explicit_param(self) -> None: + """A block field whose name collides with an explicit parameter is a clear build error.""" + + @dataclass + class Blk(ArgumentBlock): + target: Annotated[str, Option("--target")] = "z" + + def do_x(self, target: str, blk: Blk) -> None: ... + + with pytest.raises(TypeError, match=r"target.*more than once"): + build_parser_from_function(do_x) + + def test_field_name_collides_across_two_blocks(self) -> None: + """Two blocks sharing a field name collide regardless of flags (same namespace dest).""" + + @dataclass + class BlkA(ArgumentBlock): + x: Annotated[int, Option("--x")] = 0 + + @dataclass + class BlkB(ArgumentBlock): + x: Annotated[int, Option("--xx")] = 0 # different flag, same field/dest name + + def do_x(self, a: BlkA, b: BlkB) -> None: ... + + with pytest.raises(TypeError, match=r"x.*more than once"): + build_parser_from_function(do_x) + + def test_field_named_like_block_param_rejected(self) -> None: + """A block field whose name equals the block parameter name is rejected: the field's flat argument + and the parameter receiving the block instance would share one keyword-argument key.""" + + @dataclass + class Opts(ArgumentBlock): + opts: Annotated[str, Option("--opts")] = "x" + + def do_x(self, opts: Opts) -> None: ... + + with pytest.raises(TypeError, match=r"field 'opts' collides with the block parameter name 'opts'"): + build_parser_from_function(do_x) + + def test_init_false_field_named_like_block_param_allowed(self) -> None: + """A field(init=False) is not a command-line argument, so sharing the block parameter name is fine + (no flat argument shadows the parameter) and must not be rejected.""" + + @dataclass + class Opts(ArgumentBlock): + name: Annotated[str, Option("--name")] = "n" + opts: str = field(init=False, default="z") + + def do_x(self, opts: Opts) -> None: ... + + parser = build_parser_from_function(do_x) + assert {a.dest for a in parser._actions if a.dest != "help"} == {"name"} + + def test_block_field_append_action_without_dataclass_default_rejected(self) -> None: + """An append block field with no dataclass default would emit a shared `[]` argparse default that + argparse reuses across invocations; reject it (the default must live on the dataclass field).""" + + @dataclass + class Blk(ArgumentBlock): + tags: Annotated[list[str], Option("--tag", action="append")] # no dataclass default + + def do_x(self, blk: Blk) -> None: ... + + with pytest.raises(TypeError, match=r"field 'tags'.*default.*must live on the dataclass field"): + build_parser_from_function(do_x) + + def test_block_field_metadata_default_without_dataclass_default_rejected(self) -> None: + """A metadata `Option(default=...)` on a block field with no dataclass default bypasses the + dataclass as the single source of truth; reject it.""" + + @dataclass + class Blk(ArgumentBlock): + count: Annotated[int, Option("--count", default=3)] # default in metadata, not the field + + def do_x(self, blk: Blk) -> None: ... + + with pytest.raises(TypeError, match=r"field 'count'.*default.*must live on the dataclass field"): + build_parser_from_function(do_x) + + def test_post_init_runs(self) -> None: + """Reconstruction goes through the dataclass constructor, so __post_init__ runs.""" + + @dataclass + class PostInit(ArgumentBlock): + width: int = 2 + doubled: int = 0 + + def __post_init__(self) -> None: + self.doubled = self.width * 2 + + class App(cmd2.Cmd): + @with_annotated + def do_x(self, opts: PostInit) -> None: + self.poutput(f"doubled={opts.doubled}") + + app = App() + app.stdout = cmd2.utils.StdSim(app.stdout) + assert run_cmd(app, "x --width 5")[0] == ["doubled=10"] + assert run_cmd(app, "x")[0] == ["doubled=4"] + + def test_required_field_errors_when_omitted(self) -> None: + """A field with no default is a required argument.""" + + @dataclass + class Req(ArgumentBlock): + host: Annotated[str, Option("--host")] + + class App(cmd2.Cmd): + @with_annotated + def do_x(self, opts: Req) -> None: + self.poutput(opts.host) + + app = App() + app.stdout = cmd2.utils.StdSim(app.stdout) + _out, err = run_cmd(app, "x") + assert any("host" in line.lower() and ("required" in line.lower() or "error" in line.lower()) for line in err) + assert run_cmd(app, "x --host db")[0] == ["db"] + + def test_plain_dataclass_is_not_a_block(self) -> None: + """A plain @dataclass (no ArgumentBlock trait) is never expanded; it is an ordinary value.""" + + @dataclass + class Plain: + x: int = 0 + y: int = 0 + + def do_x(self, p: Plain) -> None: ... + + # Without the trait it falls through to the normal type path: a dataclass scalar with no converter + # is an unsupported type (it is not silently decomposed into x/y). + with pytest.raises(TypeError, match="Unsupported parameter type"): + build_parser_from_function(do_x) + + def test_plain_dataclass_with_converter_is_single_value(self) -> None: + """A plain @dataclass used as a single value (via a converter) is one argument, not a block.""" + + @dataclass + class Point: + x: int = 0 + y: int = 0 + + def parse_point(s: str) -> Point: + a, b = s.split(",") + return Point(int(a), int(b)) + + class App(cmd2.Cmd): + @with_annotated + def do_x(self, p: Annotated[Point, Argument(converter=parse_point)]) -> None: + self.poutput(f"{type(p).__name__}({p.x},{p.y})") + + app = App() + app.stdout = cmd2.utils.StdSim(app.stdout) + parser = build_parser_from_function(App.do_x.__wrapped__) # single 'p' arg, not decomposed + assert [a.dest for a in parser._actions if a.dest != "help"] == ["p"] + assert run_cmd(app, "x 3,4")[0] == ["Point(3,4)"] + + def test_optional_block_rejected(self) -> None: + """An ArgumentBlock combined with Optional (``Block | None``) is rejected with a clear message.""" + + @dataclass + class Blk(ArgumentBlock): + x: int = 0 + + def do_x(self, blk: Blk | None) -> None: ... + + with pytest.raises(TypeError, match="bare annotation"): + build_parser_from_function(do_x) + + def test_union_of_blocks_rejected(self) -> None: + """A union of ArgumentBlocks (``BlockA | BlockB``) is rejected with a clear message.""" + + @dataclass + class BlkA(ArgumentBlock): + x: int = 0 + + @dataclass + class BlkB(ArgumentBlock): + y: int = 0 + + def do_x(self, blk: BlkA | BlkB) -> None: ... + + with pytest.raises(TypeError, match="bare annotation"): + build_parser_from_function(do_x) + + def test_annotated_block_rejected(self) -> None: + """An ArgumentBlock wrapped in Annotated is rejected (a block must be the bare annotation).""" + + @dataclass + class Blk(ArgumentBlock): + x: int = 0 + + def do_x(self, blk: Annotated[Blk, "doc"]) -> None: ... + + with pytest.raises(TypeError, match="bare annotation"): + build_parser_from_function(do_x) + + def test_argument_block_without_dataclass_rejected(self) -> None: + """An ArgumentBlock subclass that is not a @dataclass has no fields; reject with guidance.""" + + class NotADataclass(ArgumentBlock): + x: int = 0 + + def do_x(self, blk: NotADataclass) -> None: ... + + with pytest.raises(TypeError, match="must be decorated with @dataclass"): + build_parser_from_function(do_x) + + def test_stringized_field_annotations_resolve(self) -> None: + """A block whose field hints are strings (forward refs / ``from __future__ import annotations``) + resolves through get_type_hints and behaves identically. + """ + + class App(cmd2.Cmd): + @with_annotated + def do_x(self, target: "str", common: "_ForwardFieldBlock") -> None: + self.poutput(f"{target} verbose={common.verbose} tags={common.tags}") + + app = App() + app.stdout = cmd2.utils.StdSim(app.stdout) + assert run_cmd(app, "x app --verbose --tag a --tag b")[0] == ["app verbose=True tags=['a', 'b']"] + assert run_cmd(app, "x app2")[0] == ["app2 verbose=False tags=[]"] + + def test_block_parameter_default_rejected(self) -> None: + """The dataclass owns its fields' defaults, so a default on the block parameter is rejected.""" + + @dataclass + class Blk(ArgumentBlock): + verbose: Annotated[bool, Option("-v")] = False + + def do_x(self, blk: Blk = Blk()) -> None: ... # noqa: B008 — the block-param default is the thing under test + + with pytest.raises(TypeError, match="cannot have a default value"): + build_parser_from_function(do_x) + + def test_field_name_collides_with_reserved_namespace_attr(self) -> None: + """A field named like a cmd2-injected namespace attr would be overwritten at parse time; reject it.""" + + @dataclass + class Blk(ArgumentBlock): + cmd2_statement: Annotated[str, Option("--stmt")] = "x" + + def do_x(self, blk: Blk) -> None: ... + + with pytest.raises(TypeError, match="reserved cmd2 namespace attribute"): + build_parser_from_function(do_x) + + def test_block_field_both_defaults_error_names_dataclass_source(self) -> None: + """A field with both a dataclass default and a metadata default is rejected, naming the right source. + + The block field's signature default lives on the dataclass (its internal ``param_default`` is a + placeholder ``None``), so the conflict message must say "dataclass field", not "function signature (None)". + """ + + @dataclass + class Blk(ArgumentBlock): + level: Annotated[int, Option("--level", default=5)] = 2 + + def do_x(self, blk: Blk) -> None: ... + + with pytest.raises(TypeError, match=r"both the dataclass field and the metadata \(5\)"): + build_parser_from_function(do_x) + + def test_initvar_field_rejected(self) -> None: + """An InitVar is required by the constructor but never becomes a CLI argument; reject it clearly.""" + + @dataclass + class Blk(ArgumentBlock): + ratio: InitVar[int] + name: Annotated[str, Option("--name")] = "n" + + def __post_init__(self, ratio: int) -> None: + self.ratio = ratio + + def do_x(self, blk: Blk) -> None: ... + + with pytest.raises(TypeError, match="InitVar"): + build_parser_from_function(do_x) + + def test_init_false_field_not_expanded(self) -> None: + """A field(init=False) is not a constructor argument, so it gets no CLI option and is left to + __post_init__ / its default rather than being expanded into a command-line value.""" + + @dataclass + class Blk(ArgumentBlock): + name: Annotated[str, Option("--name")] = "n" + computed: str = field(init=False, default="auto") + + def __post_init__(self) -> None: + self.computed = self.name.upper() + + def do_x(self, blk: Blk) -> None: ... + + parser = build_parser_from_function(do_x) + dests = {a.dest for a in parser._actions if a.dest != "help"} + assert dests == {"name"} # computed (init=False) is not a command-line argument + + class App(cmd2.Cmd): + @with_annotated + def do_x(self, blk: Blk) -> None: + self.poutput(f"name={blk.name} computed={blk.computed}") + + app = App() + app.stdout = cmd2.utils.StdSim(app.stdout) + assert run_cmd(app, "x --name bob")[0] == ["name=bob computed=BOB"] + + def test_unresolvable_field_hint_raises_block_error(self) -> None: + """An unresolvable forward reference on a block field aborts with a clear, ArgumentBlock-specific + error rather than leaking the opaque NameError raised by typing.get_type_hints.""" + + @dataclass + class Blk(ArgumentBlock): + x: Annotated["NoSuchType", Option("--x")] = 0 # noqa: F821 -- intentionally undefined + + def do_x(self, blk: Blk) -> None: ... + + with pytest.raises(TypeError, match=r"Failed to resolve type hints for ArgumentBlock"): + build_parser_from_function(do_x) + + +class _SubcommandBlockApp(cmd2.Cmd): + @with_annotated(base_command=True) + def do_db(self, cmd2_subcommand_func) -> None: + if cmd2_subcommand_func: + cmd2_subcommand_func() + + @with_annotated(subcommand_to="db") + def db_migrate(self, name: str, common: _CommonArgs) -> None: + self.poutput(f"migrate {name} verbose={common.verbose}") + + +class TestDataclassBlockSubcommand: + def test_subcommand_block_reconstructed(self) -> None: + app = _SubcommandBlockApp() + app.stdout = cmd2.utils.StdSim(app.stdout) + out, _err = run_cmd(app, "db migrate users --verbose") + assert out == ["migrate users verbose=True"] + + +# --------------------------------------------------------------------------- +# Shared blocks: a command declares an inheritable block as ``cmd2_base_args`` +# and its subcommands receive it as ``cmd2_parent_args`` instead of re-declaring +# the arguments. This is the typed answer to passing parent-level args down to +# subcommands (#1690). +# --------------------------------------------------------------------------- + + +@dataclass +class _SharedOpts(ArgumentBlock): + verbose: Annotated[bool, Option("-v", "--verbose")] = False + level: Annotated[int, Option("--level")] = 1 + + +class _InheritBlockApp(cmd2.Cmd): + def __init__(self) -> None: + super().__init__() + self.received: object = None + + @with_annotated(base_command=True) + def do_root(self, cmd2_subcommand_func, cmd2_base_args: _SharedOpts) -> None: + """Parent declares the inheritable block, so its fields land on the base parser.""" + if cmd2_subcommand_func: + cmd2_subcommand_func() + + @with_annotated(subcommand_to="root", help="show the inherited block") + def root_show(self, cmd2_parent_args: _SharedOpts) -> None: + self.received = cmd2_parent_args + self.poutput(f"verbose={cmd2_parent_args.verbose} level={cmd2_parent_args.level}") + + +@pytest.fixture +def inherit_app() -> _InheritBlockApp: + app = _InheritBlockApp() + app.stdout = cmd2.utils.StdSim(app.stdout) + return app + + +class TestParentArgsInheritance: + def test_subcommand_inherits_parent_block_values(self, inherit_app) -> None: + """Values parsed on the parent flow into the subcommand's inherited block.""" + out, _err = run_cmd(inherit_app, "root --verbose --level 5 show") + assert out == ["verbose=True level=5"] + + def test_inherited_block_uses_parent_defaults_when_omitted(self, inherit_app) -> None: + """An option the parent did not receive arrives at its declared default, not absent.""" + out, _err = run_cmd(inherit_app, "root show") + assert out == ["verbose=False level=1"] + + def test_inherited_block_reconstructed_instance(self, inherit_app) -> None: + """The subcommand receives a real dataclass instance, not loose values.""" + run_cmd(inherit_app, "root --verbose show") + assert isinstance(inherit_app.received, _SharedOpts) + assert inherit_app.received.verbose is True + + def test_inherited_block_fields_not_re_added_to_subparser(self, inherit_app) -> None: + """The inherited block adds no arguments to the subparser; its flags live only on the parent.""" + _out, err = run_cmd(inherit_app, "root show --verbose") + assert any("unrecognized arguments" in line for line in err), err + + def test_grandparent_declares_leaf_inherits(self) -> None: + """A leaf subcommand inherits a block its grandparent declared (an intermediate level in between).""" + + class App(cmd2.Cmd): + @with_annotated(base_command=True) + def do_root(self, cmd2_subcommand_func, cmd2_base_args: _SharedOpts) -> None: + if cmd2_subcommand_func: + cmd2_subcommand_func() + + @with_annotated(subcommand_to="root", base_command=True) + def root_show(self, cmd2_subcommand_func) -> None: + if cmd2_subcommand_func: + cmd2_subcommand_func() + + @with_annotated(subcommand_to="root show") + def root_show_detail(self, cmd2_parent_args: _SharedOpts) -> None: + self.poutput(f"verbose={cmd2_parent_args.verbose}") + + app = App() + app.stdout = cmd2.utils.StdSim(app.stdout) + assert run_cmd(app, "root --verbose show detail")[0] == ["verbose=True"] + + def test_intermediate_declares_leaf_inherits(self) -> None: + """An intermediate command declares the block and a deeper subcommand inherits it. + + The intermediate command's handler never runs in the dispatch chain (only the entry base command + and the leaf do), so the marker must come from the *parser* at parse time, not a running handler. + """ + + class App(cmd2.Cmd): + @with_annotated(base_command=True) + def do_root(self, cmd2_subcommand_func) -> None: + if cmd2_subcommand_func: + cmd2_subcommand_func() + + @with_annotated(subcommand_to="root", base_command=True) + def root_show(self, cmd2_subcommand_func, cmd2_base_args: _SharedOpts) -> None: + if cmd2_subcommand_func: + cmd2_subcommand_func() + + @with_annotated(subcommand_to="root show") + def root_show_detail(self, cmd2_parent_args: _SharedOpts) -> None: + self.poutput(f"verbose={cmd2_parent_args.verbose}") + + app = App() + app.stdout = cmd2.utils.StdSim(app.stdout) + assert run_cmd(app, "root show --verbose detail")[0] == ["verbose=True"] + + def test_inherited_field_colliding_with_own_arg_rejected(self) -> None: + """An inherited field that also names the subcommand's own argument is rejected at build time.""" + + def root_show(self, verbose: str, cmd2_parent_args: _SharedOpts) -> None: ... + + with pytest.raises(TypeError, match="inherited ArgumentBlock field"): + build_parser_from_function(root_show) + + def test_parent_args_must_be_bare_block(self) -> None: + """``cmd2_parent_args`` must be annotated with a bare ArgumentBlock subclass.""" + + def root_show(self, cmd2_parent_args: int) -> None: ... + + with pytest.raises(TypeError, match="must be annotated with a bare ArgumentBlock"): + build_parser_from_function(root_show) + + def test_parent_args_default_rejected(self) -> None: + """An inherited block always comes from the parent, so a default on it is rejected.""" + + def root_show(self, cmd2_parent_args: _SharedOpts = _SharedOpts()) -> None: ... # noqa: B008 + + with pytest.raises(TypeError, match="cannot have a default value"): + build_parser_from_function(root_show) + + def test_base_args_must_be_block(self) -> None: + """``cmd2_base_args`` must be annotated with a bare ArgumentBlock subclass.""" + + def do_root(self, cmd2_base_args: int) -> None: ... + + with pytest.raises(TypeError, match="must be annotated with a bare ArgumentBlock"): + build_parser_from_function(do_root) + + def test_missing_parent_declaration_errors_at_runtime(self) -> None: + """A cmd2_parent_args subcommand whose ancestors never declare cmd2_base_args errors when first run. + + Registration succeeds (the misconfiguration is detectable only once the shared namespace exists), so + the app constructs cleanly and the clear error surfaces on first invocation of the subcommand. + """ + + class App(cmd2.Cmd): + @with_annotated(base_command=True) + def do_root(self, cmd2_subcommand_func) -> None: # parent does NOT declare cmd2_base_args + if cmd2_subcommand_func: + cmd2_subcommand_func() + + @with_annotated(subcommand_to="root") + def root_show(self, cmd2_parent_args: _SharedOpts) -> None: ... + + app = App() # construction must NOT raise + app.stdout = cmd2.utils.StdSim(app.stdout) + _out, err = run_cmd(app, "root show") + assert any("no ancestor command declares" in line for line in err), err + + def test_type_mismatch_errors_at_runtime(self) -> None: + """A cmd2_parent_args whose type no ancestor declared as cmd2_base_args errors when first run.""" + + @dataclass + class _OtherOpts(ArgumentBlock): + level: Annotated[int, Option("--level")] = 0 + + class App(cmd2.Cmd): + @with_annotated(base_command=True) + def do_root(self, cmd2_subcommand_func, cmd2_base_args: _SharedOpts) -> None: + if cmd2_subcommand_func: + cmd2_subcommand_func() + + @with_annotated(subcommand_to="root") + def root_show(self, cmd2_parent_args: _OtherOpts) -> None: ... + + app = App() + app.stdout = cmd2.utils.StdSim(app.stdout) + _out, err = run_cmd(app, "root show") + assert any("no ancestor command declares" in line for line in err), err + + def test_directly_supplied_inherited_block_skips_reconstruction_and_check(self) -> None: + """A block already supplied (a direct call passing the instance) is used as-is. + + Reconstruction is skipped, and for an inherited block so is the ancestor-presence check -- the + caller provided the instance, so there is nothing to reconstruct or verify. + """ + spec = _BlockSpec(_SharedOpts, ["verbose", "level"], inherited=True) + provided = _SharedOpts(verbose=True, level=9) + func_kwargs = {"cmd2_parent_args": provided} + # An empty namespace carries no presence marker; the directly-supplied instance must bypass it. + _reconstruct_dataclass_blocks(func_kwargs, {"cmd2_parent_args": spec}, argparse.Namespace()) + assert func_kwargs["cmd2_parent_args"] is provided + + def test_directly_supplied_block_pops_stray_field_values(self) -> None: + """A directly-supplied instance wins, and parsed field values are dropped (not stranded as stray kwargs). + + A programmatic call may pass the block instance while the command line also parsed some of its fields + into the namespace. Those expanded field names are not parameters of the command function, so they must + be popped even though reconstruction is skipped -- otherwise the call fails with an unexpected keyword. + """ + spec = _BlockSpec(_SharedOpts, ["verbose", "level"], inherited=False) + provided = _SharedOpts(verbose=True, level=9) + # 'verbose' was parsed from the command line; 'cmd2_parent_args'/'common' supplied directly. + func_kwargs = {"common": provided, "verbose": False, "target": "app"} + _reconstruct_dataclass_blocks(func_kwargs, {"common": spec}, argparse.Namespace()) + assert func_kwargs["common"] is provided + assert "verbose" not in func_kwargs # the stray parsed field value was dropped + assert func_kwargs["target"] == "app" # an unrelated command parameter is untouched + + def test_directly_supplied_block_via_command_does_not_strand_fields(self) -> None: + """End-to-end: passing a block instance to a decorated command while the line parses a field succeeds.""" + + class App(cmd2.Cmd): + @with_annotated + def do_build(self, target: str, common: _CommonArgs) -> None: + self.poutput(f"target={target} verbose={common.verbose}") + + app = App() + app.stdout = cmd2.utils.StdSim(app.stdout) + # The directly-supplied instance is used; the parsed --verbose does not crash the call as a stray kwarg. + app.do_build("app --verbose", common=_CommonArgs(verbose=False)) + assert app.stdout.getvalue().splitlines() == ["target=app verbose=False"] + + +# --------------------------------------------------------------------------- +# A base command and its subcommands share one parsed namespace. Each shared +# (cmd2_base_args/cmd2_parent_args) block field parses into a type-qualified +# dest, so two blocks of different types that happen to share a field name never +# collide on one attribute -- an inherited block always sees its own type's +# value rather than whatever a different block at another chain level wrote. +# --------------------------------------------------------------------------- + + +@dataclass +class _ChainBaseA(ArgumentBlock): + level: Annotated[int, Option("--level")] = 1 + only_a: Annotated[str, Option("--only-a")] = "a" + + +@dataclass +class _ChainBaseBSameName(ArgumentBlock): + level: Annotated[int, Option("--level")] = 2 # same field name as _ChainBaseA, different type + + +@dataclass +class _ChainBaseBDistinct(ArgumentBlock): + region: Annotated[str, Option("--region")] = "us" # distinct field name + + +class TestBaseArgsChainNamespacing: + def test_same_field_name_in_different_block_types_is_isolated(self) -> None: + """Two base_args blocks of different types sharing a field name do not collide: the leaf inheriting + _ChainBaseA sees root's value, not the _ChainBaseBSameName value parsed at the intervening level.""" + + class App(cmd2.Cmd): + @with_annotated(base_command=True) + def do_root(self, cmd2_subcommand_func, cmd2_base_args: _ChainBaseA) -> None: + if cmd2_subcommand_func: + cmd2_subcommand_func() + + @with_annotated(subcommand_to="root", base_command=True) + def root_mid(self, cmd2_subcommand_func, cmd2_base_args: _ChainBaseBSameName) -> None: + if cmd2_subcommand_func: + cmd2_subcommand_func() + + @with_annotated(subcommand_to="root mid") + def root_mid_leaf(self, cmd2_parent_args: _ChainBaseA) -> None: + self.poutput(f"level={cmd2_parent_args.level} only_a={cmd2_parent_args.only_a}") + + app = App() + app.stdout = cmd2.utils.StdSim(app.stdout) + out, _err = run_cmd(app, "root --level 5 mid --level 9 leaf") + assert out == ["level=5 only_a=a"] # root's BaseA.level, not mid's BaseB.level=9 + + def test_distinct_fields_across_base_args_levels(self) -> None: + """Distinct field names across levels also reach the inherited block with the right value.""" + + class App(cmd2.Cmd): + @with_annotated(base_command=True) + def do_root(self, cmd2_subcommand_func, cmd2_base_args: _ChainBaseA) -> None: + if cmd2_subcommand_func: + cmd2_subcommand_func() + + @with_annotated(subcommand_to="root", base_command=True) + def root_mid(self, cmd2_subcommand_func, cmd2_base_args: _ChainBaseBDistinct) -> None: + if cmd2_subcommand_func: + cmd2_subcommand_func() + + @with_annotated(subcommand_to="root mid") + def root_mid_leaf(self, cmd2_parent_args: _ChainBaseA) -> None: + self.poutput(f"level={cmd2_parent_args.level} only_a={cmd2_parent_args.only_a}") + + app = App() + app.stdout = cmd2.utils.StdSim(app.stdout) + out, _err = run_cmd(app, "root --level 5 mid --region eu leaf") + assert out == ["level=5 only_a=a"] + + def test_same_base_args_type_at_two_levels_nearest_wins(self) -> None: + """Reusing the *same* block type at two levels shares one (type-qualified) dest, so the nearest + level that sets the flag wins -- a well-defined override, not a cross-type corruption.""" + + class App(cmd2.Cmd): + @with_annotated(base_command=True) + def do_root(self, cmd2_subcommand_func, cmd2_base_args: _ChainBaseA) -> None: + if cmd2_subcommand_func: + cmd2_subcommand_func() + + @with_annotated(subcommand_to="root", base_command=True) + def root_mid(self, cmd2_subcommand_func, cmd2_base_args: _ChainBaseA) -> None: + if cmd2_subcommand_func: + cmd2_subcommand_func() + + @with_annotated(subcommand_to="root mid") + def root_mid_leaf(self, cmd2_parent_args: _ChainBaseA) -> None: + self.poutput(f"level={cmd2_parent_args.level}") + + app = App() + app.stdout = cmd2.utils.StdSim(app.stdout) + assert run_cmd(app, "root --level 5 mid --level 9 leaf")[0] == ["level=9"] # nearest (mid) wins + assert run_cmd(app, "root --level 5 mid leaf")[0] == ["level=5"] # absent mid does not overwrite + + def test_positional_shared_field_keeps_clean_metavar(self) -> None: + """A positional field in a shared block parses into a type-qualified dest (so it can't collide + across a chain), but its help metavar stays the field name -- the qualified dest never leaks.""" + + @dataclass + class Base(ArgumentBlock): + target: Annotated[str, Argument()] # positional + level: Annotated[int, Option("--level")] = 1 + + def do_x(self, cmd2_base_args: Base) -> None: ... + + parser = build_parser_from_function(do_x) + positional = next(a for a in parser._actions if not a.option_strings and a.dest != "help") + assert positional.metavar == "target" # field name shown in help, not the internal dest + assert positional.dest != "target" # but the dest is type-qualified to avoid chain collisions