From 7e41c10f179e1419df3c0a5972d33784a2c997bc Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Wed, 6 May 2026 22:11:13 +0100 Subject: [PATCH 01/25] feat: add annotated argparse building Adds @with_annotated decorator that builds argparse parsers from type-annotated function signatures. Supports Annotated[T, Argument(...)] / Annotated[T, Option(...)] metadata, automatic positional/option detection, optional unwrapping, collections, enums, literals, Path completion, subcommands via subcommand_to=, base_command=True with cmd2_handler dispatch, and argument/mutually-exclusive groups. - New module cmd2/annotated.py with Argument, Option, with_annotated, and build_parser_from_function helpers - Comprehensive test suite in tests/test_annotated.py - Example in examples/annotated_example.py - Docs updates in docs/features/argument_processing.md --- cmd2/__init__.py | 9 + cmd2/annotated.py | 1078 ++++++++++++++++++++ docs/features/argument_processing.md | 228 ++++- examples/annotated_example.py | 316 ++++++ tests/test_annotated.py | 1391 ++++++++++++++++++++++++++ 5 files changed, 3017 insertions(+), 5 deletions(-) create mode 100644 cmd2/annotated.py create mode 100755 examples/annotated_example.py create mode 100644 tests/test_annotated.py diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 2d13650ae..98ba7e752 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -11,6 +11,11 @@ rich_utils, string_utils, ) +from .annotated import ( + Argument, + Option, + with_annotated, +) from .argparse_completer import set_default_ap_completer_type from .argparse_utils import ( Cmd2ArgumentParser, @@ -87,7 +92,11 @@ "Choices", "CompletionItem", "Completions", + # Annotated + "Argument", + "Option", # Decorators + "with_annotated", "with_argument_list", "with_argparser", "with_category", diff --git a/cmd2/annotated.py b/cmd2/annotated.py new file mode 100644 index 000000000..910733f91 --- /dev/null +++ b/cmd2/annotated.py @@ -0,0 +1,1078 @@ +"""Build argparse parsers from type-annotated function signatures. + +.. warning:: Experimental + + This module is experimental and its behavior may change in future releases. + +This module provides the :func:`with_annotated` decorator that inspects a +command function's type hints and default values to automatically construct +a ``Cmd2ArgumentParser``. It also provides :class:`Argument` and +:class:`Option` metadata classes for use with ``typing.Annotated`` when +finer control is needed. + +Basic usage -- parameters without defaults become positional arguments, +parameters with defaults become ``--option`` flags. Keyword-only +parameters (after ``*``) always become options; without a default they +are required. The parameter name ``dest`` is reserved and cannot be +used:: + + class MyApp(cmd2.Cmd): + @cmd2.with_annotated + def do_greet(self, name: str, count: int = 1, loud: bool = False): + for _ in range(count): + msg = f"Hello {name}" + self.poutput(msg.upper() if loud else msg) + +Use ``Annotated`` with :class:`Argument` or :class:`Option` for finer +control over individual parameters:: + + from typing import Annotated + + class MyApp(cmd2.Cmd): + def color_choices(self) -> cmd2.Choices: + return cmd2.Choices.from_values(["red", "green", "blue"]) + + @cmd2.with_annotated + def do_paint( + self, + item: str, + color: Annotated[str, Option("--color", "-c", + choices_provider=color_choices, + help_text="Color to use")] = "blue", + ): + self.poutput(f"Painting {item} {color}") + +How annotations map to argparse settings: + +- ``str`` -- default string argument +- ``int``, ``float`` -- sets ``type=`` for argparse +- ``bool`` with default -- ``--flag / --no-flag`` via ``BooleanOptionalAction`` +- positional ``bool`` -- parsed from ``true/false``, ``yes/no``, ``on/off``, ``1/0`` +- ``pathlib.Path`` -- sets ``type=Path`` +- ``enum.Enum`` subclass -- ``type=converter``, ``choices`` from member values +- ``decimal.Decimal`` -- sets ``type=Decimal`` +- ``Literal[...]`` -- sets ``type=converter`` and ``choices`` from literal values +- ``list[T]`` / ``set[T]`` / ``tuple[T, ...]`` -- ``nargs='+'`` (or ``'*'`` if has a default) +- ``tuple[T, T]`` (fixed arity, same type) -- ``nargs=N`` with ``type=T`` +- ``T | None`` -- unwrapped to ``T``, treated as optional + +Action compatibility note: + +- Some argparse actions (``count``, ``store_true``, ``store_false``, + ``store_const``, ``help``, ``version``) do not accept ``type=``. + If one of these actions is selected via ``Option(action=...)``, any + inferred ``type`` converter is removed before calling ``add_argument()``. + +Unsupported patterns (raise ``TypeError``): + +- ``str | int`` -- union of multiple non-None types is ambiguous +- ``tuple[int, str, float]`` -- mixed element types are not currently supported + because argparse can only apply a single ``type=`` converter per argument + +When combining ``Annotated`` with ``Optional``, the union must go +*inside*: ``Annotated[T | None, meta]``. Writing +``Annotated[T, meta] | None`` is ambiguous and raises ``TypeError``. + +Note: ``Path`` and ``Enum`` annotations with ``@with_annotated`` also get +automatic tab completion via generated parser metadata. +If a user-supplied ``choices_provider`` or ``completer`` is set on an argument, +it always takes priority over the type-inferred completion. +""" + +import argparse +import decimal +import enum +import functools +import inspect +import types +from collections.abc import Callable, Container, Sequence +from pathlib import Path +from typing import ( + Annotated, + Any, + ClassVar, + Literal, + Union, + get_args, + get_origin, + get_type_hints, +) + +from . import constants +from .cmd2 import Cmd +from .completion import CompletionItem +from .decorators import _parse_positionals +from .exceptions import Cmd2ArgparseError +from .types import CmdOrSetT, UnboundChoicesProvider, UnboundCompleter + +# --------------------------------------------------------------------------- +# Metadata classes +# --------------------------------------------------------------------------- + + +class _BaseArgMetadata: + """Shared fields for ``Argument`` and ``Option`` metadata.""" + + _KWARGS_MAP: ClassVar[dict[str, str]] = { + "help_text": "help", + "metavar": "metavar", + "choices": "choices", + "choices_provider": "choices_provider", + "completer": "completer", + "table_columns": "table_columns", + "suppress_tab_hint": "suppress_tab_hint", + "nargs": "nargs", + } + + def __init__( + self, + *, + help_text: str | None = None, + metavar: str | None = None, + nargs: int | str | tuple[int, ...] | None = None, + choices: list[Any] | None = None, + choices_provider: UnboundChoicesProvider[CmdOrSetT] | None = None, + completer: UnboundCompleter[CmdOrSetT] | None = None, + table_columns: tuple[str, ...] | None = None, + suppress_tab_hint: bool | None = None, + ) -> None: + """Initialise shared metadata fields.""" + self.help_text = help_text + self.metavar = metavar + self.nargs = nargs + self.choices = choices + self.choices_provider = choices_provider + self.completer = completer + self.table_columns = table_columns + self.suppress_tab_hint = suppress_tab_hint + + def to_kwargs(self) -> dict[str, Any]: + """Return non-None fields as an argparse kwargs dict.""" + return {kwarg: val for attr, kwarg in self._KWARGS_MAP.items() if (val := getattr(self, attr)) is not None} + + +class Argument(_BaseArgMetadata): + """Metadata for a positional argument in an ``Annotated`` type hint. + + Example:: + + def do_greet(self, name: Annotated[str, Argument(help_text="Person to greet")]): + ... + """ + + +class Option(_BaseArgMetadata): + """Metadata for an optional/flag argument in an ``Annotated`` type hint. + + Positional ``*names`` are the flag strings (e.g. ``"--color"``, ``"-c"``). + When omitted, the decorator auto-generates ``--param_name``. + + Example:: + + def do_paint( + self, + color: Annotated[str, Option("--color", "-c", help_text="Color")] = "blue", + ): + ... + """ + + def __init__( + self, + *names: str, + action: str | None = None, + required: bool = False, + **kwargs: Any, + ) -> None: + """Initialise Option metadata.""" + super().__init__(**kwargs) + self.names = names + self.action = action + self.required = required + + def to_kwargs(self) -> dict[str, Any]: + """Return non-None fields as an argparse kwargs dict.""" + kwargs = super().to_kwargs() + if self.action: + kwargs["action"] = self.action + if self.required: + kwargs["required"] = self.required + return kwargs + + +#: Metadata extracted from ``Annotated[T, meta]``, or ``None`` for plain types. +ArgMetadata = Argument | Option | None + +_NormalizedAnnotation = tuple[Any, ArgMetadata, bool] +_ResolvedParam = tuple[str, ArgMetadata, bool, list[str], dict[str, Any]] +_ArgumentTarget = argparse.ArgumentParser | argparse._MutuallyExclusiveGroup | argparse._ArgumentGroup + + +# --------------------------------------------------------------------------- +# Type resolvers +# --------------------------------------------------------------------------- +# +# Each resolver: (tp, args, *, is_positional, has_default, default, metadata) -> dict +# The returned dict is merged into the argparse kwargs. +# Internal keys ('base_type', 'is_collection', 'is_bool_flag') are stripped +# before passing to argparse. +# --------------------------------------------------------------------------- + +_BOOL_TRUE_VALUES = ["1", "true", "t", "yes", "y", "on"] +_BOOL_FALSE_VALUES = ["0", "false", "f", "no", "n", "off"] +_ACTIONS_DISALLOW_TYPE = frozenset({"count", "store_true", "store_false", "store_const", "help", "version"}) +_BOOL_CHOICES = [CompletionItem(True, text=text) for text in _BOOL_TRUE_VALUES] + [ + CompletionItem(False, text=text) for text in _BOOL_FALSE_VALUES +] + + +def _parse_bool(value: str) -> bool: + """Parse a string into a boolean value for argparse type conversion.""" + lowered = value.strip().lower() + if lowered in _BOOL_TRUE_VALUES: + return True + if lowered in _BOOL_FALSE_VALUES: + return False + raise argparse.ArgumentTypeError(f"invalid boolean value: {value!r} (choose from: 1, 0, true, false, yes, no, on, off)") + + +def _make_literal_type(literal_values: list[Any]) -> Callable[[str], Any]: + """Create an argparse converter for a Literal's exact values.""" + value_map: dict[str, Any] = {} + for value in literal_values: + key = str(value) + if key in value_map and value_map[key] is not value: + raise TypeError( + f"Literal values {value_map[key]!r} and {value!r} have the same string " + f"representation {key!r} and cannot be distinguished on the command line." + ) + value_map[key] = value + + def _convert(value: str) -> Any: + if value in value_map: + return value_map[value] + if value.lower() in _BOOL_TRUE_VALUES: + bool_value = True + elif value.lower() in _BOOL_FALSE_VALUES: + bool_value = False + else: + bool_value = None + + if bool_value is not None and bool_value in literal_values: + return bool_value + + valid = ", ".join(str(v) for v in literal_values) + raise argparse.ArgumentTypeError(f"invalid choice: {value!r} (choose from {valid})") + + _convert.__name__ = "literal" + return _convert + + +def _make_enum_type(enum_class: type[enum.Enum]) -> Callable[[str], enum.Enum]: + """Create an argparse *type* converter for an Enum class. + + Accepts both member *values* and member *names*. + """ + _value_map = {str(m.value): m for m in enum_class} + + def _convert(value: str) -> enum.Enum: + member = _value_map.get(value) + if member is not None: + return member + try: + return enum_class[value] + except KeyError as err: + valid = ", ".join(_value_map) + raise argparse.ArgumentTypeError(f"invalid choice: {value!r} (choose from {valid})") from err + + _convert.__name__ = enum_class.__name__ + _convert._cmd2_enum_class = enum_class # type: ignore[attr-defined] + return _convert + + +class _CollectionCastingAction(argparse._StoreAction): + """Store action that can coerce parsed collection values to a container type.""" + + def __init__(self, *args: Any, container_factory: Callable[[list[Any]], Any] | None = None, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._container_factory = container_factory + + def __call__( + self, + _parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: Any, + _option_string: str | None = None, + ) -> None: + result = values + if self._container_factory is not None and isinstance(values, list): + result = self._container_factory(values) + setattr(namespace, self.dest, result) + + +# -- Individual resolvers ----------------------------------------------------- + + +def _make_simple_resolver(converter: Callable[..., Any] | type) -> Callable[..., dict[str, Any]]: + """Create a resolver for types that just need ``type=converter``.""" + + def _resolve(_tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> dict[str, Any]: + return {"type": converter} + + return _resolve + + +def _resolve_path(_tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> dict[str, Any]: + """Resolve Path and add completer.""" + return {"type": Path, "completer": Cmd.path_complete} + + +def _resolve_bool( + _tp: Any, + _args: tuple[Any, ...], + *, + is_positional: bool, + metadata: ArgMetadata, + **_ctx: Any, +) -> dict[str, Any]: + """Resolve bool -- flag or positional depending on context.""" + if not is_positional: + action_str = getattr(metadata, "action", None) if metadata else None + if action_str: + return {"action": action_str, "is_bool_flag": True} + return {"action": argparse.BooleanOptionalAction, "is_bool_flag": True} + return {"type": _parse_bool, "choices": list(_BOOL_CHOICES)} + + +def _resolve_element(tp: Any) -> tuple[Any, dict[str, Any]]: + """Resolve a collection element type and reject nested collections.""" + element_type, inner = _resolve_type(tp, is_positional=True) + if inner.get("is_collection"): + raise TypeError("Nested collections are not supported") + return element_type, inner + + +def _make_collection_resolver(collection_type: type) -> Callable[..., dict[str, Any]]: + """Create a resolver for single-arg collections (list[T], set[T]).""" + + def _resolve(_tp: Any, args: tuple[Any, ...], *, has_default: bool = False, **_ctx: Any) -> dict[str, Any]: + nargs = "*" if has_default else "+" + if len(args) == 0: + # Bare list/tuple without type args -- treat as list[str]/set[str] + return { + "is_collection": True, + "nargs": nargs, + "base_type": str, + "action": _CollectionCastingAction, + "container_factory": collection_type, + } + if len(args) != 1: + raise TypeError( + f"{collection_type.__name__}[...] with {len(args)} type arguments is not supported; " + f"use {collection_type.__name__}[T] with a single element type." + ) + element_type, inner = _resolve_element(args[0]) + return { + **inner, + "is_collection": True, + "nargs": nargs, + "base_type": element_type, + "action": _CollectionCastingAction, + "container_factory": collection_type, + } + + return _resolve + + +def _resolve_tuple(_tp: Any, args: tuple[Any, ...], *, has_default: bool = False, **_ctx: Any) -> dict[str, Any]: + """Resolve tuple[T, ...] and tuple[T1, T2, ...].""" + cast_kwargs = {"action": _CollectionCastingAction, "container_factory": tuple} + + nargs = "*" if has_default else "+" + if not args: + # Bare tuple without type args -- treat as tuple[str, ...] + return {"is_collection": True, "nargs": nargs, "base_type": str, **cast_kwargs} + + if len(args) == 2 and args[1] is Ellipsis: + element_type, inner = _resolve_element(args[0]) + return {**inner, "is_collection": True, "nargs": nargs, "base_type": element_type, **cast_kwargs} + + if Ellipsis not in args: + first = args[0] + if not all(a == first for a in args[1:]): + raise TypeError( + f"tuple[{', '.join(a.__name__ if hasattr(a, '__name__') else str(a) for a in args)}] " + f"has mixed element types which is not currently supported because argparse " + f"can only apply a single type= converter per argument. " + f"Use tuple[T, T] (same type) or tuple[T, ...] instead." + ) + _, inner = _resolve_element(first) + return {**inner, "is_collection": True, "nargs": len(args), "base_type": first, **cast_kwargs} + + raise TypeError( + "tuple with Ellipsis in an unexpected position is not supported; " + "use tuple[T, ...] for variable-length or tuple[T, T] for fixed-arity." + ) + + +def _resolve_literal(_tp: Any, args: tuple[Any, ...], **_ctx: Any) -> dict[str, Any]: + """Resolve Literal["a", "b", ...] into converter + choices.""" + literal_values = list(args) + return {"type": _make_literal_type(literal_values), "choices": literal_values} + + +def _resolve_enum(tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> dict[str, Any]: + """Resolve Enum subclasses into converter + choices.""" + return { + "type": _make_enum_type(tp), + "choices": [CompletionItem(m, text=str(m.value), display_meta=m.name) for m in tp], + } + + +# -- Registry ----------------------------------------------------------------- + +_TYPE_RESOLVERS: dict[Any, Callable[..., dict[str, Any]]] = { + # Subclass-matchable entries first -- iteration order matters for the + # issubclass fallback. enum.Enum must precede int (IntEnum <: int). + enum.Enum: _resolve_enum, + Path: _resolve_path, + # Exact-match entries (order among these doesn't affect subclass lookup). + bool: _resolve_bool, + int: _make_simple_resolver(int), + float: _make_simple_resolver(float), + decimal.Decimal: _make_simple_resolver(decimal.Decimal), + list: _make_collection_resolver(list), + set: _make_collection_resolver(set), + tuple: _resolve_tuple, + Literal: _resolve_literal, +} + + +def _resolve_type( + tp: type, + *, + is_positional: bool = False, + has_default: bool = False, + default: Any = None, + metadata: ArgMetadata = None, + is_kw_only: bool = False, +) -> tuple[type, dict[str, Any]]: + """Resolve a type into argparse kwargs via the registry. + + Lookup order: ``get_origin(tp)`` → ``tp`` → ``issubclass`` fallback. + + Returns ``(base_type, kwargs_dict)``. + """ + args = get_args(tp) + resolver_has_default = has_default or is_kw_only + ctx: dict[str, Any] = { + "is_positional": is_positional, + "has_default": resolver_has_default, + "default": default, + "metadata": metadata, + } + + resolver = _TYPE_RESOLVERS.get(get_origin(tp)) or _TYPE_RESOLVERS.get(tp) + + # Subclass fallback (e.g. MyEnum → enum.Enum, MyPath → pathlib.Path) + if resolver is None and isinstance(tp, type): + for parent, candidate in _TYPE_RESOLVERS.items(): + if isinstance(parent, type) and issubclass(tp, parent): + resolver = candidate + break + + if resolver is not None: + kwargs = resolver(tp, args, **ctx) + base_type = kwargs.pop("base_type", tp) + else: + base_type = tp + kwargs = {} + + if metadata: + kwargs.update(metadata.to_kwargs()) + + # Some argparse actions (e.g. count/store_true) do not accept a type converter. + action_name = kwargs.get("action") + if isinstance(action_name, str) and action_name in _ACTIONS_DISALLOW_TYPE: + kwargs.pop("type", None) + + if has_default: + kwargs["default"] = default + + if is_kw_only and not has_default: + kwargs["required"] = True + + if kwargs.get("choices_provider") or kwargs.get("completer"): + kwargs.pop("choices", None) + + return base_type, kwargs + + +def _unwrap_optional(tp: type) -> tuple[type, bool]: + """If *tp* is ``T | None``, return ``(T, True)``. Otherwise ``(tp, False)``. + + Raises ``TypeError`` for ambiguous unions like ``str | int`` or ``str | int | None``. + """ + origin = get_origin(tp) + if origin is Union or origin is types.UnionType: # type: ignore[comparison-overlap] + all_args = get_args(tp) + non_none = [a for a in all_args if a is not type(None)] + has_none = len(non_none) < len(all_args) + if len(non_none) == 1: + if has_none: + return non_none[0], True + raise TypeError( + f"Unexpected single-element Union without None: Union[{non_none[0]}]. " + f"Use the type directly instead of wrapping in Union." + ) + type_names = " | ".join(a.__name__ if hasattr(a, "__name__") else str(a) for a in non_none) + raise TypeError(f"Union type {type_names} is ambiguous for auto-resolution.") + return tp, False + + +def _normalize_annotation(annotation: type) -> _NormalizedAnnotation: + """Normalize an annotation into its inner type, metadata, and optionality.""" + tp = annotation + metadata: ArgMetadata = None + is_optional = False + + tp, unwrapped = _unwrap_optional(tp) + if unwrapped: + is_optional = True + if get_origin(tp) is Annotated: # type: ignore[comparison-overlap] + inner_tp = get_args(tp)[0] + inner_origin = get_origin(inner_tp) + inner_is_union = inner_origin is Union or inner_origin is types.UnionType # type: ignore[comparison-overlap] + if not (inner_is_union and type(None) in get_args(inner_tp)): + raise TypeError("Annotated[T, meta] | None is ambiguous. Use Annotated[T | None, meta] instead.") + + if get_origin(tp) is Annotated: # type: ignore[comparison-overlap] + args = get_args(tp) + tp = args[0] + for meta in args[1:]: + if isinstance(meta, (Argument, Option)): + metadata = meta + break + + tp, inner_unwrapped = _unwrap_optional(tp) + if inner_unwrapped: + is_optional = True + + return tp, metadata, is_optional + + +# --------------------------------------------------------------------------- +# Annotation resolution +# --------------------------------------------------------------------------- + + +def _resolve_annotation( + annotation: type, + *, + has_default: bool = False, + default: Any = None, + is_kw_only: bool = False, +) -> tuple[dict[str, Any], ArgMetadata, bool, bool]: + """Decompose a type annotation into ``(type_kwargs, metadata, is_positional, is_bool_flag)``. + + Peels ``Annotated`` then ``Optional``. The only supported way to combine + ``Annotated`` with ``Optional`` is ``Annotated[T | None, meta]``. + Writing ``Annotated[T, meta] | None`` is ambiguous and raises ``TypeError``. + """ + tp, metadata, is_optional = _normalize_annotation(annotation) + + is_positional = isinstance(metadata, Argument) or ( + not isinstance(metadata, Option) and not has_default and not is_optional and not is_kw_only + ) + + # 4. Resolve type and finalize argparse kwargs + tp, type_kwargs = _resolve_type( + tp, + is_positional=is_positional, + has_default=has_default, + default=default, + metadata=metadata, + is_kw_only=is_kw_only, + ) + + # Strip internal keys not meant for argparse + is_bool_flag = type_kwargs.pop("is_bool_flag", False) + type_kwargs.pop("is_collection", None) + type_kwargs.pop("base_type", None) + + return type_kwargs, metadata, is_positional, is_bool_flag + + +# Parameter names that conflict with argparse internals and cannot be used +# as annotated parameter names. +_RESERVED_PARAM_NAMES = frozenset({"dest", "subcommand"}) + + +# --------------------------------------------------------------------------- +# Signature → Parser conversion +# --------------------------------------------------------------------------- + + +def _validate_base_command_params( + func: Callable[..., Any], + *, + skip_params: frozenset[str] | None = None, +) -> None: + """Validate a ``base_command=True`` function has ``cmd2_handler`` and no positional args.""" + sig = inspect.signature(func) + + if "cmd2_handler" not in sig.parameters: + raise TypeError(f"with_annotated(base_command=True) requires a 'cmd2_handler' parameter in {func.__qualname__}") + + if skip_params is None: + skip_params = _SKIP_PARAMS + + for name, metadata, positional, _flags, _kwargs in _resolve_parameters(func, skip_params=skip_params): + if positional and not isinstance(metadata, Argument): + raise TypeError( + f"Parameter '{name}' in {func.__qualname__} is positional, " + f"which conflicts with subcommand parsing. " + f"Use a keyword-only parameter (after *) or give it a default value." + ) + if isinstance(metadata, Argument): + raise TypeError( + f"Parameter '{name}' in {func.__qualname__} uses Argument() metadata, " + f"which creates a positional argument that conflicts with subcommand parsing." + ) + + +# Parameters that are handled specially by the decorator and should not +# be added to the argparse parser. The first positional parameter (self/cls) +# is always skipped by position; these cover additional decorator-managed names. +_SKIP_PARAMS = frozenset({"cmd2_handler", "cmd2_statement"}) + + +def _resolve_parameters( + func: Callable[..., Any], + *, + skip_params: frozenset[str] = _SKIP_PARAMS, +) -> list[_ResolvedParam]: + """Resolve a function signature into parser-ready parameter records.""" + sig = inspect.signature(func) + try: + hints = get_type_hints(func, 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 + + resolved: list[_ResolvedParam] = [] + + # Skip the first parameter by position (self/cls for methods) + params = list(sig.parameters.items()) + if params: + params = params[1:] + + for name, param in params: + if name in skip_params: + continue + + if param.kind == inspect.Parameter.POSITIONAL_ONLY: + raise TypeError( + f"Parameter {name!r} in {func.__qualname__} is positional-only, " + "which is not supported by @with_annotated because parameters are passed as keyword arguments." + ) + + if name in _RESERVED_PARAM_NAMES: + raise ValueError( + f"Parameter name {name!r} in {func.__qualname__} is reserved by argparse " + f"and cannot be used as an annotated parameter name." + ) + + annotation = hints.get(name, param.annotation) + has_default = param.default is not inspect.Parameter.empty + default = param.default if has_default else None + is_kw_only = param.kind == inspect.Parameter.KEYWORD_ONLY + + kwargs, metadata, positional, _is_bool_flag = _resolve_annotation( + annotation, + has_default=has_default, + default=default, + is_kw_only=is_kw_only, + ) + + if positional: + flags: list[str] = [] + else: + flags = list(metadata.names) if isinstance(metadata, Option) and metadata.names else [f"--{name}"] + kwargs["dest"] = name + + resolved.append((name, metadata, positional, flags, kwargs)) + + return resolved + + +def _filtered_namespace_kwargs( + ns: argparse.Namespace, + *, + accepted: Container[str] | None = None, + exclude_subcommand: bool = False, +) -> dict[str, Any]: + """Filter a parsed Namespace down to user-visible kwargs.""" + from .constants import NS_ATTR_SUBCMD_HANDLER + + filtered: dict[str, Any] = {} + for key, value in vars(ns).items(): + if accepted is not None and key not in accepted: + continue + if key == NS_ATTR_SUBCMD_HANDLER: + continue + if exclude_subcommand and key == "subcommand": + continue + filtered[key] = value + + return filtered + + +def _validate_group_members( + member_names: tuple[str, ...], + *, + all_param_names: set[str], + group_type: str, +) -> None: + """Validate that all referenced group members exist.""" + for name in member_names: + if name not in all_param_names: + raise ValueError(f"{group_type} references nonexistent parameter {name!r}") + + +def _build_argument_group_targets( + parser: argparse.ArgumentParser, + *, + groups: tuple[tuple[str, ...], ...] | None, + all_param_names: set[str], +) -> tuple[dict[str, _ArgumentTarget], dict[str, argparse._ArgumentGroup]]: + """Build argument groups and return add_argument targets for their members.""" + target_for: dict[str, _ArgumentTarget] = {} + argument_group_for: dict[str, argparse._ArgumentGroup] = {} + argument_group_index_for: dict[str, int] = {} + + if not groups: + return target_for, argument_group_for + + for index, member_names in enumerate(groups, start=1): + _validate_group_members(member_names, all_param_names=all_param_names, group_type="groups") + for name in member_names: + if name in argument_group_for: + raise ValueError( + f"parameter {name!r} cannot be assigned to both argument " + f"group {argument_group_index_for[name]} and argument group {index}" + ) + + group = parser.add_argument_group() + for name in member_names: + argument_group_for[name] = group + argument_group_index_for[name] = index + target_for[name] = group + + return target_for, argument_group_for + + +def _apply_mutex_group_targets( + parser: argparse.ArgumentParser, + *, + target_for: dict[str, _ArgumentTarget], + argument_group_for: dict[str, argparse._ArgumentGroup], + mutually_exclusive_groups: tuple[tuple[str, ...], ...] | None, + all_param_names: set[str], +) -> None: + """Build mutually exclusive groups and update add_argument targets for their members.""" + mutex_target_for: dict[str, argparse._MutuallyExclusiveGroup] = {} + + if not mutually_exclusive_groups: + return + + for index, member_names in enumerate(mutually_exclusive_groups, start=1): + _validate_group_members( + member_names, + all_param_names=all_param_names, + group_type="mutually_exclusive_groups", + ) + for name in member_names: + if name in mutex_target_for: + raise ValueError(f"parameter {name!r} cannot be assigned to multiple mutually exclusive groups") + + parent_groups = {argument_group_for[name] for name in member_names if name in argument_group_for} + if len(parent_groups) > 1: + raise ValueError( + f"mutually exclusive group {index} spans parameters in different argument groups, " + "which argparse cannot represent cleanly" + ) + + mutex_parent: _ArgumentTarget = next(iter(parent_groups)) if parent_groups else parser + mutex_group = mutex_parent.add_mutually_exclusive_group() + for name in member_names: + mutex_target_for[name] = mutex_group + target_for[name] = mutex_group + + +def build_parser_from_function( + func: Callable[..., Any], + *, + skip_params: frozenset[str] = _SKIP_PARAMS, + groups: tuple[tuple[str, ...], ...] | None = None, + mutually_exclusive_groups: tuple[tuple[str, ...], ...] | None = None, +) -> argparse.ArgumentParser: + """Inspect a function's signature and build a ``Cmd2ArgumentParser``. + + Parameters without defaults become positional arguments. + Parameters with defaults become ``--option`` flags. + ``Annotated[T, Argument(...)]`` or ``Annotated[T, Option(...)]`` + overrides the default behavior. + + :param func: the command function to inspect + :param skip_params: parameter names to exclude from the parser + :param groups: tuples of parameter names to place in argument groups (for help display) + :param mutually_exclusive_groups: tuples of parameter names that are mutually exclusive + :return: a fully configured ``Cmd2ArgumentParser`` + """ + from .argparse_utils import DEFAULT_ARGUMENT_PARSER + + parser = DEFAULT_ARGUMENT_PARSER() + + resolved = _resolve_parameters(func, skip_params=skip_params) + + # Phase 2: build group lookup + all_param_names = {name for name, *_rest in resolved} + target_for, argument_group_for = _build_argument_group_targets( + parser, + groups=groups, + all_param_names=all_param_names, + ) + _apply_mutex_group_targets( + parser, + target_for=target_for, + argument_group_for=argument_group_for, + mutually_exclusive_groups=mutually_exclusive_groups, + all_param_names=all_param_names, + ) + + # Phase 3: add arguments to appropriate targets + for name, _metadata, positional, flags, kwargs in resolved: + target = target_for.get(name, parser) + if positional: + target.add_argument(name, **kwargs) + else: + target.add_argument(*flags, **kwargs) + + return parser + + +def _derive_subcommand_name(func: Callable[..., Any], subcommand_to: str) -> str: + """Derive the subcommand name from the function name and validate the naming convention. + + ``subcommand_to='team member'`` + ``func.__name__='team_member_add'`` → ``'add'``. + """ + expected_prefix = subcommand_to.replace(" ", "_") + "_" + if not func.__name__.startswith(expected_prefix): + raise TypeError( + f"Function '{func.__name__}' must be named '{expected_prefix}' " + f"when using subcommand_to='{subcommand_to}'" + ) + return func.__name__[len(expected_prefix) :] + + +def build_subcommand_handler( + func: Callable[..., Any], + subcommand_to: str, + *, + base_command: bool = False, + groups: tuple[tuple[str, ...], ...] | None = None, + mutually_exclusive_groups: tuple[tuple[str, ...], ...] | None = None, +) -> tuple[Callable[..., Any], str, Callable[[], argparse.ArgumentParser]]: + """Build a subcommand handler wrapper and its parser from type annotations. + + Validates the naming convention, builds a parser from annotations, and + returns a wrapper that unpacks ``argparse.Namespace`` into typed kwargs + before calling the original function. + + :param func: the subcommand handler function + :param subcommand_to: parent command name (space-delimited for nesting) + :param base_command: if True, the parser also gets ``add_subparsers()`` + :return: ``(handler, subcommand_name, parser_builder)`` + """ + subcmd_name = _derive_subcommand_name(func, subcommand_to) + + if base_command: + _validate_base_command_params(func) + + _accepted = set(list(inspect.signature(func).parameters.keys())[1:]) + + @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) + return func(self_arg, **filtered) + + def parser_builder() -> argparse.ArgumentParser: + parser = build_parser_from_function(func, groups=groups, mutually_exclusive_groups=mutually_exclusive_groups) + if base_command: + parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND", required=True) + return parser + + return handler, subcmd_name, parser_builder + + +def with_annotated( + func: Callable[..., Any] | None = None, + *, + ns_provider: Callable[..., argparse.Namespace] | None = None, + preserve_quotes: bool = False, + with_unknown_args: bool = False, + base_command: bool = False, + subcommand_to: str | None = None, + help: str | None = None, # noqa: A002 + aliases: Sequence[str] | None = None, + groups: tuple[tuple[str, ...], ...] | None = None, + mutually_exclusive_groups: tuple[tuple[str, ...], ...] | None = None, +) -> Any: + """Decorate a ``do_*`` method to build its argparse parser from type annotations. + + :param func: the command function (when used without parentheses) + :param ns_provider: optional callable returning a prepopulated argparse.Namespace. + Not supported with ``subcommand_to``. + :param preserve_quotes: if True, preserve quotes in arguments. + Not supported with ``subcommand_to``. + :param with_unknown_args: if True, capture unknown args (passed as extra kwarg ``_unknown``). + Not supported with ``subcommand_to``. + :param base_command: if True, this command has subcommands (adds ``add_subparsers()``). + Requires a ``cmd2_handler`` parameter and no positional arguments. + :param subcommand_to: parent command name (e.g. ``'team'`` or ``'team member'``). + Function must be named ``{parent_underscored}_{subcommand}``. + :param help: help text for the subcommand (only valid with ``subcommand_to``) + :param aliases: alternative names for the subcommand (only valid with ``subcommand_to``) + :param groups: tuples of parameter names to place in argument groups (for help display) + :param mutually_exclusive_groups: tuples of parameter names that are mutually exclusive + + Example:: + + class MyApp(cmd2.Cmd): + @with_annotated + def do_greet(self, name: str, count: int = 1): ... + + @with_annotated(base_command=True) + def do_team(self, *, cmd2_handler): ... + + @with_annotated(subcommand_to='team', help='create a team') + def team_create(self, name: str): ... + + """ + if (help is not None or aliases is not None) and subcommand_to is None: + raise TypeError("'help' and 'aliases' are only valid with subcommand_to") + if subcommand_to is not None: + unsupported: list[str] = [] + if ns_provider is not None: + unsupported.append("ns_provider") + if preserve_quotes: + unsupported.append("preserve_quotes") + if with_unknown_args: + unsupported.append("with_unknown_args") + if unsupported: + names = ", ".join(unsupported) + raise TypeError( + f"{names} {'is' if len(unsupported) == 1 else 'are'} not supported with subcommand_to. " + "Configure these behaviors on the base command instead." + ) + + def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: + if with_unknown_args: + unknown_param = inspect.signature(fn).parameters.get("_unknown") + if unknown_param is None: + raise TypeError("with_annotated(with_unknown_args=True) requires a parameter named _unknown") + if unknown_param.kind is inspect.Parameter.POSITIONAL_ONLY: + raise TypeError("Parameter _unknown must be keyword-compatible when with_unknown_args=True") + + if subcommand_to is not None: + handler, subcmd_name, subcmd_parser_builder = build_subcommand_handler( + fn, + subcommand_to, + base_command=base_command, + groups=groups, + mutually_exclusive_groups=mutually_exclusive_groups, + ) + setattr(handler, constants.SUBCMD_ATTR_COMMAND, subcommand_to) + setattr(handler, constants.SUBCMD_ATTR_NAME, subcmd_name) + setattr(handler, constants.CMD_ATTR_ARGPARSER, subcmd_parser_builder) + add_parser_kwargs: dict[str, Any] = {} + if help is not None: + add_parser_kwargs["help"] = help + if aliases: + add_parser_kwargs["aliases"] = list(aliases) + setattr(handler, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, add_parser_kwargs) + return handler + + command_name = fn.__name__[len(constants.COMMAND_FUNC_PREFIX) :] + + skip_params = _SKIP_PARAMS | ({"_unknown"} if with_unknown_args else frozenset()) + if base_command: + _validate_base_command_params(fn, skip_params=skip_params) + + # Cache signature introspection at decoration time, not per-invocation + accepted = set(list(inspect.signature(fn).parameters.keys())[1:]) + + def parser_builder() -> argparse.ArgumentParser: + parser = build_parser_from_function( + fn, + skip_params=skip_params, + groups=groups, + mutually_exclusive_groups=mutually_exclusive_groups, + ) + if base_command: + parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND", required=True) + return parser + + @functools.wraps(fn) + def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: + cmd2_app, statement_arg = _parse_positionals(args) + owner = args[0] # Cmd or CommandSet instance + statement, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list( + command_name, statement_arg, preserve_quotes + ) + + arg_parser = cmd2_app.command_parsers.get(cmd_wrapper) + if arg_parser is None: + raise ValueError(f"No argument parser found for {command_name}") + + if ns_provider is None: + namespace = None + else: + provider_self = cmd2_app._resolve_func_self(ns_provider, args[0]) + namespace = ns_provider(provider_self if provider_self is not None else cmd2_app) + + try: + if with_unknown_args: + ns, unknown = arg_parser.parse_known_args(parsed_arglist, namespace) + else: + ns = arg_parser.parse_args(parsed_arglist, namespace) + unknown = None + except SystemExit as exc: + raise Cmd2ArgparseError from exc + + setattr(ns, constants.NS_ATTR_STATEMENT, statement) + handler = getattr(ns, constants.NS_ATTR_SUBCMD_HANDLER, None) + if base_command and handler is not None: + handler = functools.partial(handler, ns) + ns.cmd2_handler = handler + + func_kwargs = _filtered_namespace_kwargs(ns, accepted=accepted, exclude_subcommand=base_command) + + if with_unknown_args: + func_kwargs["_unknown"] = unknown + + func_kwargs.update(kwargs) + result: bool | None = fn(owner, **func_kwargs) + return result + + setattr(cmd_wrapper, constants.CMD_ATTR_ARGPARSER, parser_builder) + setattr(cmd_wrapper, constants.CMD_ATTR_PRESERVE_QUOTES, preserve_quotes) + + return cmd_wrapper + + # Support both @with_annotated and @with_annotated(...) + if func is not None: + return decorator(func) + return decorator diff --git a/docs/features/argument_processing.md b/docs/features/argument_processing.md index a0a577380..3c19606b4 100644 --- a/docs/features/argument_processing.md +++ b/docs/features/argument_processing.md @@ -16,18 +16,21 @@ following for you: 1. Adds the usage message from the argument parser to your command's help. 1. Checks if the `-h/--help` option is present, and if so, displays the help message for the command -These features are all provided by the [@with_argparser][cmd2.with_argparser] decorator which is -imported from `cmd2`. +These features are provided by two decorators: + +- [@with_argparser][cmd2.with_argparser] -- build parsers manually with `add_argument()` calls +- [@with_annotated][cmd2.decorators.with_annotated] -- build parsers automatically from type hints See the -[argparse_example](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_example.py) -example to learn more about how to use the various `cmd2` argument processing decorators in your -`cmd2` applications. +[argparse_completion](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_completion.py) +and [annotated_example](https://github.com/python-cmd2/cmd2/blob/main/examples/annotated_example.py) +examples to compare the two styles side by side. `cmd2` provides the following [decorators](../api/decorators.md) for assisting with parsing arguments passed to commands: - [cmd2.decorators.with_argparser][] +- [cmd2.decorators.with_annotated][] - [cmd2.decorators.with_argument_list][] All of these decorators accept an optional **preserve_quotes** argument which defaults to `False`. @@ -52,6 +55,221 @@ stores internally. A consequence is that parsers don't need to be unique across to dynamically modify this parser at a later time, you need to retrieve this deep copy. This can be done using `self.command_parsers.get(self.do_commandname)`. +## with_annotated decorator + +The [@with_annotated][cmd2.decorators.with_annotated] decorator builds an argparse parser +automatically from the decorated function's type annotations. No manual `add_argument()` calls are +required. + +### Basic usage + +Parameters without defaults become positional arguments. Parameters with defaults become `--option` +flags. Keyword-only parameters (after `*`) always become options, and without a default they become +required options. The function receives typed keyword arguments directly instead of an +`argparse.Namespace`. + +```py +from cmd2 import with_annotated + +class MyApp(cmd2.Cmd): + @with_annotated + def do_greet(self, name: str, count: int = 1, loud: bool = False): + """Greet someone.""" + for _ in range(count): + msg = f"Hello {name}" + self.poutput(msg.upper() if loud else msg) +``` + +The command `greet Alice --count 3 --loud` parses `name="Alice"`, `count=3`, `loud=True` and passes +them as keyword arguments. + +### How annotations map to argparse + +The decorator converts Python type annotations into `add_argument()` calls: + +| Type annotation | Generated argparse setting | +| -------------------------------------------------------- | --------------------------------------------------- | +| `str` | default (no `type=` needed) | +| `int`, `float` | `type=int` or `type=float` | +| `bool` with a default | boolean optional flag via `BooleanOptionalAction` | +| positional `bool` | parsed from `true/false`, `yes/no`, `on/off`, `1/0` | +| `Path` | `type=Path` | +| `Enum` subclass | `type=converter`, `choices` from member values | +| `decimal.Decimal` | `type=decimal.Decimal` | +| `Literal[...]` | `type=literal-converter`, `choices` from values | +| `Collection[T]` / `list[T]` / `set[T]` / `tuple[T, ...]` | `nargs='+'` (or `'*'` if it has a default) | +| `tuple[T, T]` | fixed `nargs=N` with `type=T` | +| `T \| None` | unwrapped to `T`, treated as optional | + +When collection types are used with `@with_annotated`, parsed values are passed to the command +function as: + +- `list[T]` and `Collection[T]` as `list` +- `set[T]` as `set` +- `tuple[T, ...]` as `tuple` + +Unsupported patterns raise `TypeError`, including: + +- unions with multiple non-`None` members such as `str | int` +- mixed-type tuples such as `tuple[int, str]` +- `Annotated[T, meta] | None`; write `Annotated[T | None, meta]` instead + +The parameter names `dest` and `subcommand` are reserved and may not be used as annotated parameter +names. + +### Annotated metadata + +For finer control, use `typing.Annotated` with [Argument][cmd2.annotated.Argument] or +[Option][cmd2.annotated.Option] metadata: + +```py +from typing import Annotated +from cmd2 import Argument, Option, with_annotated + +class MyApp(cmd2.Cmd): + def sport_choices(self) -> cmd2.Choices: + return cmd2.Choices.from_values(["football", "basketball"]) + + @with_annotated + def do_play( + self, + sport: Annotated[str, Argument( + choices_provider=sport_choices, + help_text="Sport to play", + )], + venue: Annotated[str, Option( + "--venue", "-v", + help_text="Where to play", + completer=cmd2.Cmd.path_complete, + )] = "home", + ): + self.poutput(f"Playing {sport} at {venue}") +``` + +Both `Argument` and `Option` accept the same cmd2-specific fields as `add_argument()`: `choices`, +`choices_provider`, `completer`, `table_columns`, `suppress_tab_hint`, `metavar`, `nargs`, and +`help_text`. + +`Option` additionally accepts `action`, `required`, and positional `*names` for custom flag strings +(e.g. `Option("--color", "-c")`). + +When an `Option(action=...)` uses an argparse action that does not accept `type=` (`count`, +`store_true`, `store_false`, `store_const`, `help`, `version`), `@with_annotated` removes any +inferred `type` converter before calling `add_argument()`. This matches argparse behavior and avoids +parser-construction errors such as combining `action='count'` with `type=int`. + +### Comparison with @with_argparser + +The two decorators are interchangeable. Here is the same command written both ways: + +**@with_argparser** + +```py +parser = Cmd2ArgumentParser() +parser.add_argument('name', help='person to greet') +parser.add_argument('--count', type=int, default=1, help='repetitions') +parser.add_argument('--loud', action='store_true', help='shout') + +@with_argparser(parser) +def do_greet(self, args): + for _ in range(args.count): + msg = f"Hello {args.name}" + self.poutput(msg.upper() if args.loud else msg) +``` + +**@with_annotated** + +```py +@with_annotated +def do_greet(self, name: str, count: int = 1, loud: bool = False): + for _ in range(count): + msg = f"Hello {name}" + self.poutput(msg.upper() if loud else msg) +``` + +The annotated version is more concise and gives you typed parameters. It also supports several +advanced cmd2 features directly, including `ns_provider`, `with_unknown_args`, and typed +subcommands. + +### Decorator options + +`@with_annotated` currently supports: + +- `ns_provider` -- prepopulate the namespace before parsing, mirroring `@with_argparser` +- `preserve_quotes` -- if `True`, quotes in arguments are preserved +- `with_unknown_args` -- if `True`, unrecognised arguments are passed as `_unknown` +- `subcommand_to` -- register the function as an annotated subcommand under a parent command +- `base_command` -- create a base command whose parser also adds subparsers and exposes + `cmd2_handler` +- `help` -- help text for an annotated subcommand +- `aliases` -- aliases for an annotated subcommand + +```py +@with_annotated(with_unknown_args=True) +def do_rawish(self, name: str, _unknown: list[str] | None = None): + self.poutput((name, _unknown)) +``` + +### Annotated subcommands + +`@with_annotated` can also build typed subcommand trees without manually constructing subparsers. + +```py +@with_annotated(base_command=True) +def do_manage(self, *, cmd2_handler): + handler = cmd2_handler + if handler: + handler() + +@with_annotated(subcommand_to="manage", help="list projects") +def manage_list(self): + self.poutput("listing") +``` + +For nested subcommands, `subcommand_to` can be space-delimited, for example +`subcommand_to="manage project"`. The intermediate level must also be declared as a subcommand that +creates its own subparsers: + +```py +@with_annotated(subcommand_to="manage", base_command=True, help="manage projects") +def manage_project(self, *, cmd2_handler): + handler = cmd2_handler + if handler: + handler() + +@with_annotated(subcommand_to="manage project", help="add a project") +def manage_project_add(self, name: str): + self.poutput(f"added {name}") +``` + +### Lower-level parser building + +If you need parser grouping or mutually-exclusive groups while still using annotation-driven parser +generation, [cmd2.annotated.build_parser_from_function][cmd2.annotated.build_parser_from_function] +also supports: + +- `groups=((...), (...))` +- `mutually_exclusive_groups=((...), (...))` + +```py +@with_annotated(preserve_quotes=True) +def do_raw(self, text: str): + self.poutput(f"raw: {text}") +``` + +## Automatic Completion from Types + +With `@with_annotated`, arguments annotated as `Path` or `Enum` get automatic completion without +needing an explicit `choices_provider` or `completer`. + +Specifically: + +- `Path` (or any `Path` subclass) triggers filesystem path completion +- `MyEnum` (any `enum.Enum` subclass) triggers completion from enum member values + +With `@with_argparser`, provide `choices`, `choices_provider`, or `completer` explicitly when you +want completion behavior. + ## Argument Parsing For each command in the `cmd2.Cmd` subclass which requires argument parsing, create an instance of diff --git a/examples/annotated_example.py b/examples/annotated_example.py new file mode 100755 index 000000000..6dad2df5a --- /dev/null +++ b/examples/annotated_example.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +"""Annotated decorator example -- type-hint-driven argument parsing. + +Shows how ``@with_annotated`` eliminates boilerplate compared to +``@with_argparser``. The focus is on features that are unique to +the annotated style -- type inference, auto-completion from types, and +typed function parameters -- while also demonstrating that all of cmd2's +advanced completion features (choices_provider, completer, table_columns, +arg_tokens) remain available via ``Annotated`` metadata. + +Compare with ``argparse_completion.py`` which uses ``@with_argparser`` +for the same completion features. + +Usage:: + + python examples/annotated_example.py +""" + +import sys +from argparse import Namespace +from decimal import Decimal +from enum import Enum +from pathlib import Path +from typing import ( + Annotated, + Literal, +) + +import cmd2 +from cmd2 import ( + Choices, + Cmd, +) + + +class Color(str, Enum): + red = "red" + green = "green" + blue = "blue" + yellow = "yellow" + + +class LogLevel(str, Enum): + debug = "debug" + info = "info" + warning = "warning" + error = "error" + + +ANNOTATED_CATEGORY = "Annotated Commands" + + +class AnnotatedExample(Cmd): + """Demonstrates @with_annotated strengths over @with_argparser.""" + + intro = "Welcome! Try tab-completing the commands below.\n" + prompt = "annotated> " + + def __init__(self) -> None: + super().__init__(include_ipy=True) + self._sports = ["Basketball", "Football", "Tennis", "Hockey"] + self._default_region = "staging" + + # -- Type inference: int, float, bool ------------------------------------ + # With @with_argparser you'd manually set type=int and action='store_true'. + # Here the decorator infers everything from the annotations. + + @cmd2.with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_add(self, a: int, b: int = 0, verbose: bool = False) -> None: + """Add two integers. Types are inferred from annotations. + + Examples: + add 2 --b 3 + add 10 --b 5 --verbose + """ + result = a + b + if verbose: + self.poutput(f"{a} + {b} = {result}") + else: + self.poutput(str(result)) + + # -- Enum auto-completion ------------------------------------------------ + # With @with_argparser you'd list every member in choices=[...]. + # Here the Enum type provides choices and validation automatically. + + @cmd2.with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_paint( + self, + item: str, + color: Annotated[Color, cmd2.Option("--color", "-c", help_text="Color to use")] = Color.blue, + level: LogLevel = LogLevel.info, + ) -> None: + """Paint an item. Enum types auto-complete their member values. + + Try: + paint wall --color + paint wall --level + """ + self.poutput(f"[{level.value}] Painting {item} {color.value}") + + # -- Path auto-completion ------------------------------------------------ + # With @with_argparser you'd wire completer=Cmd.path_complete on each arg. + # Here the Path type triggers filesystem completion automatically. + + @cmd2.with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_copy(self, src: Path, dst: Path) -> None: + """Copy a file. Path parameters auto-complete filesystem paths. + + Try: + copy ./ /tmp/ + """ + self.poutput(f"Copying {src} -> {dst}") + + # -- Bool flags ---------------------------------------------------------- + # With @with_argparser you'd spell out the action. + # Here bool defaults drive the generated boolean option. + + @cmd2.with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_build( + self, + target: str, + verbose: bool = False, + color: bool = True, + ) -> None: + """Build a target. Bool flags are inferred from defaults. + + ``verbose: bool = False`` becomes a boolean optional flag. + ``color: bool = True`` becomes a ``--color`` / ``--no-color`` style option. + + Try: + build app --verbose --no-color + """ + parts = [f"Building {target}"] + if verbose: + parts.append("(verbose)") + if not color: + parts.append("(no color)") + self.poutput(" ".join(parts)) + + # -- List arguments ------------------------------------------------------ + # With @with_argparser you'd set type=float and nargs='+'. + # Here list[float] does both at once. + + @cmd2.with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_sum(self, numbers: list[float]) -> None: + """Sum numbers. ``list[T]`` becomes ``nargs='+'`` automatically. + + Try: + sum 1.5 2.5 3.0 + """ + self.poutput(f"{' + '.join(str(n) for n in numbers)} = {sum(numbers)}") + + # -- Literal + Decimal --------------------------------------------------- + # Literal values become validated choices. Decimal values preserve precision. + + @cmd2.with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_deploy( + self, + service: str, + mode: Literal["safe", "fast"] = "safe", + budget: Decimal = Decimal("1.50"), + ) -> None: + """Deploy using Literal choices and Decimal parsing. + + Try: + deploy api --mode + deploy api --mode fast --budget 2.75 + """ + self.poutput(f"Deploying {service} in {mode} mode with budget {budget}") + + # -- Typed kwargs -------------------------------------------------------- + # With @with_argparser you'd access args.name, args.count on a Namespace. + # Here each parameter is a typed local variable. + + @cmd2.with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_greet(self, name: str, count: int = 1, loud: bool = False) -> None: + """Greet someone. Parameters are typed -- no Namespace unpacking. + + Try: + greet Alice --count 3 --loud + """ + for _ in range(count): + msg = f"Hello {name}!" + self.poutput(msg.upper() if loud else msg) + + # -- Advanced: choices_provider + arg_tokens ----------------------------- + # These cmd2-specific features still work via Annotated metadata. + + def sport_choices(self) -> Choices: + """choices_provider using instance data.""" + return Choices.from_values(self._sports) + + def context_choices(self, arg_tokens: dict[str, list[str]]) -> Choices: + """arg_tokens-aware completion -- choices depend on prior arguments.""" + sport = arg_tokens.get("sport", [""])[0] + if sport == "Basketball": + return Choices.from_values(["3-pointer", "dunk", "layup"]) + if sport == "Football": + return Choices.from_values(["touchdown", "field-goal", "punt"]) + return Choices.from_values(["play"]) + + @cmd2.with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_score( + self, + sport: Annotated[ + str, + cmd2.Argument( + choices_provider=sport_choices, + help_text="Sport to score", + ), + ], + play: Annotated[ + str, + cmd2.Argument( + choices_provider=context_choices, + help_text="Type of play (depends on sport)", + ), + ], + points: int = 1, + ) -> None: + """Score a play. Demonstrates choices_provider and arg_tokens. + + Try: + score + score Basketball + score Football + """ + self.poutput(f"{sport}: {play} for {points} point(s)") + + # -- Namespace provider -------------------------------------------------- + # This mirrors one of @with_argparser's advanced features. + + def default_namespace(self) -> Namespace: + return Namespace(region=self._default_region) + + @cmd2.with_annotated(ns_provider=default_namespace) + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_ship(self, package: str, region: str = "local") -> None: + """Use ns_provider to prepopulate parser defaults at runtime. + + Try: + ship parcel + ship parcel --region remote + """ + self.poutput(f"Shipping {package} to {region}") + + # -- Unknown args -------------------------------------------------------- + + @cmd2.with_annotated(with_unknown_args=True) + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_flex(self, name: str, _unknown: list[str] | None = None) -> None: + """Capture unknown arguments instead of failing parse. + + Try: + flex alice --future-flag value + """ + self.poutput(f"name={name}") + if _unknown: + self.poutput(f"unknown={_unknown}") + + # -- Subcommands --------------------------------------------------------- + # @with_annotated also supports typed subcommand trees. + + @cmd2.with_annotated(base_command=True) + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_manage(self, verbose: bool = False, *, cmd2_handler) -> None: + """Base command for annotated subcommands. + + Try: + help manage + manage project add demo + """ + if verbose: + self.poutput("verbose mode") + handler = cmd2_handler.get() + if handler: + handler() + + @cmd2.with_annotated(subcommand_to="manage", base_command=True, help="manage projects") + def manage_project(self, *, cmd2_handler) -> None: + handler = cmd2_handler.get() + if handler: + handler() + + @cmd2.with_annotated(subcommand_to="manage project", help="add a project") + def manage_project_add(self, name: str) -> None: + self.poutput(f"project added: {name}") + + @cmd2.with_annotated(subcommand_to="manage project", help="list projects") + def manage_project_list(self) -> None: + self.poutput("project list: demo") + + # -- Preserve quotes ----------------------------------------------------- + + @cmd2.with_annotated(preserve_quotes=True) + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_echo(self, text: str) -> None: + """Echo text with quotes preserved. + + Try: + echo "hello world" + """ + self.poutput(text) + + +if __name__ == "__main__": + app = AnnotatedExample() + sys.exit(app.cmdloop()) diff --git a/tests/test_annotated.py b/tests/test_annotated.py new file mode 100644 index 000000000..373f14bfa --- /dev/null +++ b/tests/test_annotated.py @@ -0,0 +1,1391 @@ +"""Unit tests for cmd2.annotated -- verify build_parser_from_function produces correct actions. + +The focus is on testing that type annotations are correctly translated into +argparse action attributes (option_strings, type, nargs, choices, action, default, etc.). +We do NOT re-test argparse parsing logic or cmd2 integration here. +""" + +import argparse +import decimal +import enum +from pathlib import Path +from typing import ( + Annotated, + Literal, +) + +import pytest + +import cmd2 +from cmd2 import ( + CompletionItem, +) +from cmd2.annotated import ( + Argument, + Option, + _apply_mutex_group_targets, + _build_argument_group_targets, + _CollectionCastingAction, + _make_enum_type, + _make_literal_type, + _parse_bool, + _resolve_annotation, + _validate_group_members, + build_parser_from_function, +) + +from .conftest import run_cmd + +# --------------------------------------------------------------------------- +# Test enums +# --------------------------------------------------------------------------- + + +class _Color(str, enum.Enum): + red = "red" + green = "green" + blue = "blue" + + +class _IntColor(enum.IntEnum): + red = 1 + green = 2 + blue = 3 + + +class _PlainColor(enum.Enum): + RED = "red" + GREEN = "green" + BLUE = "blue" + + +_COLOR_CHOICE_ITEMS = [ + CompletionItem(_Color.red, text="red", display_meta="red"), + CompletionItem(_Color.green, text="green", display_meta="green"), + CompletionItem(_Color.blue, text="blue", display_meta="blue"), +] + +_INT_COLOR_CHOICE_ITEMS = [ + CompletionItem(_IntColor.red, text="1", display_meta="red"), + CompletionItem(_IntColor.green, text="2", display_meta="green"), + CompletionItem(_IntColor.blue, text="3", display_meta="blue"), +] + +_PLAIN_COLOR_CHOICE_ITEMS = [ + CompletionItem(_PlainColor.RED, text="red", display_meta="RED"), + CompletionItem(_PlainColor.GREEN, text="green", display_meta="GREEN"), + CompletionItem(_PlainColor.BLUE, text="blue", display_meta="BLUE"), +] + + +# --------------------------------------------------------------------------- +# Single-parameter test functions for build_parser_from_function. +# Each has exactly one param (besides self) so dest is auto-derived. +# --------------------------------------------------------------------------- + + +def _func_str(self, name: str) -> None: ... +def _func_int_option(self, count: int = 1) -> None: ... +def _func_float_option(self, rate: float = 1.0) -> None: ... +def _func_bool_false(self, verbose: bool = False) -> None: ... +def _func_bool_true(self, debug: bool = True) -> None: ... +def _func_bool_positional(self, flag: bool) -> None: ... +def _func_path(self, file: Path) -> None: ... +def _func_path_option(self, file: Path = Path(".")) -> None: ... +def _func_decimal(self, amount: decimal.Decimal) -> None: ... +def _func_enum(self, color: _Color) -> None: ... +def _func_enum_option(self, color: _Color = _Color.blue) -> None: ... +def _func_literal(self, mode: Literal["fast", "slow"]) -> None: ... +def _func_literal_option(self, mode: Literal["fast", "slow"] = "fast") -> None: ... +def _func_literal_int(self, level: Literal[1, 2, 3]) -> None: ... +def _func_optional(self, name: str | None = None) -> None: ... +def _func_list(self, files: list[str]) -> None: ... +def _func_list_default(self, items: list[str] | None = None) -> None: ... +def _func_set(self, tags: set[str]) -> None: ... +def _func_tuple_ellipsis(self, values: tuple[int, ...]) -> None: ... +def _func_tuple_fixed(self, pair: tuple[int, int]) -> None: ... +def _func_bare_list(self, items: list) -> None: ... +def _func_bare_tuple(self, items: tuple) -> None: ... +def _func_annotated_arg(self, name: Annotated[str, Argument(help_text="Your name")]) -> None: ... +def _func_annotated_option(self, color: Annotated[str, Option("--color", "-c", help_text="Pick")] = "blue") -> None: ... +def _func_annotated_metavar(self, name: Annotated[str, Argument(metavar="NAME")]) -> None: ... +def _func_annotated_nargs(self, names: Annotated[str, Argument(nargs=2)]) -> None: ... +def _func_annotated_action(self, verbose: Annotated[bool, Option("--verbose", "-v", action="count")] = False) -> None: ... +def _func_annotated_action_non_bool(self, count: Annotated[int, Option("--count", action="count")] = 0) -> None: ... +def _func_annotated_required(self, name: Annotated[str, Option("--name", required=True)]) -> None: ... +def _func_annotated_required_auto_flag(self, name: Annotated[str, Option(required=True)]) -> None: ... +def _func_annotated_choices(self, food: Annotated[str, Argument(choices=["a", "b"])]) -> None: ... +def _func_dest_param(self, dest: str) -> None: ... +def _func_kw_only(self, *, name: str) -> None: ... +def _func_kw_only_with_default(self, *, name: str = "world") -> None: ... +def _func_underscore_option(self, my_param: str = "x") -> None: ... +def _func_default_type_mismatch(self, count: int = "1") -> None: ... # type: ignore[assignment] +def _func_path_default(self, file: Path = Path("/tmp")) -> None: ... +def _func_optional_annotated_inside(self, name: Annotated[str | None, Option("--name")] = None) -> None: ... +def _func_optional_annotated_outside(self, name: Annotated[str, Option("--name")] | None = None) -> None: ... +def _func_int_enum(self, color: _IntColor) -> None: ... +def _func_plain_enum(self, color: _PlainColor) -> None: ... +def _func_list_int(self, nums: list[int]) -> None: ... +def _func_set_int(self, nums: set[int]) -> None: ... +def _func_tuple_fixed_triple(self, triple: tuple[int, int, int]) -> None: ... +def _func_multi(self, a: str, b: int, c: int = 1) -> None: ... +def _func_grouped( + self, + *, + local: str | None = None, + remote: str | None = None, + force: bool = False, + dry_run: bool = False, +) -> None: ... + + +def _func_positional_only(self, name: str, /) -> None: ... + + +def _provider(cmd: cmd2.Cmd): + return [] + + +def _func_choices_provider_on_enum( + self, + color: Annotated[_Color, Argument(choices_provider=_provider)], +) -> None: ... + + +def _func_completer_on_path( + self, + file: Annotated[Path, Argument(completer=cmd2.Cmd.path_complete)], +) -> None: ... + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + + +def _get_param_action(func: object) -> argparse.Action: + """Build parser from a single-param function and return its action.""" + import inspect + + sig = inspect.signature(func) # type: ignore[arg-type] + param_names = [n for n in sig.parameters if n != "self"] + assert len(param_names) == 1, f"Expected 1 param besides self, got {param_names}" + parser = build_parser_from_function(func) # type: ignore[arg-type] + for action in parser._actions: + if action.dest == param_names[0]: + return action + raise ValueError(f"No action with dest={param_names[0]!r}") + + +def _complete_cmd(app: cmd2.Cmd, line: str, text: str) -> list[str]: + begidx = len(line) - len(text) + endidx = len(line) + completions = app.complete(text, line, begidx, endidx) + return list(completions.to_strings()) + + +# --------------------------------------------------------------------------- +# Core: build_parser_from_function produces correct action attributes +# --------------------------------------------------------------------------- + + +class TestBuildParser: + """Verify action attributes produced by build_parser_from_function.""" + + @pytest.mark.parametrize( + ("func", "expected"), + [ + # --- Positionals --- + pytest.param(_func_str, {"option_strings": [], "type": None}, id="str_positional"), + pytest.param(_func_path, {"option_strings": [], "type": Path}, id="path_positional"), + pytest.param(_func_decimal, {"option_strings": [], "type": decimal.Decimal}, id="decimal_positional"), + pytest.param(_func_bool_positional, {"option_strings": [], "type": _parse_bool}, id="bool_positional"), + pytest.param(_func_enum, {"option_strings": [], "choices": _COLOR_CHOICE_ITEMS}, id="enum_positional"), + pytest.param(_func_literal, {"option_strings": [], "choices": ["fast", "slow"]}, id="literal_positional"), + pytest.param(_func_literal_int, {"option_strings": [], "choices": [1, 2, 3]}, id="literal_int_positional"), + pytest.param(_func_int_enum, {"option_strings": [], "choices": _INT_COLOR_CHOICE_ITEMS}, id="int_enum_positional"), + pytest.param( + _func_plain_enum, {"option_strings": [], "choices": _PLAIN_COLOR_CHOICE_ITEMS}, id="plain_enum_positional" + ), + pytest.param(_func_list_int, {"option_strings": [], "nargs": "+", "type": int}, id="list_int"), + pytest.param(_func_set_int, {"option_strings": [], "nargs": "+", "type": int}, id="set_int"), + pytest.param(_func_tuple_fixed_triple, {"option_strings": [], "nargs": 3, "type": int}, id="tuple_fixed_triple"), + pytest.param(_func_list, {"option_strings": [], "nargs": "+"}, id="list_positional"), + pytest.param(_func_set, {"option_strings": [], "nargs": "+"}, id="set_positional"), + pytest.param(_func_tuple_ellipsis, {"option_strings": [], "nargs": "+", "type": int}, id="tuple_ellipsis"), + pytest.param(_func_tuple_fixed, {"option_strings": [], "nargs": 2, "type": int}, id="tuple_fixed"), + pytest.param(_func_bare_list, {"option_strings": [], "nargs": "+"}, id="bare_list"), + pytest.param(_func_bare_tuple, {"option_strings": [], "nargs": "+"}, id="bare_tuple"), + # --- Options --- + pytest.param(_func_int_option, {"option_strings": ["--count"], "type": int, "default": 1}, id="int_option"), + pytest.param(_func_float_option, {"option_strings": ["--rate"], "type": float, "default": 1.0}, id="float_option"), + pytest.param(_func_bool_false, {"option_strings": ["--verbose", "--no-verbose"]}, id="bool_optional_action"), + pytest.param( + _func_bool_true, + {"option_strings": ["--debug", "--no-debug"], "default": True}, + id="bool_optional_action_true", + ), + pytest.param(_func_path_option, {"option_strings": ["--file"], "type": Path}, id="path_option"), + pytest.param( + _func_enum_option, + {"option_strings": ["--color"], "choices": _COLOR_CHOICE_ITEMS, "default": _Color.blue}, + id="enum_option", + ), + pytest.param( + _func_literal_option, {"option_strings": ["--mode"], "choices": ["fast", "slow"]}, id="literal_option" + ), + pytest.param(_func_optional, {"option_strings": ["--name"], "default": None}, id="optional_str"), + pytest.param(_func_list_default, {"option_strings": ["--items"], "nargs": "*"}, id="list_with_default"), + # --- Annotated metadata --- + pytest.param(_func_annotated_arg, {"option_strings": [], "help": "Your name"}, id="annotated_help"), + pytest.param( + _func_annotated_option, {"option_strings": ["--color", "-c"], "help": "Pick"}, id="annotated_custom_flags" + ), + pytest.param(_func_annotated_metavar, {"option_strings": [], "metavar": "NAME"}, id="annotated_metavar"), + pytest.param(_func_annotated_nargs, {"option_strings": [], "nargs": 2}, id="annotated_nargs"), + pytest.param(_func_annotated_required, {"option_strings": ["--name"], "required": True}, id="annotated_required"), + pytest.param( + _func_annotated_required_auto_flag, + {"option_strings": ["--name"], "required": True}, + id="annotated_required_auto_flag", + ), + pytest.param(_func_annotated_choices, {"option_strings": [], "choices": ["a", "b"]}, id="annotated_choices"), + # --- Keyword-only --- + pytest.param(_func_kw_only, {"option_strings": ["--name"], "required": True}, id="kw_only_required"), + pytest.param(_func_kw_only_with_default, {"option_strings": ["--name"], "default": "world"}, id="kw_only_default"), + # --- Underscore in flag names --- + pytest.param(_func_underscore_option, {"option_strings": ["--my_param"], "default": "x"}, id="underscore_flag"), + # --- Default type preservation --- + pytest.param( + _func_default_type_mismatch, {"option_strings": ["--count"], "default": "1"}, id="default_not_coerced" + ), + pytest.param(_func_path_default, {"option_strings": ["--file"], "default": Path("/tmp")}, id="path_default"), + # --- Optional + Annotated (union inside) --- + pytest.param( + _func_optional_annotated_inside, + {"option_strings": ["--name"], "default": None}, + id="optional_annotated_inside", + ), + ], + ) + def test_action_attributes(self, func, expected) -> None: + action = _get_param_action(func) + for key, value in expected.items(): + assert getattr(action, key) == value, f"{key}: expected {value!r}, got {getattr(action, key)!r}" + + def test_annotated_action_count(self) -> None: + action = _get_param_action(_func_annotated_action) + assert isinstance(action, argparse._CountAction) + + def test_annotated_action_count_non_bool(self) -> None: + action = _get_param_action(_func_annotated_action_non_bool) + assert isinstance(action, argparse._CountAction) + assert action.default == 0 + + @pytest.mark.parametrize( + "func", + [ + pytest.param(_func_set, id="set"), + pytest.param(_func_tuple_ellipsis, id="tuple"), + ], + ) + def test_collection_uses_casting_action(self, func) -> None: + action = _get_param_action(func) + assert isinstance(action, _CollectionCastingAction) + + def test_self_skipped(self) -> None: + parser = build_parser_from_function(_func_str) + dests = {a.dest for a in parser._actions} + assert "self" not in dests + + def test_no_params_produces_empty_parser(self) -> None: + """A function with zero parameters (not even self) produces a parser with no actions.""" + + def bare() -> None: ... + + parser = build_parser_from_function(bare) + dests = {a.dest for a in parser._actions if a.dest != "help"} + assert dests == set() + + def test_get_type_hints_failure_raises(self) -> None: + def do_broken(self, name: "NonExistentType"): # noqa: F821 + pass + + with pytest.raises(TypeError, match="Failed to resolve type hints"): + build_parser_from_function(do_broken) + + def test_validate_base_command_type_hints_failure_raises(self) -> None: + """_validate_base_command_params should raise, not swallow, type hint failures.""" + from cmd2.annotated import _validate_base_command_params + + def do_broken(self, cmd2_handler, name: "NonExistentType"): # noqa: F821 + pass + + with pytest.raises(TypeError, match="Failed to resolve type hints"): + _validate_base_command_params(do_broken) + + def test_dest_param_raises(self) -> None: + with pytest.raises(ValueError, match="dest"): + build_parser_from_function(_func_dest_param) + + def test_subcommand_param_raises(self) -> None: + def func(self, subcommand: str) -> None: ... + + with pytest.raises(ValueError, match="subcommand"): + build_parser_from_function(func) + + def test_with_annotated_positional_only_param_raises(self) -> None: + with pytest.raises(TypeError, match="positional-only"): + build_parser_from_function(_func_positional_only) + + def test_optional_annotated_outside_raises(self) -> None: + with pytest.raises(TypeError, match="Annotated"): + build_parser_from_function(_func_optional_annotated_outside) + + def test_annotated_ambiguous_union_raises(self) -> None: + """Annotated[str | int, meta] must raise -- ambiguous inner union.""" + with pytest.raises(TypeError, match="ambiguous"): + _resolve_annotation(Annotated[str | int, Option("--name")]) + + def test_multi_param_order_and_presence(self) -> None: + """Positional order preserved, options generated correctly.""" + parser = build_parser_from_function(_func_multi) + positionals = [a.dest for a in parser._actions if not a.option_strings and a.dest != "help"] + assert positionals == ["a", "b"] + dests = {a.dest for a in parser._actions} + assert "c" in dests + + +class TestTypeInferenceBuildParser: + """Type-inference behavior and override precedence when building parser actions.""" + + def test_choices_provider_overrides_inferred_enum_choices(self) -> None: + action = _get_param_action(_func_choices_provider_on_enum) + assert action.choices is None + assert action.get_choices_provider() is not None # type: ignore[attr-defined] + assert action.get_completer() is None # type: ignore[attr-defined] + + def test_completer_overrides_inferred_path_completion(self) -> None: + action = _get_param_action(_func_completer_on_path) + assert action.get_choices_provider() is None # type: ignore[attr-defined] + assert action.get_completer() is cmd2.Cmd.path_complete # type: ignore[attr-defined] + + def test_inferred_enum_choices_match_type_converter(self) -> None: + """Enum choices must be convertible by the type converter.""" + action = _get_param_action(_func_enum) + converter = action.type + for choice in action.choices: + assert isinstance(converter(str(choice)), _Color) + + +# --------------------------------------------------------------------------- +# Argument groups and mutually exclusive groups +# --------------------------------------------------------------------------- + + +class TestArgumentGroups: + def test_groups_and_mutex_applied(self) -> None: + parser = build_parser_from_function( + _func_grouped, + groups=(("local", "remote"), ("force", "dry_run")), + mutually_exclusive_groups=(("local", "remote"), ("force", "dry_run")), + ) + + nonempty_groups = [group for group in parser._action_groups if group._group_actions] + grouped_dests = [{action.dest for action in group._group_actions} for group in nonempty_groups] + assert {"local", "remote"} in grouped_dests + assert {"force", "dry_run"} in grouped_dests + + mutex_groups = [{action.dest for action in group._group_actions} for group in parser._mutually_exclusive_groups] + assert {"local", "remote"} in mutex_groups + assert {"force", "dry_run"} in mutex_groups + + def test_group_nonexistent_param_raises(self) -> None: + with pytest.raises(ValueError, match="nonexistent parameter"): + build_parser_from_function(_func_grouped, groups=(("missing",),)) + + def test_param_in_multiple_groups_raises(self) -> None: + with pytest.raises(ValueError, match="cannot be assigned to both argument group"): + build_parser_from_function(_func_grouped, groups=(("local",), ("local", "remote"))) + + def test_mutex_group_spanning_different_argument_groups_raises(self) -> None: + with pytest.raises(ValueError, match="spans parameters in different argument groups"): + build_parser_from_function( + _func_grouped, + groups=(("local",), ("remote",)), + mutually_exclusive_groups=(("local", "remote"),), + ) + + def test_mutually_exclusive_group(self) -> None: + """Mutually exclusive params cannot be used together.""" + + def func(self, verbose: bool = False, quiet: bool = False) -> None: ... + + parser = build_parser_from_function(func, mutually_exclusive_groups=(("verbose", "quiet"),)) + assert len(parser._mutually_exclusive_groups) == 1 + group_dests = {a.dest for a in parser._mutually_exclusive_groups[0]._group_actions} + assert group_dests == {"verbose", "quiet"} + with pytest.raises(SystemExit): + parser.parse_args(["--verbose", "--quiet"]) + + def test_multiple_mutually_exclusive_groups(self) -> None: + """Multiple mutually exclusive groups.""" + + def func(self, verbose: bool = False, quiet: bool = False, json: bool = False, csv: bool = False) -> None: ... + + parser = build_parser_from_function(func, mutually_exclusive_groups=(("verbose", "quiet"), ("json", "csv"))) + assert len(parser._mutually_exclusive_groups) == 2 + + def test_argument_group(self) -> None: + """Arguments in a group appear under a shared heading in help.""" + + def func(self, src: str, dst: str, recursive: bool = False, verbose: bool = False) -> None: ... + + parser = build_parser_from_function(func, groups=(("src", "dst"),)) + default_titles = {"Positional Arguments", "options"} + custom_groups = [g for g in parser._action_groups if g.title not in default_titles] + assert len(custom_groups) >= 1 + all_custom_dests = {a.dest for g in custom_groups for a in g._group_actions} + assert {"src", "dst"} <= all_custom_dests + + def test_mutually_exclusive_via_decorator(self) -> None: + """@with_annotated(mutually_exclusive_groups=...) works end-to-end.""" + + class App(cmd2.Cmd): + @cmd2.with_annotated(mutually_exclusive_groups=(("verbose", "quiet"),)) + def do_run(self, verbose: bool = False, quiet: bool = False) -> None: + if verbose: + self.poutput("verbose") + elif quiet: + self.poutput("quiet") + else: + self.poutput("normal") + + app = App() + out, _err = run_cmd(app, "run --verbose") + assert out == ["verbose"] + + _out, err = run_cmd(app, "run --verbose --quiet") + assert any("not allowed" in line.lower() for line in err) + + def test_group_and_mutex_can_overlap(self) -> None: + def func(self, json: bool = False, csv: bool = False, plain: bool = False) -> None: ... + + parser = build_parser_from_function( + func, + groups=(("json", "csv"),), + mutually_exclusive_groups=(("json", "csv"),), + ) + custom_groups = [g for g in parser._action_groups if g.title not in {"Positional Arguments", "options"}] + all_custom_dests = {a.dest for g in custom_groups for a in g._group_actions} + assert {"json", "csv"} <= all_custom_dests + with pytest.raises(SystemExit): + parser.parse_args(["--json", "--csv"]) + + +class TestGroupHelpers: + def test_validate_group_members_rejects_nonexistent_param(self) -> None: + with pytest.raises(ValueError, match="nonexistent"): + _validate_group_members(("verbose", "nonexistent"), all_param_names={"verbose"}, group_type="groups") + + def test_build_argument_group_targets(self) -> None: + parser = argparse.ArgumentParser() + target_for, argument_group_for = _build_argument_group_targets( + parser, + groups=(("src", "dst"),), + all_param_names={"src", "dst", "recursive"}, + ) + assert set(target_for) == {"src", "dst"} + assert set(argument_group_for) == {"src", "dst"} + assert target_for["src"] is argument_group_for["src"] + assert target_for["dst"] is argument_group_for["dst"] + + def test_build_argument_group_targets_rejects_duplicate_assignment(self) -> None: + parser = argparse.ArgumentParser() + with pytest.raises(ValueError, match="argument group 1 and argument group 2"): + _build_argument_group_targets( + parser, + groups=(("verbose",), ("verbose",)), + all_param_names={"verbose"}, + ) + + def test_apply_mutex_group_targets(self) -> None: + parser = argparse.ArgumentParser() + target_for, argument_group_for = _build_argument_group_targets( + parser, + groups=(("json", "csv"),), + all_param_names={"json", "csv", "plain"}, + ) + + _apply_mutex_group_targets( + parser, + target_for=target_for, + argument_group_for=argument_group_for, + mutually_exclusive_groups=(("json", "csv"),), + all_param_names={"json", "csv", "plain"}, + ) + + assert target_for["json"] is target_for["csv"] + assert isinstance(target_for["json"], argparse._MutuallyExclusiveGroup) + + def test_apply_mutex_group_targets_rejects_duplicate_assignment(self) -> None: + parser = argparse.ArgumentParser() + with pytest.raises(ValueError, match="multiple mutually exclusive groups"): + _apply_mutex_group_targets( + parser, + target_for={}, + argument_group_for={}, + mutually_exclusive_groups=(("verbose",), ("verbose",)), + all_param_names={"verbose"}, + ) + + def test_apply_mutex_group_targets_rejects_cross_group_members(self) -> None: + parser = argparse.ArgumentParser() + _target_for, argument_group_for = _build_argument_group_targets( + parser, + groups=(("src",), ("dst",)), + all_param_names={"src", "dst"}, + ) + + with pytest.raises(ValueError, match="different argument groups"): + _apply_mutex_group_targets( + parser, + target_for={}, + argument_group_for=argument_group_for, + mutually_exclusive_groups=(("src", "dst"),), + all_param_names={"src", "dst"}, + ) + + +# --------------------------------------------------------------------------- +# _resolve_annotation: positional vs option classification + bool flag +# --------------------------------------------------------------------------- + +_ARG_META = Argument(help_text="Name") +_OPT_META = Option("--color", "-c", help_text="Pick") + + +class TestResolveAnnotation: + @pytest.mark.parametrize( + ("annotation", "has_default", "expected_positional", "expected_bool_flag"), + [ + pytest.param(str, False, True, False, id="plain_str"), + pytest.param(str | None, False, False, False, id="optional_str"), + pytest.param(Annotated[str, _ARG_META], False, True, False, id="annotated_argument"), + pytest.param(Annotated[str, _OPT_META], False, False, False, id="annotated_option"), + pytest.param(Annotated[str, "some doc"], False, True, False, id="annotated_no_meta"), + pytest.param(str, True, False, False, id="has_default"), + pytest.param(bool, True, False, True, id="bool_flag"), + ], + ) + def test_classification(self, annotation, has_default, expected_positional, expected_bool_flag) -> None: + _kwargs, _meta, positional, is_bool_flag = _resolve_annotation(annotation, has_default=has_default) + assert positional is expected_positional + assert is_bool_flag is expected_bool_flag + + def test_optional_wrapping_annotated_with_none_inside(self) -> None: + """Optional[Annotated[T | None, meta]] is allowed (inner type contains None).""" + ann = Annotated[str | None, _OPT_META] | None + _kwargs, meta, positional, _bf = _resolve_annotation(ann) + assert meta is _OPT_META + assert positional is False + + def test_typing_union_optional(self) -> None: + ns: dict = {} + exec("import typing; t = typing.Union[str, None]", ns) + _kwargs, _meta, positional, _bool_flag = _resolve_annotation(ns["t"]) + assert positional is False + + def test_annotated_multiple_metadata_picks_first(self) -> None: + meta1 = Argument(help_text="first") + meta2 = Option("--x", help_text="second") + kwargs, meta, _, _ = _resolve_annotation(Annotated[str, meta1, meta2]) + assert meta is meta1 + assert kwargs.get("help") == "first" + + +# --------------------------------------------------------------------------- +# Error paths +# --------------------------------------------------------------------------- + + +class TestUnsupportedPatterns: + def test_union_raises_with_diagnostic_message(self) -> None: + with pytest.raises(TypeError, match=r"str.*int") as exc_info: + _resolve_annotation(str | int) + assert "Union" in str(exc_info.value) + + def test_tuple_mixed_raises(self) -> None: + with pytest.raises(TypeError, match="mixed element types"): + _resolve_annotation(tuple[int, str, float]) + + @pytest.mark.parametrize( + "annotation", + [ + pytest.param(list[set[int]], id="list_of_set"), + pytest.param(set[list[str]], id="set_of_list"), + pytest.param(tuple[list[int], ...], id="tuple_of_list"), + ], + ) + def test_nested_collection_raises(self, annotation) -> None: + with pytest.raises(TypeError, match="Nested collections are not supported"): + _resolve_annotation(annotation) + + @pytest.mark.parametrize( + "annotation", + [ + pytest.param(frozenset[str], id="frozenset"), + pytest.param(dict[str, int], id="dict"), + ], + ) + def test_unsupported_collection_no_nargs(self, annotation) -> None: + kwargs, _, _, _ = _resolve_annotation(annotation) + assert "nargs" not in kwargs + assert "action" not in kwargs + + @pytest.mark.parametrize( + "annotation", + [ + pytest.param(list[int, str], id="list_multi_args"), + pytest.param(set[int, str], id="set_multi_args"), + ], + ) + def test_collection_multiple_type_args_raises(self, annotation) -> None: + with pytest.raises(TypeError, match="type arguments is not supported"): + _resolve_annotation(annotation) + + def test_tuple_ellipsis_wrong_position_raises(self) -> None: + with pytest.raises(TypeError, match="Ellipsis in an unexpected position"): + _resolve_annotation(tuple[..., int]) + + def test_single_element_union_without_none_raises(self) -> None: + """Union with one non-None type and no None should raise.""" + from typing import Union + from unittest.mock import patch + + from cmd2.annotated import _unwrap_optional + + # Python normalizes Union[str] to str, so we can't construct this + # through normal typing. Patch get_origin/get_args to simulate it. + sentinel = object() + with ( + patch("cmd2.annotated.get_origin", return_value=Union), + patch("cmd2.annotated.get_args", return_value=(str,)), + pytest.raises(TypeError, match="single-element Union"), + ): + _unwrap_optional(sentinel) + + +# --------------------------------------------------------------------------- +# Converters +# --------------------------------------------------------------------------- + + +class TestParseBool: + @pytest.mark.parametrize("value", ["1", "true", "True", "t", "yes", "y", "on"]) + def test_true(self, value) -> None: + assert _parse_bool(value) is True + + @pytest.mark.parametrize("value", ["0", "false", "False", "f", "no", "n", "off"]) + def test_false(self, value) -> None: + assert _parse_bool(value) is False + + def test_invalid(self) -> None: + with pytest.raises(argparse.ArgumentTypeError, match="invalid boolean"): + _parse_bool("maybe") + + +class TestEnumConverter: + @pytest.mark.parametrize( + ("enum_cls", "input_val", "expected"), + [ + pytest.param(_Color, "red", _Color.red, id="str_by_value"), + pytest.param(_IntColor, "1", _IntColor.red, id="int_by_value"), + pytest.param(_IntColor, "red", _IntColor.red, id="int_by_name"), + pytest.param(_PlainColor, "red", _PlainColor.RED, id="plain_by_value"), + pytest.param(_PlainColor, "BLUE", _PlainColor.BLUE, id="plain_by_name"), + ], + ) + def test_convert(self, enum_cls, input_val, expected) -> None: + assert _make_enum_type(enum_cls)(input_val) is expected + + def test_invalid(self) -> None: + with pytest.raises(argparse.ArgumentTypeError, match="invalid choice"): + _make_enum_type(_Color)("purple") + + def test_preserves_class(self) -> None: + assert _make_enum_type(_Color)._cmd2_enum_class is _Color + + +class TestLiteralConverter: + @pytest.mark.parametrize( + ("values", "input_val", "expected"), + [ + pytest.param(["fast", "slow"], "fast", "fast", id="str_match"), + pytest.param([1, 2, 3], "2", 2, id="int_match"), + pytest.param([True, False], "yes", True, id="bool_true_coercion"), + pytest.param([True, False], "0", False, id="bool_false_coercion"), + ], + ) + def test_convert(self, values, input_val, expected) -> None: + assert _make_literal_type(values)(input_val) == expected + + def test_invalid(self) -> None: + with pytest.raises(argparse.ArgumentTypeError, match="invalid choice"): + _make_literal_type(["fast", "slow"])("medium") + + def test_direct_match_before_bool_coercion(self) -> None: + assert _make_literal_type(["yes", "no"])("yes") == "yes" + + def test_colliding_str_representations_raises(self) -> None: + with pytest.raises(TypeError, match="same string representation"): + _make_literal_type(["1", 1]) + + +# --------------------------------------------------------------------------- +# Metadata classes +# --------------------------------------------------------------------------- + + +class TestMetadata: + @pytest.mark.parametrize( + ("meta_kwargs", "expected"), + [ + pytest.param({}, {}, id="empty"), + pytest.param({"help_text": "Name"}, {"help": "Name"}, id="help_text"), + pytest.param({"metavar": "NAME"}, {"metavar": "NAME"}, id="metavar"), + pytest.param({"choices": ["a", "b"]}, {"choices": ["a", "b"]}, id="choices"), + pytest.param({"table_columns": ("Name", "Age")}, {"table_columns": ("Name", "Age")}, id="table_columns"), + pytest.param({"suppress_tab_hint": True}, {"suppress_tab_hint": True}, id="suppress_tab_hint"), + ], + ) + def test_to_kwargs(self, meta_kwargs, expected) -> None: + assert Argument(**meta_kwargs).to_kwargs() == expected + + def test_to_kwargs_preserves_empty_string(self) -> None: + """Explicit empty string help_text should not be silently dropped.""" + assert Argument(help_text="").to_kwargs() == {"help": ""} + + def test_to_kwargs_preserves_empty_choices(self) -> None: + """Explicit empty choices list should not be silently dropped.""" + assert Argument(choices=[]).to_kwargs() == {"choices": []} + + def test_option_to_kwargs_includes_action_and_required(self) -> None: + opt = Option("--color", "-c", action="count", required=True, help_text="Pick") + kwargs = opt.to_kwargs() + assert "names" not in kwargs + assert "flags" not in kwargs + assert kwargs["action"] == "count" + assert kwargs["required"] is True + assert kwargs["help"] == "Pick" + + def test_choices_provider_in_kwargs(self) -> None: + def provider(cmd): + return [] + + assert Argument(choices_provider=provider).to_kwargs()["choices_provider"] is provider + + def test_completer_in_kwargs(self) -> None: + assert Argument(completer=cmd2.Cmd.path_complete).to_kwargs()["completer"] is cmd2.Cmd.path_complete + + +# --------------------------------------------------------------------------- +# _CollectionCastingAction +# --------------------------------------------------------------------------- + + +class TestCollectionCastingAction: + def test_casts_list_to_container(self) -> None: + action = _CollectionCastingAction( + option_strings=[], + dest="items", + nargs="+", + container_factory=set, + ) + ns = argparse.Namespace() + action(argparse.ArgumentParser(), ns, ["a", "b", "a"]) + assert ns.items == {"a", "b"} + + def test_non_list_passthrough(self) -> None: + action = _CollectionCastingAction( + option_strings=[], + dest="items", + nargs="?", + container_factory=set, + ) + ns = argparse.Namespace() + action(argparse.ArgumentParser(), ns, "single_value") + assert ns.items == "single_value" + + +# --------------------------------------------------------------------------- +# _filtered_namespace_kwargs edge cases +# --------------------------------------------------------------------------- + + +class TestFilteredNamespaceKwargs: + def test_excludes_subcmd_handler_key(self) -> None: + from cmd2.annotated import _filtered_namespace_kwargs + from cmd2.constants import NS_ATTR_SUBCMD_HANDLER + + ns = argparse.Namespace(**{NS_ATTR_SUBCMD_HANDLER: lambda: None, "name": "Alice"}) + result = _filtered_namespace_kwargs(ns) + assert NS_ATTR_SUBCMD_HANDLER not in result + assert result == {"name": "Alice"} + + def test_excludes_subcommand_key(self) -> None: + from cmd2.annotated import _filtered_namespace_kwargs + + ns = argparse.Namespace(subcommand="add", name="Alice") + result = _filtered_namespace_kwargs(ns, exclude_subcommand=True) + assert "subcommand" not in result + assert result == {"name": "Alice"} + + +# --------------------------------------------------------------------------- +# _parse_positionals edge case +# --------------------------------------------------------------------------- + + +class TestParsePositionals: + def test_skips_non_statement_next_arg(self) -> None: + """When next_arg after Cmd is not Statement/str, loop continues.""" + from cmd2.decorators import _parse_positionals + + app = cmd2.Cmd() + # Two Cmd-like objects: first has non-str next, second has str next + result_cmd, result_stmt = _parse_positionals((app, 42, app, "hello")) + assert result_cmd is app + assert result_stmt == "hello" + + def test_matches_statement_type(self) -> None: + """When next_arg is a Statement, it is accepted.""" + from cmd2.decorators import _parse_positionals + from cmd2.parsing import Statement + + app = cmd2.Cmd() + stmt = Statement("hello") + result_cmd, result_stmt = _parse_positionals((app, stmt)) + assert result_cmd is app + assert result_stmt is stmt + + +# --------------------------------------------------------------------------- +# Runtime coverage +# --------------------------------------------------------------------------- + + +class _Sport(str, enum.Enum): + football = "football" + basketball = "basketball" + tennis = "tennis" + + +class _RuntimeAnnotatedApp(cmd2.Cmd): + def __init__(self) -> None: + super().__init__() + self._items = ["apple", "banana", "cherry"] + + def item_choices(self) -> list[cmd2.CompletionItem]: + return [cmd2.CompletionItem(item) for item in self._items] + + @cmd2.with_annotated + def do_greet(self, name: str, count: int = 1) -> None: + for _ in range(count): + self.poutput(f"Hello {name}") + + @cmd2.with_annotated + def do_add(self, a: int, b: int = 0) -> None: + self.poutput(str(a + b)) + + @cmd2.with_annotated + def do_paint( + self, + item: str, + color: Annotated[_Color, Option("--color", "-c", help_text="Color")] = _Color.blue, + verbose: bool = False, + ) -> None: + msg = f"Painting {item} {color.value}" + if verbose: + msg += " (verbose)" + self.poutput(msg) + + @cmd2.with_annotated + def do_pick(self, item: Annotated[str, Argument(choices_provider=item_choices)]) -> None: + self.poutput(f"Picked: {item}") + + @cmd2.with_annotated + def do_open(self, path: Path) -> None: + self.poutput(f"Opening: {path}") + + @cmd2.with_annotated + def do_sport(self, sport: _Sport) -> None: + self.poutput(f"Playing: {sport.value}") + + @cmd2.with_annotated + def do_toggle(self, enabled: bool) -> None: + self.poutput(f"Enabled: {enabled}") + + @cmd2.with_annotated(preserve_quotes=True) + def do_raw(self, text: str) -> None: + self.poutput(f"raw: {text}") + + +@pytest.fixture +def runtime_app() -> _RuntimeAnnotatedApp: + app = _RuntimeAnnotatedApp() + app.stdout = cmd2.utils.StdSim(app.stdout) + return app + + +class TestRuntimeExecution: + @pytest.mark.parametrize( + ("command", "expected"), + [ + pytest.param("greet Alice", ["Hello Alice"], id="greet_basic"), + pytest.param("greet Alice --count 3", ["Hello Alice", "Hello Alice", "Hello Alice"], id="greet_count"), + pytest.param("add 2 --b 3", ["5"], id="add"), + pytest.param("add 10", ["10"], id="add_default"), + pytest.param("paint wall", ["Painting wall blue"], id="paint_default_color"), + pytest.param("paint wall --color red", ["Painting wall red"], id="paint_color"), + pytest.param("paint wall --verbose", ["Painting wall blue (verbose)"], id="paint_verbose"), + pytest.param("sport football", ["Playing: football"], id="sport_enum"), + ], + ) + def test_command_execution(self, runtime_app, command, expected) -> None: + out, _err = run_cmd(runtime_app, command) + assert out == expected + + def test_help_shows_arguments(self, runtime_app) -> None: + out, _ = run_cmd(runtime_app, "help greet") + assert "name" in "\n".join(out).lower() + + def test_help_shows_option_help(self, runtime_app) -> None: + out, _ = run_cmd(runtime_app, "help paint") + help_text = "\n".join(out) + assert "Color" in help_text or "color" in help_text + + +class TestRuntimeCompletion: + def test_enum_completion(self, runtime_app) -> None: + assert sorted(_complete_cmd(runtime_app, "paint wall --color ", "")) == ["blue", "green", "red"] + + def test_enum_completion_partial(self, runtime_app) -> None: + assert _complete_cmd(runtime_app, "paint wall --color r", "r") == ["red"] + + def test_choices_provider_completion(self, runtime_app) -> None: + assert sorted(_complete_cmd(runtime_app, "pick ", "")) == ["apple", "banana", "cherry"] + + def test_positional_enum_completion(self, runtime_app) -> None: + assert _complete_cmd(runtime_app, "sport foot", "foot") == ["football"] + + def test_path_completion_from_annotation(self, runtime_app, tmp_path) -> None: + test_file = tmp_path / "annotated-path.txt" + test_file.touch() + text = str(tmp_path) + "/" + result_strings = _complete_cmd(runtime_app, f"open {text}", text) + assert any("annotated-path.txt" in item for item in result_strings) + + def test_positional_bool_completion_from_annotation(self, runtime_app) -> None: + completions = set(_complete_cmd(runtime_app, "toggle ", "")) + assert {"true", "false", "yes", "no", "on", "off", "1", "0"}.issubset(completions) + + +class _AnnotatedCommandSet(cmd2.CommandSet): + def __init__(self) -> None: + super().__init__() + self._sports = ["football", "baseball"] + + def sport_choices(self) -> list[cmd2.CompletionItem]: + return [cmd2.CompletionItem(sport) for sport in self._sports] + + @cmd2.with_annotated + def do_play(self, sport: Annotated[str, Argument(choices_provider=sport_choices)]) -> None: + self._cmd.poutput(f"Playing {sport}") + + +@pytest.fixture +def cmdset_app() -> cmd2.Cmd: + cmdset = _AnnotatedCommandSet() + app = cmd2.Cmd(command_sets=[cmdset]) + app.stdout = cmd2.utils.StdSim(app.stdout) + return app + + +class TestCommandSet: + def test_command_set_execution(self, cmdset_app) -> None: + out, _err = run_cmd(cmdset_app, "play football") + assert out == ["Playing football"] + + def test_command_set_completion(self, cmdset_app) -> None: + assert sorted(_complete_cmd(cmdset_app, "play ", "")) == ["baseball", "football"] + + +# --------------------------------------------------------------------------- +# Integration: with_annotated decorator runs commands through cmd2 +# --------------------------------------------------------------------------- + + +class _IntegrationApp(cmd2.Cmd): + def __init__(self) -> None: + super().__init__() + self.ns_calls = 0 + + def namespace_provider(self) -> argparse.Namespace: + self.ns_calls += 1 + ns = argparse.Namespace() + ns.custom_stuff = "custom" + return ns + + @cmd2.with_annotated + def do_greet(self, name: str, count: int = 1, loud: bool = False, *, keyword_arg: str | None = None) -> None: + """Greet someone.""" + for _ in range(count): + msg = f"Hello {name}" + self.poutput(msg.upper() if loud else msg) + if keyword_arg is not None: + self.poutput(keyword_arg) + + @cmd2.with_annotated(with_unknown_args=True) + def do_flex(self, name: str, _unknown: list[str] | None = None) -> None: + self.poutput(f"name={name}") + if _unknown: + self.poutput(f"unknown={_unknown}") + + @cmd2.with_annotated(preserve_quotes=True) + def do_raw(self, text: str) -> None: + self.poutput(f"raw: {text}") + + @cmd2.with_annotated(ns_provider=namespace_provider) + def do_ns_test(self, cmd2_statement=None) -> None: + self.poutput("ok") + + @cmd2.with_annotated + def do_prefixed(self, cmd2_mode: int = 1) -> None: + self.poutput(f"cmd2_mode={cmd2_mode}") + + +class _GroupedParserApp(cmd2.Cmd): + @cmd2.with_annotated( + groups=(("local", "remote"), ("force", "dry_run")), + mutually_exclusive_groups=(("local", "remote"), ("force", "dry_run")), + ) + def do_transfer( + self, + *, + local: str | None = None, + remote: str | None = None, + force: bool = False, + dry_run: bool = False, + ) -> None: + target = local if local is not None else remote + mode = "force" if force else "dry-run" if dry_run else "normal" + self.poutput(f"Transfer {target} in {mode} mode") + + +@pytest.fixture +def app() -> _IntegrationApp: + return _IntegrationApp() + + +@pytest.fixture +def grouped_app() -> _GroupedParserApp: + return _GroupedParserApp() + + +class TestWithAnnotatedIntegration: + """Integration tests covering the decorator's cmd_wrapper runtime paths.""" + + @pytest.mark.parametrize( + ("command", "expected"), + [ + pytest.param("greet Alice", ["Hello Alice"], id="basic"), + pytest.param("greet Alice --count 2 --loud", ["HELLO ALICE", "HELLO ALICE"], id="options"), + pytest.param("greet Alice --no-loud", ["Hello Alice"], id="bool_no_flag"), + pytest.param("greet Alice --loud", ["HELLO ALICE"], id="bool_flag"), + pytest.param("flex Alice", ["name=Alice"], id="unknown_args_empty"), + ], + ) + def test_command_execution(self, app, command, expected) -> None: + out, _err = run_cmd(app, command) + assert out == expected + + def test_with_unknown_args(self, app) -> None: + out, _err = run_cmd(app, "flex Alice --extra stuff") + assert out[0] == "name=Alice" + assert "unknown=" in out[1] + + def test_preserve_quotes(self, app) -> None: + out, _err = run_cmd(app, 'raw "hello world"') + assert out == ['raw: "hello world"'] + + def test_error_produces_stderr(self, app) -> None: + _out, err = run_cmd(app, "greet") + assert any("error" in line.lower() or "usage" in line.lower() for line in err) + + def test_no_args_raises_type_error(self, app) -> None: + with pytest.raises(TypeError, match="Expected arguments"): + app.do_greet() + + def test_with_unknown_args_requires_param(self) -> None: + with pytest.raises(TypeError, match="_unknown"): + + @cmd2.with_annotated(with_unknown_args=True) + def do_broken(self, name: str) -> None: + pass + + def test_positional_only_unknown_rejected(self) -> None: + with pytest.raises(TypeError, match="keyword-compatible"): + + @cmd2.with_annotated(with_unknown_args=True) + def do_broken(self, _unknown: list[str], /) -> None: + pass + + def test_ns_provider(self, app) -> None: + out, _err = run_cmd(app, "ns_test") + assert out == ["ok"] + assert app.ns_calls == 1 + + def test_cmd2_prefixed_param_is_preserved(self, app) -> None: + out, _err = run_cmd(app, "prefixed --cmd2_mode 5") + assert out == ["cmd2_mode=5"] + + def test_kwargs_passthrough(self, app) -> None: + app.do_greet("Alice", keyword_arg="kwarg_value") + + def test_bare_call_decorator(self) -> None: + """@with_annotated() with empty parens works same as @with_annotated.""" + + class App(cmd2.Cmd): + @cmd2.with_annotated() + def do_echo(self, text: str) -> None: + self.poutput(text) + + out, _err = run_cmd(App(), "echo hi") + assert out == ["hi"] + + def test_missing_parser_raises(self, app) -> None: + from unittest.mock import patch + + with ( + patch.object(app.command_parsers, "get", return_value=None), + pytest.raises(ValueError, match="No argument parser found"), + ): + app.do_greet("Alice") + + +class TestGroupedParserIntegration: + def test_grouped_command_executes(self, grouped_app) -> None: + out, _err = run_cmd(grouped_app, "transfer --local build.tar.gz --dry_run") + assert out == ["Transfer build.tar.gz in dry-run mode"] + + def test_grouped_command_mutex_error(self, grouped_app) -> None: + _out, err = run_cmd(grouped_app, "transfer --local a --remote b") + assert any("not allowed with argument" in line.lower() for line in err) + + def test_grouped_command_help_lists_flags(self, grouped_app) -> None: + out, _err = run_cmd(grouped_app, "help transfer") + help_text = "\n".join(out) + assert "--local" in help_text + assert "--remote" in help_text + assert "--force" in help_text + assert "--dry_run" in help_text + + +# --------------------------------------------------------------------------- +# Subcommands: @with_annotated(base_command=True) + @with_annotated(subcommand_to=...) +# --------------------------------------------------------------------------- + + +class _SubcommandApp(cmd2.Cmd): + # Level 1: base command + @cmd2.with_annotated(base_command=True) + def do_manage(self, cmd2_handler, verbose: bool = False) -> None: + """Management command with subcommands.""" + if verbose: + self.poutput("verbose mode") + handler = cmd2_handler + if handler: + handler() + + # Level 2: leaf subcommands + @cmd2.with_annotated(subcommand_to="manage", help="add something") + def manage_add(self, value: str) -> None: + self.poutput(f"added: {value}") + + @cmd2.with_annotated(subcommand_to="manage", help="list things", aliases=["ls"]) + def manage_list(self) -> None: + self.poutput("listing all") + + # Level 2: intermediate subcommand (also a base for level 3) + @cmd2.with_annotated(subcommand_to="manage", base_command=True, help="manage members") + def manage_member(self, cmd2_handler) -> None: + handler = cmd2_handler + if handler: + handler() + + # Level 3: nested subcommand + @cmd2.with_annotated(subcommand_to="manage member", help="add a member") + def manage_member_add(self, name: str) -> None: + self.poutput(f"member added: {name}") + + +@pytest.fixture +def subcmd_app() -> _SubcommandApp: + return _SubcommandApp() + + +class TestSubcommands: + @pytest.mark.parametrize( + ("command", "expected"), + [ + pytest.param("manage add hello", ["added: hello"], id="add"), + pytest.param("manage list", ["listing all"], id="list"), + pytest.param("manage ls", ["listing all"], id="list_alias"), + pytest.param("manage member add Alice", ["member added: Alice"], id="nested_3_levels"), + ], + ) + def test_subcommand_executes(self, subcmd_app, command, expected) -> None: + out, _err = run_cmd(subcmd_app, command) + assert out == expected + + @pytest.mark.parametrize( + "command", + [ + pytest.param("manage", id="missing_subcmd"), + pytest.param("manage delete", id="invalid_subcmd"), + pytest.param("manage member", id="missing_nested_subcmd"), + ], + ) + def test_subcommand_errors(self, subcmd_app, command) -> None: + _out, err = run_cmd(subcmd_app, command) + assert any("error" in line.lower() or "usage" in line.lower() or "invalid" in line.lower() for line in err) + + def test_subcommand_help(self, subcmd_app) -> None: + out, _err = run_cmd(subcmd_app, "help manage") + help_text = "\n".join(out) + assert "add" in help_text + assert "list" in help_text + assert "member" in help_text + + +class TestSubcommandValidation: + def test_base_command_positional_str_raises(self) -> None: + """Positional str param conflicts with subcommand name.""" + with pytest.raises(TypeError, match="positional"): + + @cmd2.with_annotated(base_command=True) + def do_bad(self, name: str, cmd2_handler) -> None: + pass + + def test_base_command_positional_annotated_raises(self) -> None: + """Explicit Argument() metadata forces positional -- conflict.""" + with pytest.raises(TypeError, match="positional"): + + @cmd2.with_annotated(base_command=True) + def do_bad(self, a: Annotated[str, Argument(help_text="x")], cmd2_handler) -> None: + pass + + def test_base_command_missing_handler_raises(self) -> None: + with pytest.raises(TypeError, match="cmd2_handler"): + + @cmd2.with_annotated(base_command=True) + def do_bad(self, verbose: bool = False) -> None: + pass + + @pytest.mark.parametrize( + "kwargs", + [ + pytest.param({"help": "not allowed"}, id="help_only"), + pytest.param({"aliases": ["x"]}, id="aliases_only"), + ], + ) + def test_subcmd_only_params_without_subcommand_to_raises(self, kwargs) -> None: + with pytest.raises(TypeError, match="subcommand_to"): + + @cmd2.with_annotated(**kwargs) + def do_bad(self, name: str) -> None: + pass + + @pytest.mark.parametrize( + ("kwargs", "pattern"), + [ + pytest.param({"with_unknown_args": True}, "with_unknown_args", id="with_unknown_args"), + pytest.param({"preserve_quotes": True}, "preserve_quotes", id="preserve_quotes"), + pytest.param({"ns_provider": lambda self: argparse.Namespace()}, "ns_provider", id="ns_provider"), + ], + ) + def test_subcommand_rejects_unsupported_runtime_options(self, kwargs, pattern) -> None: + with pytest.raises(TypeError, match=pattern): + + @cmd2.with_annotated(subcommand_to="team", **kwargs) + def team_add(self, name: str, _unknown: list[str] | None = None) -> None: + pass + + def test_subcommand_with_mutually_exclusive_groups(self) -> None: + """mutually_exclusive_groups should work on subcommands.""" + + class App(cmd2.Cmd): + @cmd2.with_annotated(base_command=True) + def do_fmt(self, cmd2_handler) -> None: + handler = cmd2_handler + if handler: + handler() + + @cmd2.with_annotated(subcommand_to="fmt", help="output", mutually_exclusive_groups=(("json", "csv"),)) + def fmt_out(self, msg: str, json: bool = False, csv: bool = False) -> None: + self.poutput(f"json={json} csv={csv} {msg}") + + app = App() + out, _err = run_cmd(app, "fmt out hello --json") + assert out == ["json=True csv=False hello"] + _out, err = run_cmd(app, "fmt out hello --json --csv") + assert any("not allowed" in line.lower() for line in err) + + def test_intermediate_base_command_positional_raises(self) -> None: + with pytest.raises(TypeError, match="positional"): + + @cmd2.with_annotated(subcommand_to="team", base_command=True) + def team_member(self, name: str, cmd2_handler) -> None: + pass + + def test_intermediate_base_command_missing_handler_raises(self) -> None: + with pytest.raises(TypeError, match="cmd2_handler"): + + @cmd2.with_annotated(subcommand_to="team", base_command=True) + def team_member(self) -> None: + pass + + @pytest.mark.parametrize( + ("subcommand_to", "func_name"), + [ + pytest.param("team", "wrong_name", id="wrong_prefix"), + pytest.param("team member", "team_wrong", id="wrong_nested_prefix"), + ], + ) + def test_subcommand_naming_enforced(self, subcommand_to, func_name) -> None: + ns: dict = {} + exec(f"def {func_name}(self, x: str) -> None: ...", ns) + with pytest.raises(TypeError, match="must be named"): + cmd2.with_annotated(subcommand_to=subcommand_to)(ns[func_name]) + + def test_subcommand_attributes_set(self) -> None: + from cmd2 import constants + + @cmd2.with_annotated(subcommand_to="team", help="create", aliases=["c"]) + def team_create(self, name: str) -> None: ... + + assert getattr(team_create, constants.SUBCMD_ATTR_COMMAND) == "team" + assert getattr(team_create, constants.SUBCMD_ATTR_NAME) == "create" + assert getattr(team_create, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS) == {"help": "create", "aliases": ["c"]} + parser = getattr(team_create, constants.CMD_ATTR_ARGPARSER)() + assert isinstance(parser, argparse.ArgumentParser) + + def test_subcommand_without_help(self) -> None: + """Subcommand with no help or aliases -- covers the None/empty branches.""" + from cmd2 import constants + + @cmd2.with_annotated(subcommand_to="team") + def team_delete(self) -> None: ... + + assert getattr(team_delete, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS) == {} From f140150b151d5d3a18887d0d8a8410c9675b971c Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Fri, 15 May 2026 17:55:12 +0100 Subject: [PATCH 02/25] chore: fix rebase --- cmd2/annotated.py | 28 ++++++++++++++-------------- examples/annotated_example.py | 16 +++++++--------- tests/test_annotated.py | 18 +++++++++++------- 3 files changed, 32 insertions(+), 30 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 910733f91..64ba8d994 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -99,6 +99,7 @@ def do_paint( ) from . import constants +from .argparse_utils import Cmd2ArgumentParser, SubcommandSpec from .cmd2 import Cmd from .completion import CompletionItem from .decorators import _parse_positionals @@ -816,7 +817,7 @@ def build_parser_from_function( skip_params: frozenset[str] = _SKIP_PARAMS, groups: tuple[tuple[str, ...], ...] | None = None, mutually_exclusive_groups: tuple[tuple[str, ...], ...] | None = None, -) -> argparse.ArgumentParser: +) -> Cmd2ArgumentParser: """Inspect a function's signature and build a ``Cmd2ArgumentParser``. Parameters without defaults become positional arguments. @@ -883,7 +884,7 @@ def build_subcommand_handler( base_command: bool = False, groups: tuple[tuple[str, ...], ...] | None = None, mutually_exclusive_groups: tuple[tuple[str, ...], ...] | None = None, -) -> tuple[Callable[..., Any], str, Callable[[], argparse.ArgumentParser]]: +) -> tuple[Callable[..., Any], str, Callable[[], Cmd2ArgumentParser]]: """Build a subcommand handler wrapper and its parser from type annotations. Validates the naming convention, builds a parser from annotations, and @@ -908,7 +909,7 @@ def handler(self_arg: Any, ns: Any) -> Any: filtered = _filtered_namespace_kwargs(ns, accepted=_accepted) return func(self_arg, **filtered) - def parser_builder() -> argparse.ArgumentParser: + def parser_builder() -> Cmd2ArgumentParser: parser = build_parser_from_function(func, groups=groups, mutually_exclusive_groups=mutually_exclusive_groups) if base_command: parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND", required=True) @@ -994,15 +995,14 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: groups=groups, mutually_exclusive_groups=mutually_exclusive_groups, ) - setattr(handler, constants.SUBCMD_ATTR_COMMAND, subcommand_to) - setattr(handler, constants.SUBCMD_ATTR_NAME, subcmd_name) - setattr(handler, constants.CMD_ATTR_ARGPARSER, subcmd_parser_builder) - add_parser_kwargs: dict[str, Any] = {} - if help is not None: - add_parser_kwargs["help"] = help - if aliases: - add_parser_kwargs["aliases"] = list(aliases) - setattr(handler, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, add_parser_kwargs) + spec = SubcommandSpec( + name=subcmd_name, + command=subcommand_to, + help=help, + aliases=tuple(aliases) if aliases else (), + parser_source=subcmd_parser_builder, + ) + setattr(handler, constants.SUBCMD_ATTR_SPEC, spec) return handler command_name = fn.__name__[len(constants.COMMAND_FUNC_PREFIX) :] @@ -1014,7 +1014,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:]) - def parser_builder() -> argparse.ArgumentParser: + def parser_builder() -> Cmd2ArgumentParser: parser = build_parser_from_function( fn, skip_params=skip_params, @@ -1067,7 +1067,7 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: result: bool | None = fn(owner, **func_kwargs) return result - setattr(cmd_wrapper, constants.CMD_ATTR_ARGPARSER, parser_builder) + setattr(cmd_wrapper, constants.CMD_ATTR_PARSER_SOURCE, parser_builder) setattr(cmd_wrapper, constants.CMD_ATTR_PRESERVE_QUOTES, preserve_quotes) return cmd_wrapper diff --git a/examples/annotated_example.py b/examples/annotated_example.py index 6dad2df5a..22675975f 100755 --- a/examples/annotated_example.py +++ b/examples/annotated_example.py @@ -19,7 +19,7 @@ import sys from argparse import Namespace from decimal import Decimal -from enum import Enum +from enum import StrEnum from pathlib import Path from typing import ( Annotated, @@ -33,14 +33,14 @@ ) -class Color(str, Enum): +class Color(StrEnum): red = "red" green = "green" blue = "blue" yellow = "yellow" -class LogLevel(str, Enum): +class LogLevel(StrEnum): debug = "debug" info = "info" warning = "warning" @@ -280,15 +280,13 @@ def do_manage(self, verbose: bool = False, *, cmd2_handler) -> None: """ if verbose: self.poutput("verbose mode") - handler = cmd2_handler.get() - if handler: - handler() + if cmd2_handler: + cmd2_handler() @cmd2.with_annotated(subcommand_to="manage", base_command=True, help="manage projects") def manage_project(self, *, cmd2_handler) -> None: - handler = cmd2_handler.get() - if handler: - handler() + if cmd2_handler: + cmd2_handler() @cmd2.with_annotated(subcommand_to="manage project", help="add a project") def manage_project_add(self, name: str) -> None: diff --git a/tests/test_annotated.py b/tests/test_annotated.py index 373f14bfa..fa7d855f2 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -41,7 +41,7 @@ # --------------------------------------------------------------------------- -class _Color(str, enum.Enum): +class _Color(enum.StrEnum): red = "red" green = "green" blue = "blue" @@ -875,7 +875,7 @@ def test_matches_statement_type(self) -> None: # --------------------------------------------------------------------------- -class _Sport(str, enum.Enum): +class _Sport(enum.StrEnum): football = "football" basketball = "basketball" tennis = "tennis" @@ -1375,10 +1375,12 @@ def test_subcommand_attributes_set(self) -> None: @cmd2.with_annotated(subcommand_to="team", help="create", aliases=["c"]) def team_create(self, name: str) -> None: ... - assert getattr(team_create, constants.SUBCMD_ATTR_COMMAND) == "team" - assert getattr(team_create, constants.SUBCMD_ATTR_NAME) == "create" - assert getattr(team_create, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS) == {"help": "create", "aliases": ["c"]} - parser = getattr(team_create, constants.CMD_ATTR_ARGPARSER)() + spec = getattr(team_create, constants.SUBCMD_ATTR_SPEC) + assert spec.command == "team" + assert spec.name == "create" + assert spec.help == "create" + assert spec.aliases == ("c",) + parser = spec.parser_source() assert isinstance(parser, argparse.ArgumentParser) def test_subcommand_without_help(self) -> None: @@ -1388,4 +1390,6 @@ def test_subcommand_without_help(self) -> None: @cmd2.with_annotated(subcommand_to="team") def team_delete(self) -> None: ... - assert getattr(team_delete, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS) == {} + spec = getattr(team_delete, constants.SUBCMD_ATTR_SPEC) + assert spec.help is None + assert spec.aliases == () From 076defcdd81efb8f75aef3ea4e1c2a6cb3be7ea5 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Fri, 15 May 2026 18:02:40 +0100 Subject: [PATCH 03/25] chore: clean up test --- tests/test_annotated.py | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/tests/test_annotated.py b/tests/test_annotated.py index fa7d855f2..88cfd706a 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -1369,27 +1369,22 @@ def test_subcommand_naming_enforced(self, subcommand_to, func_name) -> None: with pytest.raises(TypeError, match="must be named"): cmd2.with_annotated(subcommand_to=subcommand_to)(ns[func_name]) - def test_subcommand_attributes_set(self) -> None: + @pytest.mark.parametrize( + ("decorator_kwargs", "expected_help", "expected_aliases"), + [ + pytest.param({"help": "create", "aliases": ["c"]}, "create", ("c",), id="with_help_and_aliases"), + pytest.param({}, None, (), id="without_help_or_aliases"), + ], + ) + def test_subcommand_spec_attributes(self, decorator_kwargs, expected_help, expected_aliases) -> None: from cmd2 import constants - @cmd2.with_annotated(subcommand_to="team", help="create", aliases=["c"]) - def team_create(self, name: str) -> None: ... + @cmd2.with_annotated(subcommand_to="team", **decorator_kwargs) + def team_create(self, name: str = "") -> None: ... spec = getattr(team_create, constants.SUBCMD_ATTR_SPEC) assert spec.command == "team" assert spec.name == "create" - assert spec.help == "create" - assert spec.aliases == ("c",) - parser = spec.parser_source() - assert isinstance(parser, argparse.ArgumentParser) - - def test_subcommand_without_help(self) -> None: - """Subcommand with no help or aliases -- covers the None/empty branches.""" - from cmd2 import constants - - @cmd2.with_annotated(subcommand_to="team") - def team_delete(self) -> None: ... - - spec = getattr(team_delete, constants.SUBCMD_ATTR_SPEC) - assert spec.help is None - assert spec.aliases == () + assert spec.help == expected_help + assert spec.aliases == expected_aliases + assert isinstance(spec.parser_source(), argparse.ArgumentParser) From d4756eb26b2f1bd964f79647e57a7f2c8d20c95b Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Fri, 15 May 2026 18:25:03 +0100 Subject: [PATCH 04/25] chore: more clean up --- cmd2/annotated.py | 57 +++++++++++++++++--------- tests/test_annotated.py | 90 +++++++++++++++++++++++++++++++---------- 2 files changed, 106 insertions(+), 41 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 64ba8d994..fe139e6f8 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -13,8 +13,10 @@ Basic usage -- parameters without defaults become positional arguments, parameters with defaults become ``--option`` flags. Keyword-only parameters (after ``*``) always become options; without a default they -are required. The parameter name ``dest`` is reserved and cannot be -used:: +are required. Underscores in parameter names are auto-converted to +dashes in the generated flag (``dry_run`` -> ``--dry-run``); pass +explicit names via ``Option("--my_flag")`` to opt out. The parameter +name ``dest`` is reserved and cannot be used:: class MyApp(cmd2.Cmd): @cmd2.with_annotated @@ -76,7 +78,13 @@ def do_paint( Note: ``Path`` and ``Enum`` annotations with ``@with_annotated`` also get automatic tab completion via generated parser metadata. If a user-supplied ``choices_provider`` or ``completer`` is set on an argument, -it always takes priority over the type-inferred completion. +it always takes priority over the type-inferred completion. For ``Enum`` and +``Literal``, the restrictive type converter is also stripped so user-supplied +values are not rejected at parse time. The ``Path`` converter is permissive +and is preserved when a custom completer is provided. + +The parameter name ``cmd2_handler`` is reserved for base commands declared with +``with_annotated(base_command=True)`` and may not be used elsewhere. """ import argparse @@ -100,7 +108,6 @@ def do_paint( from . import constants from .argparse_utils import Cmd2ArgumentParser, SubcommandSpec -from .cmd2 import Cmd from .completion import CompletionItem from .decorators import _parse_positionals from .exceptions import Cmd2ArgparseError @@ -166,7 +173,8 @@ class Option(_BaseArgMetadata): """Metadata for an optional/flag argument in an ``Annotated`` type hint. Positional ``*names`` are the flag strings (e.g. ``"--color"``, ``"-c"``). - When omitted, the decorator auto-generates ``--param_name``. + When omitted, the decorator auto-generates ``--param-name`` (underscores + in the parameter name are converted to dashes). Example:: @@ -193,10 +201,10 @@ def __init__( def to_kwargs(self) -> dict[str, Any]: """Return non-None fields as an argparse kwargs dict.""" kwargs = super().to_kwargs() - if self.action: + if self.action is not None: kwargs["action"] = self.action if self.required: - kwargs["required"] = self.required + kwargs["required"] = True return kwargs @@ -265,6 +273,7 @@ def _convert(value: str) -> Any: raise argparse.ArgumentTypeError(f"invalid choice: {value!r} (choose from {valid})") _convert.__name__ = "literal" + _convert._cmd2_strict_choice_converter = True # type: ignore[attr-defined] return _convert @@ -287,6 +296,7 @@ def _convert(value: str) -> enum.Enum: _convert.__name__ = enum_class.__name__ _convert._cmd2_enum_class = enum_class # type: ignore[attr-defined] + _convert._cmd2_strict_choice_converter = True # type: ignore[attr-defined] return _convert @@ -324,6 +334,8 @@ def _resolve(_tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> dict[str, Any]: def _resolve_path(_tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> dict[str, Any]: """Resolve Path and add completer.""" + from .cmd2 import Cmd + return {"type": Path, "completer": Cmd.path_complete} @@ -339,8 +351,8 @@ def _resolve_bool( if not is_positional: action_str = getattr(metadata, "action", None) if metadata else None if action_str: - return {"action": action_str, "is_bool_flag": True} - return {"action": argparse.BooleanOptionalAction, "is_bool_flag": True} + return {"action": action_str} + return {"action": argparse.BooleanOptionalAction} return {"type": _parse_bool, "choices": list(_BOOL_CHOICES)} @@ -504,6 +516,9 @@ def _resolve_type( if kwargs.get("choices_provider") or kwargs.get("completer"): kwargs.pop("choices", None) + converter = kwargs.get("type") + if getattr(converter, "_cmd2_strict_choice_converter", False): + kwargs.pop("type", None) return base_type, kwargs @@ -572,8 +587,8 @@ def _resolve_annotation( has_default: bool = False, default: Any = None, is_kw_only: bool = False, -) -> tuple[dict[str, Any], ArgMetadata, bool, bool]: - """Decompose a type annotation into ``(type_kwargs, metadata, is_positional, is_bool_flag)``. +) -> tuple[dict[str, Any], ArgMetadata, bool]: + """Decompose a type annotation into ``(type_kwargs, metadata, is_positional)``. Peels ``Annotated`` then ``Optional``. The only supported way to combine ``Annotated`` with ``Optional`` is ``Annotated[T | None, meta]``. @@ -585,7 +600,6 @@ def _resolve_annotation( not isinstance(metadata, Option) and not has_default and not is_optional and not is_kw_only ) - # 4. Resolve type and finalize argparse kwargs tp, type_kwargs = _resolve_type( tp, is_positional=is_positional, @@ -595,12 +609,10 @@ def _resolve_annotation( is_kw_only=is_kw_only, ) - # Strip internal keys not meant for argparse - is_bool_flag = type_kwargs.pop("is_bool_flag", False) type_kwargs.pop("is_collection", None) type_kwargs.pop("base_type", None) - return type_kwargs, metadata, is_positional, is_bool_flag + return type_kwargs, metadata, is_positional # Parameter names that conflict with argparse internals and cannot be used @@ -619,9 +631,7 @@ def _validate_base_command_params( skip_params: frozenset[str] | None = None, ) -> None: """Validate a ``base_command=True`` function has ``cmd2_handler`` and no positional args.""" - sig = inspect.signature(func) - - if "cmd2_handler" not in sig.parameters: + if "cmd2_handler" not in inspect.signature(func).parameters: raise TypeError(f"with_annotated(base_command=True) requires a 'cmd2_handler' parameter in {func.__qualname__}") if skip_params is None: @@ -689,7 +699,7 @@ def _resolve_parameters( default = param.default if has_default else None is_kw_only = param.kind == inspect.Parameter.KEYWORD_ONLY - kwargs, metadata, positional, _is_bool_flag = _resolve_annotation( + kwargs, metadata, positional = _resolve_annotation( annotation, has_default=has_default, default=default, @@ -699,7 +709,9 @@ def _resolve_parameters( if positional: flags: list[str] = [] else: - flags = list(metadata.names) if isinstance(metadata, Option) and metadata.names else [f"--{name}"] + flags = ( + list(metadata.names) if isinstance(metadata, Option) and metadata.names else [f"--{name.replace('_', '-')}"] + ) kwargs["dest"] = name resolved.append((name, metadata, positional, flags, kwargs)) @@ -987,6 +999,11 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: if unknown_param.kind is inspect.Parameter.POSITIONAL_ONLY: raise TypeError("Parameter _unknown must be keyword-compatible when with_unknown_args=True") + if not base_command and "cmd2_handler" in inspect.signature(fn).parameters: + raise TypeError( + f"Parameter 'cmd2_handler' in {fn.__qualname__} is only valid when with_annotated(base_command=True) is used." + ) + if subcommand_to is not None: handler, subcmd_name, subcmd_parser_builder = build_subcommand_handler( fn, diff --git a/tests/test_annotated.py b/tests/test_annotated.py index 88cfd706a..931a507b3 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -254,7 +254,7 @@ class TestBuildParser: pytest.param(_func_kw_only, {"option_strings": ["--name"], "required": True}, id="kw_only_required"), pytest.param(_func_kw_only_with_default, {"option_strings": ["--name"], "default": "world"}, id="kw_only_default"), # --- Underscore in flag names --- - pytest.param(_func_underscore_option, {"option_strings": ["--my_param"], "default": "x"}, id="underscore_flag"), + pytest.param(_func_underscore_option, {"option_strings": ["--my-param"], "default": "x"}, id="underscore_flag"), # --- Default type preservation --- pytest.param( _func_default_type_mismatch, {"option_strings": ["--count"], "default": "1"}, id="default_not_coerced" @@ -365,6 +365,28 @@ def test_choices_provider_overrides_inferred_enum_choices(self) -> None: assert action.get_choices_provider() is not None # type: ignore[attr-defined] assert action.get_completer() is None # type: ignore[attr-defined] + def test_choices_provider_strips_strict_enum_converter(self) -> None: + """User-supplied choices_provider on Enum drops the restrictive enum converter.""" + action = _get_param_action(_func_choices_provider_on_enum) + assert action.type is None + + def test_choices_provider_strips_strict_literal_converter(self) -> None: + """User-supplied choices_provider on Literal drops the restrictive literal converter.""" + + def func( + self, + mode: Annotated[Literal["fast", "slow"], Argument(choices_provider=_provider)], + ) -> None: ... + + action = _get_param_action(func) + assert action.type is None + assert action.choices is None + + def test_completer_keeps_path_converter(self) -> None: + """User-supplied completer on Path preserves the (non-restrictive) Path converter.""" + action = _get_param_action(_func_completer_on_path) + assert action.type is Path + def test_completer_overrides_inferred_path_completion(self) -> None: action = _get_param_action(_func_completer_on_path) assert action.get_choices_provider() is None # type: ignore[attr-defined] @@ -558,7 +580,7 @@ def test_apply_mutex_group_targets_rejects_cross_group_members(self) -> None: # --------------------------------------------------------------------------- -# _resolve_annotation: positional vs option classification + bool flag +# _resolve_annotation: positional vs option classification # --------------------------------------------------------------------------- _ARG_META = Argument(help_text="Name") @@ -567,39 +589,38 @@ def test_apply_mutex_group_targets_rejects_cross_group_members(self) -> None: class TestResolveAnnotation: @pytest.mark.parametrize( - ("annotation", "has_default", "expected_positional", "expected_bool_flag"), + ("annotation", "has_default", "expected_positional"), [ - pytest.param(str, False, True, False, id="plain_str"), - pytest.param(str | None, False, False, False, id="optional_str"), - pytest.param(Annotated[str, _ARG_META], False, True, False, id="annotated_argument"), - pytest.param(Annotated[str, _OPT_META], False, False, False, id="annotated_option"), - pytest.param(Annotated[str, "some doc"], False, True, False, id="annotated_no_meta"), - pytest.param(str, True, False, False, id="has_default"), - pytest.param(bool, True, False, True, id="bool_flag"), + pytest.param(str, False, True, id="plain_str"), + pytest.param(str | None, False, False, id="optional_str"), + pytest.param(Annotated[str, _ARG_META], False, True, id="annotated_argument"), + pytest.param(Annotated[str, _OPT_META], False, False, id="annotated_option"), + pytest.param(Annotated[str, "some doc"], False, True, id="annotated_no_meta"), + pytest.param(str, True, False, id="has_default"), + pytest.param(bool, True, False, id="bool_flag"), ], ) - def test_classification(self, annotation, has_default, expected_positional, expected_bool_flag) -> None: - _kwargs, _meta, positional, is_bool_flag = _resolve_annotation(annotation, has_default=has_default) + def test_classification(self, annotation, has_default, expected_positional) -> None: + _kwargs, _meta, positional = _resolve_annotation(annotation, has_default=has_default) assert positional is expected_positional - assert is_bool_flag is expected_bool_flag def test_optional_wrapping_annotated_with_none_inside(self) -> None: """Optional[Annotated[T | None, meta]] is allowed (inner type contains None).""" ann = Annotated[str | None, _OPT_META] | None - _kwargs, meta, positional, _bf = _resolve_annotation(ann) + _kwargs, meta, positional = _resolve_annotation(ann) assert meta is _OPT_META assert positional is False def test_typing_union_optional(self) -> None: ns: dict = {} exec("import typing; t = typing.Union[str, None]", ns) - _kwargs, _meta, positional, _bool_flag = _resolve_annotation(ns["t"]) + _kwargs, _meta, positional = _resolve_annotation(ns["t"]) assert positional is False def test_annotated_multiple_metadata_picks_first(self) -> None: meta1 = Argument(help_text="first") meta2 = Option("--x", help_text="second") - kwargs, meta, _, _ = _resolve_annotation(Annotated[str, meta1, meta2]) + kwargs, meta, _ = _resolve_annotation(Annotated[str, meta1, meta2]) assert meta is meta1 assert kwargs.get("help") == "first" @@ -639,7 +660,7 @@ def test_nested_collection_raises(self, annotation) -> None: ], ) def test_unsupported_collection_no_nargs(self, annotation) -> None: - kwargs, _, _, _ = _resolve_annotation(annotation) + kwargs, _, _ = _resolve_annotation(annotation) assert "nargs" not in kwargs assert "action" not in kwargs @@ -1085,7 +1106,9 @@ def do_transfer( @pytest.fixture def app() -> _IntegrationApp: - return _IntegrationApp() + app = _IntegrationApp() + app.stdout = cmd2.utils.StdSim(app.stdout) + return app @pytest.fixture @@ -1147,12 +1170,29 @@ def test_ns_provider(self, app) -> None: assert app.ns_calls == 1 def test_cmd2_prefixed_param_is_preserved(self, app) -> None: - out, _err = run_cmd(app, "prefixed --cmd2_mode 5") + out, _err = run_cmd(app, "prefixed --cmd2-mode 5") assert out == ["cmd2_mode=5"] def test_kwargs_passthrough(self, app) -> None: app.do_greet("Alice", keyword_arg="kwarg_value") + def test_direct_call_with_positional_only(self, app) -> None: + """Calling do_* directly with a single statement string parses normally.""" + app.do_greet("Alice") + assert app.stdout.getvalue().splitlines()[-1] == "Hello Alice" + + def test_direct_call_with_options(self, app) -> None: + """Direct call with a full statement string including options.""" + app.do_greet("Alice --count 2 --loud") + out = app.stdout.getvalue().splitlines() + assert out[-2:] == ["HELLO ALICE", "HELLO ALICE"] + + def test_direct_call_kwargs_override_parsed(self, app) -> None: + """Explicit kwargs on a direct call override parsed values.""" + app.do_greet("Alice", count=3) + out = app.stdout.getvalue().splitlines() + assert out[-3:] == ["Hello Alice", "Hello Alice", "Hello Alice"] + def test_bare_call_decorator(self) -> None: """@with_annotated() with empty parens works same as @with_annotated.""" @@ -1176,7 +1216,7 @@ def test_missing_parser_raises(self, app) -> None: class TestGroupedParserIntegration: def test_grouped_command_executes(self, grouped_app) -> None: - out, _err = run_cmd(grouped_app, "transfer --local build.tar.gz --dry_run") + out, _err = run_cmd(grouped_app, "transfer --local build.tar.gz --dry-run") assert out == ["Transfer build.tar.gz in dry-run mode"] def test_grouped_command_mutex_error(self, grouped_app) -> None: @@ -1189,7 +1229,7 @@ def test_grouped_command_help_lists_flags(self, grouped_app) -> None: assert "--local" in help_text assert "--remote" in help_text assert "--force" in help_text - assert "--dry_run" in help_text + assert "--dry-run" in help_text # --------------------------------------------------------------------------- @@ -1293,6 +1333,14 @@ def test_base_command_missing_handler_raises(self) -> None: def do_bad(self, verbose: bool = False) -> None: pass + def test_cmd2_handler_without_base_command_raises(self) -> None: + """A 'cmd2_handler' parameter is only valid when base_command=True.""" + with pytest.raises(TypeError, match="base_command=True"): + + @cmd2.with_annotated + def do_bad(self, cmd2_handler, name: str = "") -> None: + pass + @pytest.mark.parametrize( "kwargs", [ From bacaab3156ea50031f163964ed11b00154720b53 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Fri, 15 May 2026 18:25:54 +0100 Subject: [PATCH 05/25] chore: move documentation --- docs/features/annotated.md | 247 +++++++++++++++++++++++++++ docs/features/argument_processing.md | 216 +---------------------- mkdocs.yml | 1 + 3 files changed, 254 insertions(+), 210 deletions(-) create mode 100644 docs/features/annotated.md diff --git a/docs/features/annotated.md b/docs/features/annotated.md new file mode 100644 index 000000000..2e6b18449 --- /dev/null +++ b/docs/features/annotated.md @@ -0,0 +1,247 @@ +# Annotated Argument Processing + +!!! warning "Experimental" + + The `@with_annotated` decorator and its supporting `Argument` / `Option` metadata classes are + **experimental**. The public API, the surface of accepted type annotations, and the generated + argparse behavior may all change in future releases without a deprecation cycle. Pin a specific + `cmd2` version if you depend on the exact current semantics, and expect to revisit your usage on + upgrades. + + For production code that needs stable behavior, use + [@with_argparser](argument_processing.md#with_argparser-decorator) instead. + +The [@with_annotated][cmd2.decorators.with_annotated] decorator builds an argparse parser +automatically from the decorated function's type annotations. No manual `add_argument()` calls are +required, and the command body receives typed keyword arguments directly instead of an +`argparse.Namespace`. + +The two decorators are interchangeable -- here is the same command written both ways: + +=== "@with_annotated" + + ```py + @with_annotated + def do_greet(self, name: str, count: int = 1, loud: bool = False): + for _ in range(count): + msg = f"Hello {name}" + self.poutput(msg.upper() if loud else msg) + ``` + +=== "@with_argparser" + + ```py + parser = Cmd2ArgumentParser() + parser.add_argument('name', help='person to greet') + parser.add_argument('--count', type=int, default=1, help='repetitions') + parser.add_argument('--loud', action='store_true', help='shout') + + @with_argparser(parser) + def do_greet(self, args): + for _ in range(args.count): + msg = f"Hello {args.name}" + self.poutput(msg.upper() if args.loud else msg) + ``` + +The annotated version is more concise, gives you typed parameters, and supports several advanced +cmd2 features directly, including `ns_provider`, `with_unknown_args`, and typed subcommands. Pick +`@with_argparser` when you need a stable, well-established API or fine-grained control over the +parser; pick `@with_annotated` when you want type-hint-driven ergonomics and can accept the +experimental status. + +## Basic usage + +Parameters without defaults become positional arguments. Parameters with defaults become `--option` +flags. Keyword-only parameters (after `*`) always become options, and without a default they become +required options. + +Underscores in parameter names are converted to dashes in the generated flag, so `dry_run` becomes +`--dry-run`. The Python identifier you read inside the function body keeps its underscored form +(`args.dry_run`). To opt out, pass explicit names via `Option("--my_flag", ...)`. + +```py +from cmd2 import with_annotated + +class MyApp(cmd2.Cmd): + @with_annotated + def do_greet(self, name: str, count: int = 1, loud: bool = False): + """Greet someone.""" + for _ in range(count): + msg = f"Hello {name}" + self.poutput(msg.upper() if loud else msg) +``` + +The command `greet Alice --count 3 --loud` parses `name="Alice"`, `count=3`, `loud=True` and passes +them as keyword arguments. + +## How annotations map to argparse + +The decorator converts Python type annotations into `add_argument()` calls: + +| Type annotation | Generated argparse setting | +| -------------------------------------------------------- | --------------------------------------------------- | +| `str` | default (no `type=` needed) | +| `int`, `float` | `type=int` or `type=float` | +| `bool` with a default | boolean optional flag via `BooleanOptionalAction` | +| positional `bool` | parsed from `true/false`, `yes/no`, `on/off`, `1/0` | +| `Path` | `type=Path` | +| `Enum` subclass | `type=converter`, `choices` from member values | +| `decimal.Decimal` | `type=decimal.Decimal` | +| `Literal[...]` | `type=literal-converter`, `choices` from values | +| `Collection[T]` / `list[T]` / `set[T]` / `tuple[T, ...]` | `nargs='+'` (or `'*'` if it has a default) | +| `tuple[T, T]` | fixed `nargs=N` with `type=T` | +| `T \| None` | unwrapped to `T`, treated as optional | + +When collection types are used with `@with_annotated`, parsed values are passed to the command +function as: + +- `list[T]` and `Collection[T]` as `list` +- `set[T]` as `set` +- `tuple[T, ...]` as `tuple` + +Unsupported patterns raise `TypeError`, including: + +- unions with multiple non-`None` members such as `str | int` +- mixed-type tuples such as `tuple[int, str]` +- `Annotated[T, meta] | None`; write `Annotated[T | None, meta]` instead + +The parameter names `dest` and `subcommand` are reserved and may not be used as annotated parameter +names. + +## Annotated metadata + +For finer control, use `typing.Annotated` with [Argument][cmd2.annotated.Argument] or +[Option][cmd2.annotated.Option] metadata: + +```py +from typing import Annotated +from cmd2 import Argument, Option, with_annotated + +class MyApp(cmd2.Cmd): + def sport_choices(self) -> cmd2.Choices: + return cmd2.Choices.from_values(["football", "basketball"]) + + @with_annotated + def do_play( + self, + sport: Annotated[str, Argument( + choices_provider=sport_choices, + help_text="Sport to play", + )], + venue: Annotated[str, Option( + "--venue", "-v", + help_text="Where to play", + completer=cmd2.Cmd.path_complete, + )] = "home", + ): + self.poutput(f"Playing {sport} at {venue}") +``` + +Both `Argument` and `Option` accept the same cmd2-specific fields as `add_argument()`: `choices`, +`choices_provider`, `completer`, `table_columns`, `suppress_tab_hint`, `metavar`, `nargs`, and +`help_text`. + +`Option` additionally accepts `action`, `required`, and positional `*names` for custom flag strings +(e.g. `Option("--color", "-c")`). + +When an `Option(action=...)` uses an argparse action that does not accept `type=` (`count`, +`store_true`, `store_false`, `store_const`, `help`, `version`), `@with_annotated` removes any +inferred `type` converter before calling `add_argument()`. This matches argparse behavior and avoids +parser-construction errors such as combining `action='count'` with `type=int`. + +When a user-supplied `choices_provider` or `completer` overrides an inferred `Enum` or `Literal`, +the restrictive type converter is also dropped so the user-supplied values are not rejected at parse +time. The `Path` converter is permissive and is preserved when a custom completer is provided. + +## Decorator options + +`@with_annotated` currently supports: + +- `ns_provider` -- prepopulate the namespace before parsing, mirroring `@with_argparser` +- `preserve_quotes` -- if `True`, quotes in arguments are preserved +- `with_unknown_args` -- if `True`, unrecognised arguments are passed as `_unknown` +- `subcommand_to` -- register the function as an annotated subcommand under a parent command +- `base_command` -- create a base command whose parser also adds subparsers and exposes + `cmd2_handler`. A `cmd2_handler` parameter is only valid on a command decorated with + `base_command=True`; declaring one elsewhere raises `TypeError`. +- `help` -- help text for an annotated subcommand +- `aliases` -- aliases for an annotated subcommand + +```py +@with_annotated(with_unknown_args=True) +def do_rawish(self, name: str, _unknown: list[str] | None = None): + self.poutput((name, _unknown)) +``` + +## Annotated subcommands + +`@with_annotated` can also build typed subcommand trees without manually constructing subparsers. + +```py +@with_annotated(base_command=True) +def do_manage(self, *, cmd2_handler): + handler = cmd2_handler + if handler: + handler() + +@with_annotated(subcommand_to="manage", help="list projects") +def manage_list(self): + self.poutput("listing") +``` + +For nested subcommands, `subcommand_to` can be space-delimited, for example +`subcommand_to="manage project"`. The intermediate level must also be declared as a subcommand that +creates its own subparsers: + +```py +@with_annotated(subcommand_to="manage", base_command=True, help="manage projects") +def manage_project(self, *, cmd2_handler): + handler = cmd2_handler + if handler: + handler() + +@with_annotated(subcommand_to="manage project", help="add a project") +def manage_project_add(self, name: str): + self.poutput(f"added {name}") +``` + +## Lower-level parser building + +If you need parser grouping or mutually-exclusive groups while still using annotation-driven parser +generation, [cmd2.annotated.build_parser_from_function][cmd2.annotated.build_parser_from_function] +also supports: + +- `groups=((...), (...))` +- `mutually_exclusive_groups=((...), (...))` + +```py +@with_annotated(preserve_quotes=True) +def do_raw(self, text: str): + self.poutput(f"raw: {text}") +``` + +## Automatic completion from types + +With `@with_annotated`, arguments annotated as `Path` or `Enum` get automatic completion without +needing an explicit `choices_provider` or `completer`. + +Specifically: + +- `Path` (or any `Path` subclass) triggers filesystem path completion +- `MyEnum` (any `enum.Enum` subclass) triggers completion from enum member values + +With `@with_argparser`, provide `choices`, `choices_provider`, or `completer` explicitly when you +want completion behavior. + +## Stability and feedback + +Because this feature is experimental: + +- Behavior of edge cases (mixed-type tuples, deeply-nested `Annotated`, conflicting metadata) may + change. +- Diagnostic error messages may be reworded. +- The set of supported type annotations may be expanded or trimmed. + +If you depend on `@with_annotated`, please share feedback and edge cases via the +[issue tracker](https://github.com/python-cmd2/cmd2/issues) so behavior can be locked in before the +feature graduates out of experimental. diff --git a/docs/features/argument_processing.md b/docs/features/argument_processing.md index 3c19606b4..ffe45612e 100644 --- a/docs/features/argument_processing.md +++ b/docs/features/argument_processing.md @@ -57,218 +57,14 @@ stores internally. A consequence is that parsers don't need to be unique across ## with_annotated decorator -The [@with_annotated][cmd2.decorators.with_annotated] decorator builds an argparse parser -automatically from the decorated function's type annotations. No manual `add_argument()` calls are -required. - -### Basic usage - -Parameters without defaults become positional arguments. Parameters with defaults become `--option` -flags. Keyword-only parameters (after `*`) always become options, and without a default they become -required options. The function receives typed keyword arguments directly instead of an -`argparse.Namespace`. - -```py -from cmd2 import with_annotated - -class MyApp(cmd2.Cmd): - @with_annotated - def do_greet(self, name: str, count: int = 1, loud: bool = False): - """Greet someone.""" - for _ in range(count): - msg = f"Hello {name}" - self.poutput(msg.upper() if loud else msg) -``` - -The command `greet Alice --count 3 --loud` parses `name="Alice"`, `count=3`, `loud=True` and passes -them as keyword arguments. - -### How annotations map to argparse - -The decorator converts Python type annotations into `add_argument()` calls: - -| Type annotation | Generated argparse setting | -| -------------------------------------------------------- | --------------------------------------------------- | -| `str` | default (no `type=` needed) | -| `int`, `float` | `type=int` or `type=float` | -| `bool` with a default | boolean optional flag via `BooleanOptionalAction` | -| positional `bool` | parsed from `true/false`, `yes/no`, `on/off`, `1/0` | -| `Path` | `type=Path` | -| `Enum` subclass | `type=converter`, `choices` from member values | -| `decimal.Decimal` | `type=decimal.Decimal` | -| `Literal[...]` | `type=literal-converter`, `choices` from values | -| `Collection[T]` / `list[T]` / `set[T]` / `tuple[T, ...]` | `nargs='+'` (or `'*'` if it has a default) | -| `tuple[T, T]` | fixed `nargs=N` with `type=T` | -| `T \| None` | unwrapped to `T`, treated as optional | - -When collection types are used with `@with_annotated`, parsed values are passed to the command -function as: - -- `list[T]` and `Collection[T]` as `list` -- `set[T]` as `set` -- `tuple[T, ...]` as `tuple` - -Unsupported patterns raise `TypeError`, including: - -- unions with multiple non-`None` members such as `str | int` -- mixed-type tuples such as `tuple[int, str]` -- `Annotated[T, meta] | None`; write `Annotated[T | None, meta]` instead - -The parameter names `dest` and `subcommand` are reserved and may not be used as annotated parameter -names. +!!! warning "Experimental" -### Annotated metadata + The `@with_annotated` decorator is **experimental** and its API may change in future releases. -For finer control, use `typing.Annotated` with [Argument][cmd2.annotated.Argument] or -[Option][cmd2.annotated.Option] metadata: - -```py -from typing import Annotated -from cmd2 import Argument, Option, with_annotated - -class MyApp(cmd2.Cmd): - def sport_choices(self) -> cmd2.Choices: - return cmd2.Choices.from_values(["football", "basketball"]) - - @with_annotated - def do_play( - self, - sport: Annotated[str, Argument( - choices_provider=sport_choices, - help_text="Sport to play", - )], - venue: Annotated[str, Option( - "--venue", "-v", - help_text="Where to play", - completer=cmd2.Cmd.path_complete, - )] = "home", - ): - self.poutput(f"Playing {sport} at {venue}") -``` - -Both `Argument` and `Option` accept the same cmd2-specific fields as `add_argument()`: `choices`, -`choices_provider`, `completer`, `table_columns`, `suppress_tab_hint`, `metavar`, `nargs`, and -`help_text`. - -`Option` additionally accepts `action`, `required`, and positional `*names` for custom flag strings -(e.g. `Option("--color", "-c")`). - -When an `Option(action=...)` uses an argparse action that does not accept `type=` (`count`, -`store_true`, `store_false`, `store_const`, `help`, `version`), `@with_annotated` removes any -inferred `type` converter before calling `add_argument()`. This matches argparse behavior and avoids -parser-construction errors such as combining `action='count'` with `type=int`. - -### Comparison with @with_argparser - -The two decorators are interchangeable. Here is the same command written both ways: - -**@with_argparser** - -```py -parser = Cmd2ArgumentParser() -parser.add_argument('name', help='person to greet') -parser.add_argument('--count', type=int, default=1, help='repetitions') -parser.add_argument('--loud', action='store_true', help='shout') - -@with_argparser(parser) -def do_greet(self, args): - for _ in range(args.count): - msg = f"Hello {args.name}" - self.poutput(msg.upper() if args.loud else msg) -``` - -**@with_annotated** - -```py -@with_annotated -def do_greet(self, name: str, count: int = 1, loud: bool = False): - for _ in range(count): - msg = f"Hello {name}" - self.poutput(msg.upper() if loud else msg) -``` - -The annotated version is more concise and gives you typed parameters. It also supports several -advanced cmd2 features directly, including `ns_provider`, `with_unknown_args`, and typed -subcommands. - -### Decorator options - -`@with_annotated` currently supports: - -- `ns_provider` -- prepopulate the namespace before parsing, mirroring `@with_argparser` -- `preserve_quotes` -- if `True`, quotes in arguments are preserved -- `with_unknown_args` -- if `True`, unrecognised arguments are passed as `_unknown` -- `subcommand_to` -- register the function as an annotated subcommand under a parent command -- `base_command` -- create a base command whose parser also adds subparsers and exposes - `cmd2_handler` -- `help` -- help text for an annotated subcommand -- `aliases` -- aliases for an annotated subcommand - -```py -@with_annotated(with_unknown_args=True) -def do_rawish(self, name: str, _unknown: list[str] | None = None): - self.poutput((name, _unknown)) -``` - -### Annotated subcommands - -`@with_annotated` can also build typed subcommand trees without manually constructing subparsers. - -```py -@with_annotated(base_command=True) -def do_manage(self, *, cmd2_handler): - handler = cmd2_handler - if handler: - handler() - -@with_annotated(subcommand_to="manage", help="list projects") -def manage_list(self): - self.poutput("listing") -``` - -For nested subcommands, `subcommand_to` can be space-delimited, for example -`subcommand_to="manage project"`. The intermediate level must also be declared as a subcommand that -creates its own subparsers: - -```py -@with_annotated(subcommand_to="manage", base_command=True, help="manage projects") -def manage_project(self, *, cmd2_handler): - handler = cmd2_handler - if handler: - handler() - -@with_annotated(subcommand_to="manage project", help="add a project") -def manage_project_add(self, name: str): - self.poutput(f"added {name}") -``` - -### Lower-level parser building - -If you need parser grouping or mutually-exclusive groups while still using annotation-driven parser -generation, [cmd2.annotated.build_parser_from_function][cmd2.annotated.build_parser_from_function] -also supports: - -- `groups=((...), (...))` -- `mutually_exclusive_groups=((...), (...))` - -```py -@with_annotated(preserve_quotes=True) -def do_raw(self, text: str): - self.poutput(f"raw: {text}") -``` - -## Automatic Completion from Types - -With `@with_annotated`, arguments annotated as `Path` or `Enum` get automatic completion without -needing an explicit `choices_provider` or `completer`. - -Specifically: - -- `Path` (or any `Path` subclass) triggers filesystem path completion -- `MyEnum` (any `enum.Enum` subclass) triggers completion from enum member values - -With `@with_argparser`, provide `choices`, `choices_provider`, or `completer` explicitly when you -want completion behavior. +The [@with_annotated][cmd2.decorators.with_annotated] decorator builds an argparse parser +automatically from the decorated function's type annotations -- no manual `add_argument()` calls +required. See [Annotated Argument Processing](annotated.md) for the full reference, including type +mapping, metadata classes, subcommands, and stability caveats. ## Argument Parsing diff --git a/mkdocs.yml b/mkdocs.yml index 48e364807..4299c0bc4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -156,6 +156,7 @@ nav: - Features: - features/index.md - features/argument_processing.md + - features/annotated.md - features/async_commands.md - features/builtin_commands.md - features/clipboard.md From 70dfcf17b3e8bcb768e2384cf2311ce13637952c Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 19 May 2026 15:06:30 +0100 Subject: [PATCH 06/25] fix: address PR review comments on annotated argparse - aliases param: Sequence[str] = () to match as_subcommand_to() - update aliases None checks now that it defaults to () - type with_annotated via @overload (no longer untyped decorator) - drop experimental annotated exports from cmd2/__init__.py - import from cmd2.annotated in example/tests/docs; fix example mypy --- cmd2/__init__.py | 9 ---- cmd2/annotated.py | 29 +++++++++-- docs/features/annotated.md | 6 +-- docs/features/argument_processing.md | 6 +-- examples/annotated_example.py | 47 ++++++++++-------- tests/test_annotated.py | 73 ++++++++++++++-------------- 6 files changed, 95 insertions(+), 75 deletions(-) diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 98ba7e752..2d13650ae 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -11,11 +11,6 @@ rich_utils, string_utils, ) -from .annotated import ( - Argument, - Option, - with_annotated, -) from .argparse_completer import set_default_ap_completer_type from .argparse_utils import ( Cmd2ArgumentParser, @@ -92,11 +87,7 @@ "Choices", "CompletionItem", "Completions", - # Annotated - "Argument", - "Option", # Decorators - "with_annotated", "with_argument_list", "with_argparser", "with_category", diff --git a/cmd2/annotated.py b/cmd2/annotated.py index fe139e6f8..c845283ca 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -104,6 +104,7 @@ def do_paint( get_args, get_origin, get_type_hints, + overload, ) from . import constants @@ -930,6 +931,26 @@ def parser_builder() -> Cmd2ArgumentParser: return handler, subcmd_name, parser_builder +@overload +def with_annotated(func: Callable[..., Any]) -> Callable[..., Any]: ... + + +@overload +def with_annotated( + func: None = ..., + *, + ns_provider: Callable[..., argparse.Namespace] | None = ..., + preserve_quotes: bool = ..., + with_unknown_args: bool = ..., + base_command: bool = ..., + subcommand_to: str | None = ..., + help: str | None = ..., + aliases: Sequence[str] = ..., + groups: tuple[tuple[str, ...], ...] | None = ..., + mutually_exclusive_groups: tuple[tuple[str, ...], ...] | None = ..., +) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ... + + def with_annotated( func: Callable[..., Any] | None = None, *, @@ -939,10 +960,10 @@ def with_annotated( base_command: bool = False, subcommand_to: str | None = None, help: str | None = None, # noqa: A002 - aliases: Sequence[str] | None = None, + aliases: Sequence[str] = (), groups: tuple[tuple[str, ...], ...] | None = None, mutually_exclusive_groups: tuple[tuple[str, ...], ...] | None = None, -) -> Any: +) -> Callable[..., Any] | Callable[[Callable[..., Any]], Callable[..., Any]]: """Decorate a ``do_*`` method to build its argparse parser from type annotations. :param func: the command function (when used without parentheses) @@ -974,7 +995,7 @@ def do_team(self, *, cmd2_handler): ... def team_create(self, name: str): ... """ - if (help is not None or aliases is not None) and subcommand_to is None: + if (help is not None or aliases) and subcommand_to is None: raise TypeError("'help' and 'aliases' are only valid with subcommand_to") if subcommand_to is not None: unsupported: list[str] = [] @@ -1016,7 +1037,7 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: name=subcmd_name, command=subcommand_to, help=help, - aliases=tuple(aliases) if aliases else (), + aliases=tuple(aliases), parser_source=subcmd_parser_builder, ) setattr(handler, constants.SUBCMD_ATTR_SPEC, spec) diff --git a/docs/features/annotated.md b/docs/features/annotated.md index 2e6b18449..0e3d99bd9 100644 --- a/docs/features/annotated.md +++ b/docs/features/annotated.md @@ -11,7 +11,7 @@ For production code that needs stable behavior, use [@with_argparser](argument_processing.md#with_argparser-decorator) instead. -The [@with_annotated][cmd2.decorators.with_annotated] decorator builds an argparse parser +The [@with_annotated][cmd2.annotated.with_annotated] decorator builds an argparse parser automatically from the decorated function's type annotations. No manual `add_argument()` calls are required, and the command body receives typed keyword arguments directly instead of an `argparse.Namespace`. @@ -60,7 +60,7 @@ Underscores in parameter names are converted to dashes in the generated flag, so (`args.dry_run`). To opt out, pass explicit names via `Option("--my_flag", ...)`. ```py -from cmd2 import with_annotated +from cmd2.annotated import with_annotated class MyApp(cmd2.Cmd): @with_annotated @@ -115,7 +115,7 @@ For finer control, use `typing.Annotated` with [Argument][cmd2.annotated.Argumen ```py from typing import Annotated -from cmd2 import Argument, Option, with_annotated +from cmd2.annotated import Argument, Option, with_annotated class MyApp(cmd2.Cmd): def sport_choices(self) -> cmd2.Choices: diff --git a/docs/features/argument_processing.md b/docs/features/argument_processing.md index ffe45612e..e52d60c01 100644 --- a/docs/features/argument_processing.md +++ b/docs/features/argument_processing.md @@ -19,7 +19,7 @@ following for you: These features are provided by two decorators: - [@with_argparser][cmd2.with_argparser] -- build parsers manually with `add_argument()` calls -- [@with_annotated][cmd2.decorators.with_annotated] -- build parsers automatically from type hints +- [@with_annotated][cmd2.annotated.with_annotated] -- build parsers automatically from type hints See the [argparse_completion](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_completion.py) @@ -30,7 +30,7 @@ examples to compare the two styles side by side. arguments passed to commands: - [cmd2.decorators.with_argparser][] -- [cmd2.decorators.with_annotated][] +- [cmd2.annotated.with_annotated][] - [cmd2.decorators.with_argument_list][] All of these decorators accept an optional **preserve_quotes** argument which defaults to `False`. @@ -61,7 +61,7 @@ stores internally. A consequence is that parsers don't need to be unique across The `@with_annotated` decorator is **experimental** and its API may change in future releases. -The [@with_annotated][cmd2.decorators.with_annotated] decorator builds an argparse parser +The [@with_annotated][cmd2.annotated.with_annotated] decorator builds an argparse parser automatically from the decorated function's type annotations -- no manual `add_argument()` calls required. See [Annotated Argument Processing](annotated.md) for the full reference, including type mapping, metadata classes, subcommands, and stability caveats. diff --git a/examples/annotated_example.py b/examples/annotated_example.py index 22675975f..4695c4fdf 100755 --- a/examples/annotated_example.py +++ b/examples/annotated_example.py @@ -18,11 +18,13 @@ import sys from argparse import Namespace +from collections.abc import Callable from decimal import Decimal from enum import StrEnum from pathlib import Path from typing import ( Annotated, + Any, Literal, ) @@ -31,6 +33,11 @@ Choices, Cmd, ) +from cmd2.annotated import ( + Argument, + Option, + with_annotated, +) class Color(StrEnum): @@ -65,7 +72,7 @@ def __init__(self) -> None: # With @with_argparser you'd manually set type=int and action='store_true'. # Here the decorator infers everything from the annotations. - @cmd2.with_annotated + @with_annotated @cmd2.with_category(ANNOTATED_CATEGORY) def do_add(self, a: int, b: int = 0, verbose: bool = False) -> None: """Add two integers. Types are inferred from annotations. @@ -84,12 +91,12 @@ def do_add(self, a: int, b: int = 0, verbose: bool = False) -> None: # With @with_argparser you'd list every member in choices=[...]. # Here the Enum type provides choices and validation automatically. - @cmd2.with_annotated + @with_annotated @cmd2.with_category(ANNOTATED_CATEGORY) def do_paint( self, item: str, - color: Annotated[Color, cmd2.Option("--color", "-c", help_text="Color to use")] = Color.blue, + color: Annotated[Color, Option("--color", "-c", help_text="Color to use")] = Color.blue, level: LogLevel = LogLevel.info, ) -> None: """Paint an item. Enum types auto-complete their member values. @@ -104,7 +111,7 @@ def do_paint( # With @with_argparser you'd wire completer=Cmd.path_complete on each arg. # Here the Path type triggers filesystem completion automatically. - @cmd2.with_annotated + @with_annotated @cmd2.with_category(ANNOTATED_CATEGORY) def do_copy(self, src: Path, dst: Path) -> None: """Copy a file. Path parameters auto-complete filesystem paths. @@ -118,7 +125,7 @@ def do_copy(self, src: Path, dst: Path) -> None: # With @with_argparser you'd spell out the action. # Here bool defaults drive the generated boolean option. - @cmd2.with_annotated + @with_annotated @cmd2.with_category(ANNOTATED_CATEGORY) def do_build( self, @@ -145,7 +152,7 @@ def do_build( # With @with_argparser you'd set type=float and nargs='+'. # Here list[float] does both at once. - @cmd2.with_annotated + @with_annotated @cmd2.with_category(ANNOTATED_CATEGORY) def do_sum(self, numbers: list[float]) -> None: """Sum numbers. ``list[T]`` becomes ``nargs='+'`` automatically. @@ -158,7 +165,7 @@ def do_sum(self, numbers: list[float]) -> None: # -- Literal + Decimal --------------------------------------------------- # Literal values become validated choices. Decimal values preserve precision. - @cmd2.with_annotated + @with_annotated @cmd2.with_category(ANNOTATED_CATEGORY) def do_deploy( self, @@ -178,7 +185,7 @@ def do_deploy( # With @with_argparser you'd access args.name, args.count on a Namespace. # Here each parameter is a typed local variable. - @cmd2.with_annotated + @with_annotated @cmd2.with_category(ANNOTATED_CATEGORY) def do_greet(self, name: str, count: int = 1, loud: bool = False) -> None: """Greet someone. Parameters are typed -- no Namespace unpacking. @@ -206,20 +213,20 @@ def context_choices(self, arg_tokens: dict[str, list[str]]) -> Choices: return Choices.from_values(["touchdown", "field-goal", "punt"]) return Choices.from_values(["play"]) - @cmd2.with_annotated + @with_annotated @cmd2.with_category(ANNOTATED_CATEGORY) def do_score( self, sport: Annotated[ str, - cmd2.Argument( + Argument( choices_provider=sport_choices, help_text="Sport to score", ), ], play: Annotated[ str, - cmd2.Argument( + Argument( choices_provider=context_choices, help_text="Type of play (depends on sport)", ), @@ -241,7 +248,7 @@ def do_score( def default_namespace(self) -> Namespace: return Namespace(region=self._default_region) - @cmd2.with_annotated(ns_provider=default_namespace) + @with_annotated(ns_provider=default_namespace) @cmd2.with_category(ANNOTATED_CATEGORY) def do_ship(self, package: str, region: str = "local") -> None: """Use ns_provider to prepopulate parser defaults at runtime. @@ -254,7 +261,7 @@ def do_ship(self, package: str, region: str = "local") -> None: # -- Unknown args -------------------------------------------------------- - @cmd2.with_annotated(with_unknown_args=True) + @with_annotated(with_unknown_args=True) @cmd2.with_category(ANNOTATED_CATEGORY) def do_flex(self, name: str, _unknown: list[str] | None = None) -> None: """Capture unknown arguments instead of failing parse. @@ -269,9 +276,9 @@ def do_flex(self, name: str, _unknown: list[str] | None = None) -> None: # -- Subcommands --------------------------------------------------------- # @with_annotated also supports typed subcommand trees. - @cmd2.with_annotated(base_command=True) + @with_annotated(base_command=True) @cmd2.with_category(ANNOTATED_CATEGORY) - def do_manage(self, verbose: bool = False, *, cmd2_handler) -> None: + def do_manage(self, verbose: bool = False, *, cmd2_handler: Callable[[], Any] | None = None) -> None: """Base command for annotated subcommands. Try: @@ -283,22 +290,22 @@ def do_manage(self, verbose: bool = False, *, cmd2_handler) -> None: if cmd2_handler: cmd2_handler() - @cmd2.with_annotated(subcommand_to="manage", base_command=True, help="manage projects") - def manage_project(self, *, cmd2_handler) -> None: + @with_annotated(subcommand_to="manage", base_command=True, help="manage projects") + def manage_project(self, *, cmd2_handler: Callable[[], Any] | None = None) -> None: if cmd2_handler: cmd2_handler() - @cmd2.with_annotated(subcommand_to="manage project", help="add a project") + @with_annotated(subcommand_to="manage project", help="add a project") def manage_project_add(self, name: str) -> None: self.poutput(f"project added: {name}") - @cmd2.with_annotated(subcommand_to="manage project", help="list projects") + @with_annotated(subcommand_to="manage project", help="list projects") def manage_project_list(self) -> None: self.poutput("project list: demo") # -- Preserve quotes ----------------------------------------------------- - @cmd2.with_annotated(preserve_quotes=True) + @with_annotated(preserve_quotes=True) @cmd2.with_category(ANNOTATED_CATEGORY) def do_echo(self, text: str) -> None: """Echo text with quotes preserved. diff --git a/tests/test_annotated.py b/tests/test_annotated.py index 931a507b3..05cae2f4a 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -32,6 +32,7 @@ _resolve_annotation, _validate_group_members, build_parser_from_function, + with_annotated, ) from .conftest import run_cmd @@ -474,7 +475,7 @@ def test_mutually_exclusive_via_decorator(self) -> None: """@with_annotated(mutually_exclusive_groups=...) works end-to-end.""" class App(cmd2.Cmd): - @cmd2.with_annotated(mutually_exclusive_groups=(("verbose", "quiet"),)) + @with_annotated(mutually_exclusive_groups=(("verbose", "quiet"),)) def do_run(self, verbose: bool = False, quiet: bool = False) -> None: if verbose: self.poutput("verbose") @@ -910,16 +911,16 @@ def __init__(self) -> None: def item_choices(self) -> list[cmd2.CompletionItem]: return [cmd2.CompletionItem(item) for item in self._items] - @cmd2.with_annotated + @with_annotated def do_greet(self, name: str, count: int = 1) -> None: for _ in range(count): self.poutput(f"Hello {name}") - @cmd2.with_annotated + @with_annotated def do_add(self, a: int, b: int = 0) -> None: self.poutput(str(a + b)) - @cmd2.with_annotated + @with_annotated def do_paint( self, item: str, @@ -931,23 +932,23 @@ def do_paint( msg += " (verbose)" self.poutput(msg) - @cmd2.with_annotated + @with_annotated def do_pick(self, item: Annotated[str, Argument(choices_provider=item_choices)]) -> None: self.poutput(f"Picked: {item}") - @cmd2.with_annotated + @with_annotated def do_open(self, path: Path) -> None: self.poutput(f"Opening: {path}") - @cmd2.with_annotated + @with_annotated def do_sport(self, sport: _Sport) -> None: self.poutput(f"Playing: {sport.value}") - @cmd2.with_annotated + @with_annotated def do_toggle(self, enabled: bool) -> None: self.poutput(f"Enabled: {enabled}") - @cmd2.with_annotated(preserve_quotes=True) + @with_annotated(preserve_quotes=True) def do_raw(self, text: str) -> None: self.poutput(f"raw: {text}") @@ -1020,7 +1021,7 @@ def __init__(self) -> None: def sport_choices(self) -> list[cmd2.CompletionItem]: return [cmd2.CompletionItem(sport) for sport in self._sports] - @cmd2.with_annotated + @with_annotated def do_play(self, sport: Annotated[str, Argument(choices_provider=sport_choices)]) -> None: self._cmd.poutput(f"Playing {sport}") @@ -1058,7 +1059,7 @@ def namespace_provider(self) -> argparse.Namespace: ns.custom_stuff = "custom" return ns - @cmd2.with_annotated + @with_annotated def do_greet(self, name: str, count: int = 1, loud: bool = False, *, keyword_arg: str | None = None) -> None: """Greet someone.""" for _ in range(count): @@ -1067,27 +1068,27 @@ def do_greet(self, name: str, count: int = 1, loud: bool = False, *, keyword_arg if keyword_arg is not None: self.poutput(keyword_arg) - @cmd2.with_annotated(with_unknown_args=True) + @with_annotated(with_unknown_args=True) def do_flex(self, name: str, _unknown: list[str] | None = None) -> None: self.poutput(f"name={name}") if _unknown: self.poutput(f"unknown={_unknown}") - @cmd2.with_annotated(preserve_quotes=True) + @with_annotated(preserve_quotes=True) def do_raw(self, text: str) -> None: self.poutput(f"raw: {text}") - @cmd2.with_annotated(ns_provider=namespace_provider) + @with_annotated(ns_provider=namespace_provider) def do_ns_test(self, cmd2_statement=None) -> None: self.poutput("ok") - @cmd2.with_annotated + @with_annotated def do_prefixed(self, cmd2_mode: int = 1) -> None: self.poutput(f"cmd2_mode={cmd2_mode}") class _GroupedParserApp(cmd2.Cmd): - @cmd2.with_annotated( + @with_annotated( groups=(("local", "remote"), ("force", "dry_run")), mutually_exclusive_groups=(("local", "remote"), ("force", "dry_run")), ) @@ -1153,14 +1154,14 @@ def test_no_args_raises_type_error(self, app) -> None: def test_with_unknown_args_requires_param(self) -> None: with pytest.raises(TypeError, match="_unknown"): - @cmd2.with_annotated(with_unknown_args=True) + @with_annotated(with_unknown_args=True) def do_broken(self, name: str) -> None: pass def test_positional_only_unknown_rejected(self) -> None: with pytest.raises(TypeError, match="keyword-compatible"): - @cmd2.with_annotated(with_unknown_args=True) + @with_annotated(with_unknown_args=True) def do_broken(self, _unknown: list[str], /) -> None: pass @@ -1197,7 +1198,7 @@ def test_bare_call_decorator(self) -> None: """@with_annotated() with empty parens works same as @with_annotated.""" class App(cmd2.Cmd): - @cmd2.with_annotated() + @with_annotated() def do_echo(self, text: str) -> None: self.poutput(text) @@ -1239,7 +1240,7 @@ def test_grouped_command_help_lists_flags(self, grouped_app) -> None: class _SubcommandApp(cmd2.Cmd): # Level 1: base command - @cmd2.with_annotated(base_command=True) + @with_annotated(base_command=True) def do_manage(self, cmd2_handler, verbose: bool = False) -> None: """Management command with subcommands.""" if verbose: @@ -1249,23 +1250,23 @@ def do_manage(self, cmd2_handler, verbose: bool = False) -> None: handler() # Level 2: leaf subcommands - @cmd2.with_annotated(subcommand_to="manage", help="add something") + @with_annotated(subcommand_to="manage", help="add something") def manage_add(self, value: str) -> None: self.poutput(f"added: {value}") - @cmd2.with_annotated(subcommand_to="manage", help="list things", aliases=["ls"]) + @with_annotated(subcommand_to="manage", help="list things", aliases=["ls"]) def manage_list(self) -> None: self.poutput("listing all") # Level 2: intermediate subcommand (also a base for level 3) - @cmd2.with_annotated(subcommand_to="manage", base_command=True, help="manage members") + @with_annotated(subcommand_to="manage", base_command=True, help="manage members") def manage_member(self, cmd2_handler) -> None: handler = cmd2_handler if handler: handler() # Level 3: nested subcommand - @cmd2.with_annotated(subcommand_to="manage member", help="add a member") + @with_annotated(subcommand_to="manage member", help="add a member") def manage_member_add(self, name: str) -> None: self.poutput(f"member added: {name}") @@ -1314,7 +1315,7 @@ def test_base_command_positional_str_raises(self) -> None: """Positional str param conflicts with subcommand name.""" with pytest.raises(TypeError, match="positional"): - @cmd2.with_annotated(base_command=True) + @with_annotated(base_command=True) def do_bad(self, name: str, cmd2_handler) -> None: pass @@ -1322,14 +1323,14 @@ def test_base_command_positional_annotated_raises(self) -> None: """Explicit Argument() metadata forces positional -- conflict.""" with pytest.raises(TypeError, match="positional"): - @cmd2.with_annotated(base_command=True) + @with_annotated(base_command=True) def do_bad(self, a: Annotated[str, Argument(help_text="x")], cmd2_handler) -> None: pass def test_base_command_missing_handler_raises(self) -> None: with pytest.raises(TypeError, match="cmd2_handler"): - @cmd2.with_annotated(base_command=True) + @with_annotated(base_command=True) def do_bad(self, verbose: bool = False) -> None: pass @@ -1337,7 +1338,7 @@ def test_cmd2_handler_without_base_command_raises(self) -> None: """A 'cmd2_handler' parameter is only valid when base_command=True.""" with pytest.raises(TypeError, match="base_command=True"): - @cmd2.with_annotated + @with_annotated def do_bad(self, cmd2_handler, name: str = "") -> None: pass @@ -1351,7 +1352,7 @@ def do_bad(self, cmd2_handler, name: str = "") -> None: def test_subcmd_only_params_without_subcommand_to_raises(self, kwargs) -> None: with pytest.raises(TypeError, match="subcommand_to"): - @cmd2.with_annotated(**kwargs) + @with_annotated(**kwargs) def do_bad(self, name: str) -> None: pass @@ -1366,7 +1367,7 @@ def do_bad(self, name: str) -> None: def test_subcommand_rejects_unsupported_runtime_options(self, kwargs, pattern) -> None: with pytest.raises(TypeError, match=pattern): - @cmd2.with_annotated(subcommand_to="team", **kwargs) + @with_annotated(subcommand_to="team", **kwargs) def team_add(self, name: str, _unknown: list[str] | None = None) -> None: pass @@ -1374,13 +1375,13 @@ def test_subcommand_with_mutually_exclusive_groups(self) -> None: """mutually_exclusive_groups should work on subcommands.""" class App(cmd2.Cmd): - @cmd2.with_annotated(base_command=True) + @with_annotated(base_command=True) def do_fmt(self, cmd2_handler) -> None: handler = cmd2_handler if handler: handler() - @cmd2.with_annotated(subcommand_to="fmt", help="output", mutually_exclusive_groups=(("json", "csv"),)) + @with_annotated(subcommand_to="fmt", help="output", mutually_exclusive_groups=(("json", "csv"),)) def fmt_out(self, msg: str, json: bool = False, csv: bool = False) -> None: self.poutput(f"json={json} csv={csv} {msg}") @@ -1393,14 +1394,14 @@ def fmt_out(self, msg: str, json: bool = False, csv: bool = False) -> None: def test_intermediate_base_command_positional_raises(self) -> None: with pytest.raises(TypeError, match="positional"): - @cmd2.with_annotated(subcommand_to="team", base_command=True) + @with_annotated(subcommand_to="team", base_command=True) def team_member(self, name: str, cmd2_handler) -> None: pass def test_intermediate_base_command_missing_handler_raises(self) -> None: with pytest.raises(TypeError, match="cmd2_handler"): - @cmd2.with_annotated(subcommand_to="team", base_command=True) + @with_annotated(subcommand_to="team", base_command=True) def team_member(self) -> None: pass @@ -1415,7 +1416,7 @@ def test_subcommand_naming_enforced(self, subcommand_to, func_name) -> None: ns: dict = {} exec(f"def {func_name}(self, x: str) -> None: ...", ns) with pytest.raises(TypeError, match="must be named"): - cmd2.with_annotated(subcommand_to=subcommand_to)(ns[func_name]) + with_annotated(subcommand_to=subcommand_to)(ns[func_name]) @pytest.mark.parametrize( ("decorator_kwargs", "expected_help", "expected_aliases"), @@ -1427,7 +1428,7 @@ def test_subcommand_naming_enforced(self, subcommand_to, func_name) -> None: def test_subcommand_spec_attributes(self, decorator_kwargs, expected_help, expected_aliases) -> None: from cmd2 import constants - @cmd2.with_annotated(subcommand_to="team", **decorator_kwargs) + @with_annotated(subcommand_to="team", **decorator_kwargs) def team_create(self, name: str = "") -> None: ... spec = getattr(team_create, constants.SUBCMD_ATTR_SPEC) From 98f33ceb74c6f14dda35e02768e48c3dbce3db7b Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 19 May 2026 15:13:06 +0100 Subject: [PATCH 07/25] feat: add parser customization to annotated argparse - Group(*members, title=, description=) for titled argument-group sections (groups= now accepts bare tuples or Group) - description= and epilog= for the generated parser - formatter_class= for a custom help formatter - parser_class= for a custom parser class Includes tests, example command, and docs. --- cmd2/annotated.py | 138 +++++++++++++++++++++++++++++----- docs/features/annotated.md | 38 ++++++++-- examples/annotated_example.py | 23 ++++++ tests/test_annotated.py | 103 +++++++++++++++++++++++++ 4 files changed, 277 insertions(+), 25 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index c845283ca..cffe67943 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -112,6 +112,7 @@ def do_paint( from .completion import CompletionItem from .decorators import _parse_positionals from .exceptions import Cmd2ArgparseError +from .rich_utils import Cmd2HelpFormatter from .types import CmdOrSetT, UnboundChoicesProvider, UnboundCompleter # --------------------------------------------------------------------------- @@ -209,6 +210,38 @@ def to_kwargs(self) -> dict[str, Any]: return kwargs +class Group: + """Argument-group definition for ``with_annotated(groups=...)``. + + Wrap parameter names with an optional ``title`` and ``description`` so the + group renders its own section in ``--help`` output. A plain + ``tuple[str, ...]`` is still accepted for an untitled group. + + Example:: + + @with_annotated( + groups=(Group("host", "port", title="connection", description="where to connect"),), + ) + def do_connect(self, host: str, port: int = 22): ... + """ + + def __init__(self, *members: str, title: str | None = None, description: str | None = None) -> None: + """Initialise an argument group definition. + + :param members: parameter names to place in the group (at least one) + :param title: optional group title shown as a section header in help + :param description: optional group description shown under the title + """ + if not members: + raise ValueError("Group requires at least one member parameter name") + self.members = members + self.title = title + self.description = description + + +#: A ``groups``/``mutually_exclusive_groups`` entry: bare names or a titled ``Group``. +GroupSpec = tuple[str, ...] | Group + #: Metadata extracted from ``Annotated[T, meta]``, or ``None`` for plain types. ArgMetadata = Argument | Option | None @@ -754,10 +787,15 @@ def _validate_group_members( raise ValueError(f"{group_type} references nonexistent parameter {name!r}") +def _group_members(spec: GroupSpec) -> tuple[str, ...]: + """Return the member parameter names for a ``groups`` entry.""" + return spec.members if isinstance(spec, Group) else spec + + def _build_argument_group_targets( parser: argparse.ArgumentParser, *, - groups: tuple[tuple[str, ...], ...] | None, + groups: tuple[GroupSpec, ...] | None, all_param_names: set[str], ) -> tuple[dict[str, _ArgumentTarget], dict[str, argparse._ArgumentGroup]]: """Build argument groups and return add_argument targets for their members.""" @@ -768,7 +806,8 @@ def _build_argument_group_targets( if not groups: return target_for, argument_group_for - for index, member_names in enumerate(groups, start=1): + for index, spec in enumerate(groups, start=1): + member_names = _group_members(spec) _validate_group_members(member_names, all_param_names=all_param_names, group_type="groups") for name in member_names: if name in argument_group_for: @@ -777,7 +816,10 @@ def _build_argument_group_targets( f"group {argument_group_index_for[name]} and argument group {index}" ) - group = parser.add_argument_group() + if isinstance(spec, Group): + group = parser.add_argument_group(title=spec.title, description=spec.description) + else: + group = parser.add_argument_group() for name in member_names: argument_group_for[name] = group argument_group_index_for[name] = index @@ -791,7 +833,7 @@ def _apply_mutex_group_targets( *, target_for: dict[str, _ArgumentTarget], argument_group_for: dict[str, argparse._ArgumentGroup], - mutually_exclusive_groups: tuple[tuple[str, ...], ...] | None, + mutually_exclusive_groups: tuple[GroupSpec, ...] | None, all_param_names: set[str], ) -> None: """Build mutually exclusive groups and update add_argument targets for their members.""" @@ -800,7 +842,8 @@ def _apply_mutex_group_targets( if not mutually_exclusive_groups: return - for index, member_names in enumerate(mutually_exclusive_groups, start=1): + for index, spec in enumerate(mutually_exclusive_groups, start=1): + member_names = _group_members(spec) _validate_group_members( member_names, all_param_names=all_param_names, @@ -828,8 +871,12 @@ def build_parser_from_function( func: Callable[..., Any], *, skip_params: frozenset[str] = _SKIP_PARAMS, - groups: tuple[tuple[str, ...], ...] | None = None, - mutually_exclusive_groups: tuple[tuple[str, ...], ...] | None = None, + groups: tuple[GroupSpec, ...] | None = None, + mutually_exclusive_groups: tuple[GroupSpec, ...] | None = None, + description: str | None = None, + epilog: str | None = None, + formatter_class: type[Cmd2HelpFormatter] | None = None, + parser_class: type[Cmd2ArgumentParser] | None = None, ) -> Cmd2ArgumentParser: """Inspect a function's signature and build a ``Cmd2ArgumentParser``. @@ -840,13 +887,26 @@ def build_parser_from_function( :param func: the command function to inspect :param skip_params: parameter names to exclude from the parser - :param groups: tuples of parameter names to place in argument groups (for help display) - :param mutually_exclusive_groups: tuples of parameter names that are mutually exclusive + :param groups: parameter names to place in argument groups, as bare tuples or + :class:`Group` instances (for help display) + :param mutually_exclusive_groups: parameter names that are mutually exclusive + :param description: parser description (shown in ``--help``) + :param epilog: parser epilog text (shown at the end of ``--help``) + :param formatter_class: custom help formatter class for the parser + :param parser_class: custom parser class (defaults to the configured default) :return: a fully configured ``Cmd2ArgumentParser`` """ from .argparse_utils import DEFAULT_ARGUMENT_PARSER - parser = DEFAULT_ARGUMENT_PARSER() + parser_cls = parser_class or DEFAULT_ARGUMENT_PARSER + parser_kwargs: dict[str, Any] = {} + if description is not None: + parser_kwargs["description"] = description + if epilog is not None: + parser_kwargs["epilog"] = epilog + if formatter_class is not None: + parser_kwargs["formatter_class"] = formatter_class + parser = parser_cls(**parser_kwargs) resolved = _resolve_parameters(func, skip_params=skip_params) @@ -895,8 +955,12 @@ def build_subcommand_handler( subcommand_to: str, *, base_command: bool = False, - groups: tuple[tuple[str, ...], ...] | None = None, - mutually_exclusive_groups: tuple[tuple[str, ...], ...] | None = None, + groups: tuple[GroupSpec, ...] | None = None, + mutually_exclusive_groups: tuple[GroupSpec, ...] | None = None, + description: str | None = None, + epilog: str | None = None, + formatter_class: type[Cmd2HelpFormatter] | None = None, + parser_class: type[Cmd2ArgumentParser] | None = None, ) -> tuple[Callable[..., Any], str, Callable[[], Cmd2ArgumentParser]]: """Build a subcommand handler wrapper and its parser from type annotations. @@ -907,6 +971,13 @@ def build_subcommand_handler( :param func: the subcommand handler function :param subcommand_to: parent command name (space-delimited for nesting) :param base_command: if True, the parser also gets ``add_subparsers()`` + :param groups: parameter names to place in argument groups, as bare tuples or + :class:`Group` instances + :param mutually_exclusive_groups: parameter names that are mutually exclusive + :param description: parser description (shown in ``--help``) + :param epilog: parser epilog text (shown at the end of ``--help``) + :param formatter_class: custom help formatter class for the parser + :param parser_class: custom parser class (defaults to the configured default) :return: ``(handler, subcommand_name, parser_builder)`` """ subcmd_name = _derive_subcommand_name(func, subcommand_to) @@ -923,7 +994,15 @@ def handler(self_arg: Any, ns: Any) -> Any: return func(self_arg, **filtered) def parser_builder() -> Cmd2ArgumentParser: - parser = build_parser_from_function(func, groups=groups, mutually_exclusive_groups=mutually_exclusive_groups) + parser = build_parser_from_function( + func, + groups=groups, + mutually_exclusive_groups=mutually_exclusive_groups, + description=description, + epilog=epilog, + formatter_class=formatter_class, + parser_class=parser_class, + ) if base_command: parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND", required=True) return parser @@ -946,8 +1025,12 @@ def with_annotated( subcommand_to: str | None = ..., help: str | None = ..., aliases: Sequence[str] = ..., - groups: tuple[tuple[str, ...], ...] | None = ..., - mutually_exclusive_groups: tuple[tuple[str, ...], ...] | None = ..., + groups: tuple[GroupSpec, ...] | None = ..., + mutually_exclusive_groups: tuple[GroupSpec, ...] | None = ..., + description: str | None = ..., + epilog: str | None = ..., + formatter_class: type[Cmd2HelpFormatter] | None = ..., + parser_class: type[Cmd2ArgumentParser] | None = ..., ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ... @@ -961,8 +1044,12 @@ def with_annotated( subcommand_to: str | None = None, help: str | None = None, # noqa: A002 aliases: Sequence[str] = (), - groups: tuple[tuple[str, ...], ...] | None = None, - mutually_exclusive_groups: tuple[tuple[str, ...], ...] | None = None, + groups: tuple[GroupSpec, ...] | None = None, + mutually_exclusive_groups: tuple[GroupSpec, ...] | None = None, + description: str | None = None, + epilog: str | None = None, + formatter_class: type[Cmd2HelpFormatter] | None = None, + parser_class: type[Cmd2ArgumentParser] | None = None, ) -> Callable[..., Any] | Callable[[Callable[..., Any]], Callable[..., Any]]: """Decorate a ``do_*`` method to build its argparse parser from type annotations. @@ -979,8 +1066,13 @@ def with_annotated( Function must be named ``{parent_underscored}_{subcommand}``. :param help: help text for the subcommand (only valid with ``subcommand_to``) :param aliases: alternative names for the subcommand (only valid with ``subcommand_to``) - :param groups: tuples of parameter names to place in argument groups (for help display) - :param mutually_exclusive_groups: tuples of parameter names that are mutually exclusive + :param groups: parameter names to place in argument groups, as bare tuples or + :class:`Group` instances (use :class:`Group` for a titled section) + :param mutually_exclusive_groups: parameter names that are mutually exclusive + :param description: parser description (shown in ``--help``) + :param epilog: parser epilog text (shown at the end of ``--help``) + :param formatter_class: custom help formatter class for the parser + :param parser_class: custom parser class (defaults to the configured default) Example:: @@ -1032,6 +1124,10 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: base_command=base_command, groups=groups, mutually_exclusive_groups=mutually_exclusive_groups, + description=description, + epilog=epilog, + formatter_class=formatter_class, + parser_class=parser_class, ) spec = SubcommandSpec( name=subcmd_name, @@ -1058,6 +1154,10 @@ def parser_builder() -> Cmd2ArgumentParser: skip_params=skip_params, groups=groups, mutually_exclusive_groups=mutually_exclusive_groups, + description=description, + epilog=epilog, + formatter_class=formatter_class, + parser_class=parser_class, ) if base_command: parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND", required=True) diff --git a/docs/features/annotated.md b/docs/features/annotated.md index 0e3d99bd9..7990e9176 100644 --- a/docs/features/annotated.md +++ b/docs/features/annotated.md @@ -166,6 +166,12 @@ time. The `Path` converter is permissive and is preserved when a custom complete `base_command=True`; declaring one elsewhere raises `TypeError`. - `help` -- help text for an annotated subcommand - `aliases` -- aliases for an annotated subcommand +- `groups` -- parameter names to place in argument groups (bare tuples or `Group`) +- `mutually_exclusive_groups` -- parameter names that are mutually exclusive +- `description` -- parser description shown in `--help` +- `epilog` -- parser epilog shown at the end of `--help` +- `formatter_class` -- a custom help formatter class for the parser +- `parser_class` -- a custom parser class (defaults to the configured default) ```py @with_annotated(with_unknown_args=True) @@ -173,6 +179,28 @@ def do_rawish(self, name: str, _unknown: list[str] | None = None): self.poutput((name, _unknown)) ``` +## Parser customization + +`description`, `epilog`, `formatter_class`, and `parser_class` are passed through to the generated +parser. Argument groups accept either a bare `tuple[str, ...]` (an untitled group) or a +[Group][cmd2.annotated.Group] for a titled, described help section: + +```py +from cmd2.annotated import Group, with_annotated + +class App(cmd2.Cmd): + @with_annotated( + description="Open a network connection.", + epilog="Example: connect example.com --port 2222", + groups=(Group("host", "port", title="connection", description="where to connect"),), + ) + def do_connect(self, host: str, port: int = 22, verbose: bool = False): + self.poutput(f"connecting to {host}:{port}") +``` + +`mutually_exclusive_groups` also accepts `Group` (its `title`/`description` are ignored, since +argparse mutually-exclusive groups have no header). + ## Annotated subcommands `@with_annotated` can also build typed subcommand trees without manually constructing subparsers. @@ -207,12 +235,10 @@ def manage_project_add(self, name: str): ## Lower-level parser building -If you need parser grouping or mutually-exclusive groups while still using annotation-driven parser -generation, [cmd2.annotated.build_parser_from_function][cmd2.annotated.build_parser_from_function] -also supports: - -- `groups=((...), (...))` -- `mutually_exclusive_groups=((...), (...))` +[cmd2.annotated.build_parser_from_function][cmd2.annotated.build_parser_from_function] builds the +parser directly from a function without registering a command. It accepts the same +`groups`, `mutually_exclusive_groups`, `description`, `epilog`, `formatter_class`, and +`parser_class` arguments as `@with_annotated`. ```py @with_annotated(preserve_quotes=True) diff --git a/examples/annotated_example.py b/examples/annotated_example.py index 4695c4fdf..b74abd575 100755 --- a/examples/annotated_example.py +++ b/examples/annotated_example.py @@ -35,6 +35,7 @@ ) from cmd2.annotated import ( Argument, + Group, Option, with_annotated, ) @@ -303,6 +304,28 @@ def manage_project_add(self, name: str) -> None: def manage_project_list(self) -> None: self.poutput("project list: demo") + # -- Parser customization ------------------------------------------------ + # description / epilog set the parser's help text; Group adds a titled, + # described section; formatter_class / parser_class accept custom classes. + + @with_annotated( + description="Open a network connection.", + epilog="Example: connect example.com --port 2222", + groups=( + Group("host", "port", title="connection", description="where to connect"), + ), + ) + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_connect(self, host: str, port: int = 22, verbose: bool = False) -> None: + """Connect to a host. + + Try: + help connect + connect example.com --port 2222 --verbose + """ + msg = f"Connecting to {host}:{port}" + self.poutput(f"{msg} (verbose)" if verbose else msg) + # -- Preserve quotes ----------------------------------------------------- @with_annotated(preserve_quotes=True) diff --git a/tests/test_annotated.py b/tests/test_annotated.py index 05cae2f4a..3a22c80a6 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -22,6 +22,7 @@ ) from cmd2.annotated import ( Argument, + Group, Option, _apply_mutex_group_targets, _build_argument_group_targets, @@ -506,6 +507,108 @@ def func(self, json: bool = False, csv: bool = False, plain: bool = False) -> No parser.parse_args(["--json", "--csv"]) +class TestParserCustomization: + """description / epilog / formatter_class / parser_class and titled Group.""" + + def test_titled_group(self) -> None: + """Group(title=..., description=...) renders a titled help section.""" + + def func(self, host: str, port: int = 22, verbose: bool = False) -> None: ... + + parser = build_parser_from_function( + func, + groups=(Group("host", "port", title="connection", description="where to connect"),), + ) + titled = [g for g in parser._action_groups if g.title == "connection"] + assert len(titled) == 1 + assert titled[0].description == "where to connect" + assert {a.dest for a in titled[0]._group_actions} == {"host", "port"} + + def test_group_requires_members(self) -> None: + with pytest.raises(ValueError, match="at least one member"): + Group(title="empty") + + def test_bare_tuple_group_still_supported(self) -> None: + def func(self, src: str, dst: str) -> None: ... + + parser = build_parser_from_function(func, groups=(("src", "dst"),)) + custom = [g for g in parser._action_groups if g.title not in {"Positional Arguments", "options"}] + assert {a.dest for g in custom for a in g._group_actions} >= {"src", "dst"} + + def test_description_and_epilog(self) -> None: + def func(self, name: str) -> None: ... + + parser = build_parser_from_function(func, description="my description", epilog="my epilog") + assert parser.description == "my description" + assert parser.epilog == "my epilog" + + def test_custom_formatter_class(self) -> None: + from cmd2.rich_utils import Cmd2HelpFormatter + + class MyFormatter(Cmd2HelpFormatter): + pass + + def func(self, name: str) -> None: ... + + parser = build_parser_from_function(func, formatter_class=MyFormatter) + assert parser.formatter_class is MyFormatter + + def test_custom_parser_class(self) -> None: + class MyParser(cmd2.Cmd2ArgumentParser): + pass + + def func(self, name: str) -> None: ... + + parser = build_parser_from_function(func, parser_class=MyParser) + assert isinstance(parser, MyParser) + + def test_customization_via_decorator(self) -> None: + """description/epilog/titled Group flow through @with_annotated end-to-end.""" + + class App(cmd2.Cmd): + @with_annotated( + description="run the thing", + epilog="see docs for more", + groups=(Group("name", title="inputs"),), + ) + def do_run(self, name: str) -> None: + self.poutput(f"ran {name}") + + app = App() + out, _err = run_cmd(app, "run alice") + assert out == ["ran alice"] + + help_out, _ = run_cmd(app, "help run") + joined = "\n".join(help_out).lower() + assert "run the thing" in joined + assert "see docs for more" in joined + assert "inputs" in joined + + def test_customization_via_subcommand(self) -> None: + """description/epilog flow through subcommand parsers.""" + + class App(cmd2.Cmd): + @with_annotated(base_command=True) + def do_team(self, *, cmd2_handler=None) -> None: + if cmd2_handler: + cmd2_handler() + + @with_annotated(subcommand_to="team", help="add a member", description="add desc", epilog="add epilog") + def team_add(self, name: str) -> None: + self.poutput(f"added {name}") + + app = App() + out, _err = run_cmd(app, "team add bob") + assert out == ["added bob"] + + from cmd2 import constants + + spec = getattr(App.team_add, constants.SUBCMD_ATTR_SPEC) + subparser = spec.parser_source() + assert subparser.description == "add desc" + assert subparser.epilog == "add epilog" + + class TestGroupHelpers: def test_validate_group_members_rejects_nonexistent_param(self) -> None: with pytest.raises(ValueError, match="nonexistent"): From c1296131fef3cbf6df23de95cc6901c3595c988a Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 19 May 2026 15:51:34 +0100 Subject: [PATCH 08/25] docs: demonstrate formatter_class and parser_class in example Adds VerbatimHelpFormatter and StrictArgumentParser subclasses and a do_report command so all four parser-customization features have a runnable demonstration, not just docs/tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/annotated_example.py | 36 +++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/examples/annotated_example.py b/examples/annotated_example.py index b74abd575..478484e24 100755 --- a/examples/annotated_example.py +++ b/examples/annotated_example.py @@ -55,6 +55,18 @@ class LogLevel(StrEnum): error = "error" +class VerbatimHelpFormatter(cmd2.RawDescriptionCmd2HelpFormatter): + """Custom help formatter: keeps the description's line breaks verbatim.""" + + +class StrictArgumentParser(cmd2.Cmd2ArgumentParser): + """Custom parser class: disables ``--opt`` prefix abbreviation.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + kwargs.setdefault("allow_abbrev", False) + super().__init__(*args, **kwargs) + + ANNOTATED_CATEGORY = "Annotated Commands" @@ -326,6 +338,30 @@ def do_connect(self, host: str, port: int = 22, verbose: bool = False) -> None: msg = f"Connecting to {host}:{port}" self.poutput(f"{msg} (verbose)" if verbose else msg) + # -- Custom formatter and parser classes --------------------------------- + # formatter_class controls how --help is rendered; parser_class swaps in a + # custom Cmd2ArgumentParser subclass. + + @with_annotated( + description="Generate a report.\n - line breaks here are preserved\n - thanks to the custom formatter", + formatter_class=VerbatimHelpFormatter, + parser_class=StrictArgumentParser, + ) + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_report(self, source: str, level: int = 1, verbose: bool = False) -> None: + """Generate a report. + + ``help report`` shows the description with its line breaks intact + (VerbatimHelpFormatter), and StrictArgumentParser rejects abbreviated flags. + + Try: + help report + report db --level 2 --verbose + report db --lev 2 # rejected: abbreviation disabled + """ + msg = f"Report for {source} at level {level}" + self.poutput(f"{msg} (verbose)" if verbose else msg) + # -- Preserve quotes ----------------------------------------------------- @with_annotated(preserve_quotes=True) From e30763e3baa7b48e3305653ae1c07b9929033687 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 19 May 2026 16:01:36 +0100 Subject: [PATCH 09/25] refactor: require Group for groups/mutually_exclusive_groups Drop bare tuple[str, ...] support; entries must be Group instances. Removes the _group_members shim and adds an explicit TypeError guard (_require_group) so a wrong type fails clearly instead of with an AttributeError. Updates tests and docs accordingly. --- cmd2/annotated.py | 84 +++++++++++++---------------------- docs/features/annotated.md | 12 ++--- examples/annotated_example.py | 7 ++- tests/test_annotated.py | 54 ++++++++++------------ 4 files changed, 63 insertions(+), 94 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index cffe67943..5c6eb1c16 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -214,8 +214,8 @@ class Group: """Argument-group definition for ``with_annotated(groups=...)``. Wrap parameter names with an optional ``title`` and ``description`` so the - group renders its own section in ``--help`` output. A plain - ``tuple[str, ...]`` is still accepted for an untitled group. + group renders its own section in ``--help`` output. Every ``groups`` and + ``mutually_exclusive_groups`` entry must be a ``Group`` instance. Example:: @@ -238,9 +238,12 @@ def __init__(self, *members: str, title: str | None = None, description: str | N self.title = title self.description = description + 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}") -#: A ``groups``/``mutually_exclusive_groups`` entry: bare names or a titled ``Group``. -GroupSpec = tuple[str, ...] | Group #: Metadata extracted from ``Annotated[T, meta]``, or ``None`` for plain types. ArgMetadata = Argument | Option | None @@ -775,27 +778,10 @@ def _filtered_namespace_kwargs( return filtered -def _validate_group_members( - member_names: tuple[str, ...], - *, - all_param_names: set[str], - group_type: str, -) -> None: - """Validate that all referenced group members exist.""" - for name in member_names: - if name not in all_param_names: - raise ValueError(f"{group_type} references nonexistent parameter {name!r}") - - -def _group_members(spec: GroupSpec) -> tuple[str, ...]: - """Return the member parameter names for a ``groups`` entry.""" - return spec.members if isinstance(spec, Group) else spec - - def _build_argument_group_targets( parser: argparse.ArgumentParser, *, - groups: tuple[GroupSpec, ...] | None, + groups: tuple[Group, ...] | None, all_param_names: set[str], ) -> tuple[dict[str, _ArgumentTarget], dict[str, argparse._ArgumentGroup]]: """Build argument groups and return add_argument targets for their members.""" @@ -807,8 +793,8 @@ def _build_argument_group_targets( return target_for, argument_group_for for index, spec in enumerate(groups, start=1): - member_names = _group_members(spec) - _validate_group_members(member_names, all_param_names=all_param_names, group_type="groups") + spec._validate_members(all_param_names=all_param_names, group_type="groups") + member_names = spec.members for name in member_names: if name in argument_group_for: raise ValueError( @@ -816,10 +802,7 @@ def _build_argument_group_targets( f"group {argument_group_index_for[name]} and argument group {index}" ) - if isinstance(spec, Group): - group = parser.add_argument_group(title=spec.title, description=spec.description) - else: - group = parser.add_argument_group() + group = parser.add_argument_group(title=spec.title, description=spec.description) for name in member_names: argument_group_for[name] = group argument_group_index_for[name] = index @@ -833,7 +816,7 @@ def _apply_mutex_group_targets( *, target_for: dict[str, _ArgumentTarget], argument_group_for: dict[str, argparse._ArgumentGroup], - mutually_exclusive_groups: tuple[GroupSpec, ...] | None, + mutually_exclusive_groups: tuple[Group, ...] | None, all_param_names: set[str], ) -> None: """Build mutually exclusive groups and update add_argument targets for their members.""" @@ -843,12 +826,8 @@ def _apply_mutex_group_targets( return for index, spec in enumerate(mutually_exclusive_groups, start=1): - member_names = _group_members(spec) - _validate_group_members( - member_names, - all_param_names=all_param_names, - group_type="mutually_exclusive_groups", - ) + spec._validate_members(all_param_names=all_param_names, group_type="mutually_exclusive_groups") + member_names = spec.members for name in member_names: if name in mutex_target_for: raise ValueError(f"parameter {name!r} cannot be assigned to multiple mutually exclusive groups") @@ -871,8 +850,8 @@ def build_parser_from_function( func: Callable[..., Any], *, skip_params: frozenset[str] = _SKIP_PARAMS, - groups: tuple[GroupSpec, ...] | None = None, - mutually_exclusive_groups: tuple[GroupSpec, ...] | None = None, + groups: tuple[Group, ...] | None = None, + mutually_exclusive_groups: tuple[Group, ...] | None = None, description: str | None = None, epilog: str | None = None, formatter_class: type[Cmd2HelpFormatter] | None = None, @@ -887,9 +866,9 @@ def build_parser_from_function( :param func: the command function to inspect :param skip_params: parameter names to exclude from the parser - :param groups: parameter names to place in argument groups, as bare tuples or - :class:`Group` instances (for help display) - :param mutually_exclusive_groups: parameter names that are mutually exclusive + :param groups: :class:`Group` instances assigning parameter names to argument + groups (for help display) + :param mutually_exclusive_groups: :class:`Group` instances of mutually exclusive parameters :param description: parser description (shown in ``--help``) :param epilog: parser epilog text (shown at the end of ``--help``) :param formatter_class: custom help formatter class for the parser @@ -939,7 +918,7 @@ def build_parser_from_function( def _derive_subcommand_name(func: Callable[..., Any], subcommand_to: str) -> str: """Derive the subcommand name from the function name and validate the naming convention. - ``subcommand_to='team member'`` + ``func.__name__='team_member_add'`` → ``'add'``. + ``subcommand_to='team member'`` + ``func.__name__='team_member_add'`` -> ``'add'``. """ expected_prefix = subcommand_to.replace(" ", "_") + "_" if not func.__name__.startswith(expected_prefix): @@ -955,8 +934,8 @@ def build_subcommand_handler( subcommand_to: str, *, base_command: bool = False, - groups: tuple[GroupSpec, ...] | None = None, - mutually_exclusive_groups: tuple[GroupSpec, ...] | None = None, + groups: tuple[Group, ...] | None = None, + mutually_exclusive_groups: tuple[Group, ...] | None = None, description: str | None = None, epilog: str | None = None, formatter_class: type[Cmd2HelpFormatter] | None = None, @@ -971,9 +950,8 @@ def build_subcommand_handler( :param func: the subcommand handler function :param subcommand_to: parent command name (space-delimited for nesting) :param base_command: if True, the parser also gets ``add_subparsers()`` - :param groups: parameter names to place in argument groups, as bare tuples or - :class:`Group` instances - :param mutually_exclusive_groups: parameter names that are mutually exclusive + :param groups: :class:`Group` instances assigning parameter names to argument groups + :param mutually_exclusive_groups: :class:`Group` instances of mutually exclusive parameters :param description: parser description (shown in ``--help``) :param epilog: parser epilog text (shown at the end of ``--help``) :param formatter_class: custom help formatter class for the parser @@ -1025,8 +1003,8 @@ def with_annotated( subcommand_to: str | None = ..., help: str | None = ..., aliases: Sequence[str] = ..., - groups: tuple[GroupSpec, ...] | None = ..., - mutually_exclusive_groups: tuple[GroupSpec, ...] | None = ..., + groups: tuple[Group, ...] | None = ..., + mutually_exclusive_groups: tuple[Group, ...] | None = ..., description: str | None = ..., epilog: str | None = ..., formatter_class: type[Cmd2HelpFormatter] | None = ..., @@ -1044,8 +1022,8 @@ def with_annotated( subcommand_to: str | None = None, help: str | None = None, # noqa: A002 aliases: Sequence[str] = (), - groups: tuple[GroupSpec, ...] | None = None, - mutually_exclusive_groups: tuple[GroupSpec, ...] | None = None, + groups: tuple[Group, ...] | None = None, + mutually_exclusive_groups: tuple[Group, ...] | None = None, description: str | None = None, epilog: str | None = None, formatter_class: type[Cmd2HelpFormatter] | None = None, @@ -1066,9 +1044,9 @@ def with_annotated( Function must be named ``{parent_underscored}_{subcommand}``. :param help: help text for the subcommand (only valid with ``subcommand_to``) :param aliases: alternative names for the subcommand (only valid with ``subcommand_to``) - :param groups: parameter names to place in argument groups, as bare tuples or - :class:`Group` instances (use :class:`Group` for a titled section) - :param mutually_exclusive_groups: parameter names that are mutually exclusive + :param groups: :class:`Group` instances assigning parameter names to argument + groups (pass ``title``/``description`` for a titled section) + :param mutually_exclusive_groups: :class:`Group` instances of mutually exclusive parameters :param description: parser description (shown in ``--help``) :param epilog: parser epilog text (shown at the end of ``--help``) :param formatter_class: custom help formatter class for the parser diff --git a/docs/features/annotated.md b/docs/features/annotated.md index 7990e9176..54fbd8533 100644 --- a/docs/features/annotated.md +++ b/docs/features/annotated.md @@ -166,8 +166,8 @@ time. The `Path` converter is permissive and is preserved when a custom complete `base_command=True`; declaring one elsewhere raises `TypeError`. - `help` -- help text for an annotated subcommand - `aliases` -- aliases for an annotated subcommand -- `groups` -- parameter names to place in argument groups (bare tuples or `Group`) -- `mutually_exclusive_groups` -- parameter names that are mutually exclusive +- `groups` -- `Group` instances assigning parameter names to argument groups +- `mutually_exclusive_groups` -- `Group` instances of mutually exclusive parameters - `description` -- parser description shown in `--help` - `epilog` -- parser epilog shown at the end of `--help` - `formatter_class` -- a custom help formatter class for the parser @@ -182,8 +182,8 @@ def do_rawish(self, name: str, _unknown: list[str] | None = None): ## Parser customization `description`, `epilog`, `formatter_class`, and `parser_class` are passed through to the generated -parser. Argument groups accept either a bare `tuple[str, ...]` (an untitled group) or a -[Group][cmd2.annotated.Group] for a titled, described help section: +parser. Argument groups are declared with [Group][cmd2.annotated.Group]; pass `title` and +`description` for a titled help section (omit them for an untitled group): ```py from cmd2.annotated import Group, with_annotated @@ -198,8 +198,8 @@ class App(cmd2.Cmd): self.poutput(f"connecting to {host}:{port}") ``` -`mutually_exclusive_groups` also accepts `Group` (its `title`/`description` are ignored, since -argparse mutually-exclusive groups have no header). +`mutually_exclusive_groups` also takes `Group` instances (their `title`/`description` are ignored, +since argparse mutually-exclusive groups have no header). ## Annotated subcommands diff --git a/examples/annotated_example.py b/examples/annotated_example.py index 478484e24..24617cbed 100755 --- a/examples/annotated_example.py +++ b/examples/annotated_example.py @@ -317,8 +317,8 @@ def manage_project_list(self) -> None: self.poutput("project list: demo") # -- Parser customization ------------------------------------------------ - # description / epilog set the parser's help text; Group adds a titled, - # described section; formatter_class / parser_class accept custom classes. + # The generated parser's help text and argument grouping are configurable + # without dropping down to a hand-built parser. @with_annotated( description="Open a network connection.", @@ -339,8 +339,7 @@ def do_connect(self, host: str, port: int = 22, verbose: bool = False) -> None: self.poutput(f"{msg} (verbose)" if verbose else msg) # -- Custom formatter and parser classes --------------------------------- - # formatter_class controls how --help is rendered; parser_class swaps in a - # custom Cmd2ArgumentParser subclass. + # A custom help formatter or Cmd2ArgumentParser subclass can be supplied. @with_annotated( description="Generate a report.\n - line breaks here are preserved\n - thanks to the custom formatter", diff --git a/tests/test_annotated.py b/tests/test_annotated.py index 3a22c80a6..d61bf7df6 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -31,7 +31,6 @@ _make_literal_type, _parse_bool, _resolve_annotation, - _validate_group_members, build_parser_from_function, with_annotated, ) @@ -411,8 +410,8 @@ class TestArgumentGroups: def test_groups_and_mutex_applied(self) -> None: parser = build_parser_from_function( _func_grouped, - groups=(("local", "remote"), ("force", "dry_run")), - mutually_exclusive_groups=(("local", "remote"), ("force", "dry_run")), + groups=(Group("local", "remote"), Group("force", "dry_run")), + mutually_exclusive_groups=(Group("local", "remote"), Group("force", "dry_run")), ) nonempty_groups = [group for group in parser._action_groups if group._group_actions] @@ -426,18 +425,18 @@ def test_groups_and_mutex_applied(self) -> None: def test_group_nonexistent_param_raises(self) -> None: with pytest.raises(ValueError, match="nonexistent parameter"): - build_parser_from_function(_func_grouped, groups=(("missing",),)) + build_parser_from_function(_func_grouped, groups=(Group("missing"),)) def test_param_in_multiple_groups_raises(self) -> None: with pytest.raises(ValueError, match="cannot be assigned to both argument group"): - build_parser_from_function(_func_grouped, groups=(("local",), ("local", "remote"))) + build_parser_from_function(_func_grouped, groups=(Group("local"), Group("local", "remote"))) def test_mutex_group_spanning_different_argument_groups_raises(self) -> None: with pytest.raises(ValueError, match="spans parameters in different argument groups"): build_parser_from_function( _func_grouped, - groups=(("local",), ("remote",)), - mutually_exclusive_groups=(("local", "remote"),), + groups=(Group("local"), Group("remote")), + mutually_exclusive_groups=(Group("local", "remote"),), ) def test_mutually_exclusive_group(self) -> None: @@ -445,7 +444,7 @@ def test_mutually_exclusive_group(self) -> None: def func(self, verbose: bool = False, quiet: bool = False) -> None: ... - parser = build_parser_from_function(func, mutually_exclusive_groups=(("verbose", "quiet"),)) + parser = build_parser_from_function(func, mutually_exclusive_groups=(Group("verbose", "quiet"),)) assert len(parser._mutually_exclusive_groups) == 1 group_dests = {a.dest for a in parser._mutually_exclusive_groups[0]._group_actions} assert group_dests == {"verbose", "quiet"} @@ -457,7 +456,7 @@ def test_multiple_mutually_exclusive_groups(self) -> None: def func(self, verbose: bool = False, quiet: bool = False, json: bool = False, csv: bool = False) -> None: ... - parser = build_parser_from_function(func, mutually_exclusive_groups=(("verbose", "quiet"), ("json", "csv"))) + parser = build_parser_from_function(func, mutually_exclusive_groups=(Group("verbose", "quiet"), Group("json", "csv"))) assert len(parser._mutually_exclusive_groups) == 2 def test_argument_group(self) -> None: @@ -465,7 +464,7 @@ def test_argument_group(self) -> None: def func(self, src: str, dst: str, recursive: bool = False, verbose: bool = False) -> None: ... - parser = build_parser_from_function(func, groups=(("src", "dst"),)) + parser = build_parser_from_function(func, groups=(Group("src", "dst"),)) default_titles = {"Positional Arguments", "options"} custom_groups = [g for g in parser._action_groups if g.title not in default_titles] assert len(custom_groups) >= 1 @@ -476,7 +475,7 @@ def test_mutually_exclusive_via_decorator(self) -> None: """@with_annotated(mutually_exclusive_groups=...) works end-to-end.""" class App(cmd2.Cmd): - @with_annotated(mutually_exclusive_groups=(("verbose", "quiet"),)) + @with_annotated(mutually_exclusive_groups=(Group("verbose", "quiet"),)) def do_run(self, verbose: bool = False, quiet: bool = False) -> None: if verbose: self.poutput("verbose") @@ -497,8 +496,8 @@ def func(self, json: bool = False, csv: bool = False, plain: bool = False) -> No parser = build_parser_from_function( func, - groups=(("json", "csv"),), - mutually_exclusive_groups=(("json", "csv"),), + groups=(Group("json", "csv"),), + mutually_exclusive_groups=(Group("json", "csv"),), ) custom_groups = [g for g in parser._action_groups if g.title not in {"Positional Arguments", "options"}] all_custom_dests = {a.dest for g in custom_groups for a in g._group_actions} @@ -528,13 +527,6 @@ def test_group_requires_members(self) -> None: with pytest.raises(ValueError, match="at least one member"): Group(title="empty") - def test_bare_tuple_group_still_supported(self) -> None: - def func(self, src: str, dst: str) -> None: ... - - parser = build_parser_from_function(func, groups=(("src", "dst"),)) - custom = [g for g in parser._action_groups if g.title not in {"Positional Arguments", "options"}] - assert {a.dest for g in custom for a in g._group_actions} >= {"src", "dst"} - def test_description_and_epilog(self) -> None: def func(self, name: str) -> None: ... @@ -612,13 +604,13 @@ def team_add(self, name: str) -> None: class TestGroupHelpers: def test_validate_group_members_rejects_nonexistent_param(self) -> None: with pytest.raises(ValueError, match="nonexistent"): - _validate_group_members(("verbose", "nonexistent"), all_param_names={"verbose"}, group_type="groups") + Group("verbose", "nonexistent")._validate_members(all_param_names={"verbose"}, group_type="groups") def test_build_argument_group_targets(self) -> None: parser = argparse.ArgumentParser() target_for, argument_group_for = _build_argument_group_targets( parser, - groups=(("src", "dst"),), + groups=(Group("src", "dst"),), all_param_names={"src", "dst", "recursive"}, ) assert set(target_for) == {"src", "dst"} @@ -631,7 +623,7 @@ def test_build_argument_group_targets_rejects_duplicate_assignment(self) -> None with pytest.raises(ValueError, match="argument group 1 and argument group 2"): _build_argument_group_targets( parser, - groups=(("verbose",), ("verbose",)), + groups=(Group("verbose"), Group("verbose")), all_param_names={"verbose"}, ) @@ -639,7 +631,7 @@ def test_apply_mutex_group_targets(self) -> None: parser = argparse.ArgumentParser() target_for, argument_group_for = _build_argument_group_targets( parser, - groups=(("json", "csv"),), + groups=(Group("json", "csv"),), all_param_names={"json", "csv", "plain"}, ) @@ -647,7 +639,7 @@ def test_apply_mutex_group_targets(self) -> None: parser, target_for=target_for, argument_group_for=argument_group_for, - mutually_exclusive_groups=(("json", "csv"),), + mutually_exclusive_groups=(Group("json", "csv"),), all_param_names={"json", "csv", "plain"}, ) @@ -661,7 +653,7 @@ def test_apply_mutex_group_targets_rejects_duplicate_assignment(self) -> None: parser, target_for={}, argument_group_for={}, - mutually_exclusive_groups=(("verbose",), ("verbose",)), + mutually_exclusive_groups=(Group("verbose"), Group("verbose")), all_param_names={"verbose"}, ) @@ -669,7 +661,7 @@ def test_apply_mutex_group_targets_rejects_cross_group_members(self) -> None: parser = argparse.ArgumentParser() _target_for, argument_group_for = _build_argument_group_targets( parser, - groups=(("src",), ("dst",)), + groups=(Group("src"), Group("dst")), all_param_names={"src", "dst"}, ) @@ -678,7 +670,7 @@ def test_apply_mutex_group_targets_rejects_cross_group_members(self) -> None: parser, target_for={}, argument_group_for=argument_group_for, - mutually_exclusive_groups=(("src", "dst"),), + mutually_exclusive_groups=(Group("src", "dst"),), all_param_names={"src", "dst"}, ) @@ -1192,8 +1184,8 @@ def do_prefixed(self, cmd2_mode: int = 1) -> None: class _GroupedParserApp(cmd2.Cmd): @with_annotated( - groups=(("local", "remote"), ("force", "dry_run")), - mutually_exclusive_groups=(("local", "remote"), ("force", "dry_run")), + groups=(Group("local", "remote"), Group("force", "dry_run")), + mutually_exclusive_groups=(Group("local", "remote"), Group("force", "dry_run")), ) def do_transfer( self, @@ -1484,7 +1476,7 @@ def do_fmt(self, cmd2_handler) -> None: if handler: handler() - @with_annotated(subcommand_to="fmt", help="output", mutually_exclusive_groups=(("json", "csv"),)) + @with_annotated(subcommand_to="fmt", help="output", mutually_exclusive_groups=(Group("json", "csv"),)) def fmt_out(self, msg: str, json: bool = False, csv: bool = False) -> None: self.poutput(f"json={json} csv={csv} {msg}") From d913f06ee367ddce987e389f4265a26951cade4f Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 19 May 2026 17:01:51 +0100 Subject: [PATCH 10/25] docs: demonstrate mutually_exclusive_groups in example Adds a do_export command so mutually_exclusive_groups has a runnable demo alongside the existing groups demo. --- examples/annotated_example.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/examples/annotated_example.py b/examples/annotated_example.py index 24617cbed..6413035c6 100755 --- a/examples/annotated_example.py +++ b/examples/annotated_example.py @@ -338,6 +338,25 @@ def do_connect(self, host: str, port: int = 22, verbose: bool = False) -> None: msg = f"Connecting to {host}:{port}" self.poutput(f"{msg} (verbose)" if verbose else msg) + # -- Mutually exclusive groups ------------------------------------------- + # Group instances passed to mutually_exclusive_groups make argparse reject + # combinations (title/description are ignored here). + + @with_annotated( + description="Export data in exactly one format.", + mutually_exclusive_groups=(Group("json", "csv"),), + ) + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_export(self, name: str, json: bool = False, csv: bool = False) -> None: + """Export a dataset; --json and --csv are mutually exclusive. + + Try: + export sales --json + export sales --json --csv # rejected: not allowed together + """ + fmt = "json" if json else "csv" if csv else "text" + self.poutput(f"Exporting {name} as {fmt}") + # -- Custom formatter and parser classes --------------------------------- # A custom help formatter or Cmd2ArgumentParser subclass can be supplied. From b6a983284e4ff192ce09f78027144094f444fab9 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Tue, 19 May 2026 17:26:34 +0100 Subject: [PATCH 11/25] chore: format --- docs/features/annotated.md | 6 +++--- examples/annotated_example.py | 4 +--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/features/annotated.md b/docs/features/annotated.md index 54fbd8533..92544772e 100644 --- a/docs/features/annotated.md +++ b/docs/features/annotated.md @@ -236,9 +236,9 @@ def manage_project_add(self, name: str): ## Lower-level parser building [cmd2.annotated.build_parser_from_function][cmd2.annotated.build_parser_from_function] builds the -parser directly from a function without registering a command. It accepts the same -`groups`, `mutually_exclusive_groups`, `description`, `epilog`, `formatter_class`, and -`parser_class` arguments as `@with_annotated`. +parser directly from a function without registering a command. It accepts the same `groups`, +`mutually_exclusive_groups`, `description`, `epilog`, `formatter_class`, and `parser_class` +arguments as `@with_annotated`. ```py @with_annotated(preserve_quotes=True) diff --git a/examples/annotated_example.py b/examples/annotated_example.py index 6413035c6..c64f2b860 100755 --- a/examples/annotated_example.py +++ b/examples/annotated_example.py @@ -323,9 +323,7 @@ def manage_project_list(self) -> None: @with_annotated( description="Open a network connection.", epilog="Example: connect example.com --port 2222", - groups=( - Group("host", "port", title="connection", description="where to connect"), - ), + groups=(Group("host", "port", title="connection", description="where to connect"),), ) @cmd2.with_category(ANNOTATED_CATEGORY) def do_connect(self, host: str, port: int = 22, verbose: bool = False) -> None: From f05bc31a2184bffa7106c99b508469b10e21cdd7 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Wed, 20 May 2026 10:07:25 -0400 Subject: [PATCH 12/25] Adding another argument to an example method to help demonstrate a really subtle conversion edge-case bug --- examples/annotated_example.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/annotated_example.py b/examples/annotated_example.py index c64f2b860..6adae6a36 100755 --- a/examples/annotated_example.py +++ b/examples/annotated_example.py @@ -185,6 +185,7 @@ def do_deploy( service: str, mode: Literal["safe", "fast"] = "safe", budget: Decimal = Decimal("1.50"), + timeout: Literal[0, 1, 2] = 1, ) -> None: """Deploy using Literal choices and Decimal parsing. @@ -192,7 +193,7 @@ def do_deploy( deploy api --mode deploy api --mode fast --budget 2.75 """ - self.poutput(f"Deploying {service} in {mode} mode with budget {budget}") + self.poutput(f"Deploying {service} in {mode} mode with budget {budget} and timeout {timeout}") # -- Typed kwargs -------------------------------------------------------- # With @with_argparser you'd access args.name, args.count on a Namespace. From 3232e13557d948aa46d0ca0f181efe997c4aa155 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Wed, 20 May 2026 10:24:28 -0400 Subject: [PATCH 13/25] Fix subtle edge case bug where booleans were accepted as integer literals --- cmd2/annotated.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 5c6eb1c16..c115a5aa7 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -303,8 +303,10 @@ def _convert(value: str) -> Any: else: bool_value = None - if bool_value is not None and bool_value in literal_values: - return bool_value + if bool_value is not None: + for v in literal_values: + if type(v) is bool and v == bool_value: + return bool_value valid = ", ".join(str(v) for v in literal_values) raise argparse.ArgumentTypeError(f"invalid choice: {value!r} (choose from {valid})") From da26c3ad7f2e466d5e99a1c17f11d966524ad6a2 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Wed, 20 May 2026 22:07:25 +0100 Subject: [PATCH 14/25] feat: cover 0-or-1 case --- cmd2/annotated.py | 13 +++++++++---- tests/test_annotated.py | 15 ++++++++++++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index c115a5aa7..1cbe5f3eb 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -56,7 +56,8 @@ def do_paint( - ``Literal[...]`` -- sets ``type=converter`` and ``choices`` from literal values - ``list[T]`` / ``set[T]`` / ``tuple[T, ...]`` -- ``nargs='+'`` (or ``'*'`` if has a default) - ``tuple[T, T]`` (fixed arity, same type) -- ``nargs=N`` with ``type=T`` -- ``T | None`` -- unwrapped to ``T``, treated as optional +- ``T | None`` (no default) -- positional with ``nargs='?'`` (accepts 0-or-1 tokens) +- ``T | None = None`` -- ``--flag`` option with ``default=None`` Action compatibility note: @@ -503,6 +504,7 @@ def _resolve_type( tp: type, *, is_positional: bool = False, + is_optional: bool = False, has_default: bool = False, default: Any = None, metadata: ArgMetadata = None, @@ -553,6 +555,10 @@ def _resolve_type( if is_kw_only and not has_default: kwargs["required"] = True + # An optional positional scalar (``T | None`` without a default) takes 0-or-1 tokens. + if is_optional and is_positional and "nargs" not in kwargs and not kwargs.get("is_collection"): + kwargs["nargs"] = "?" + if kwargs.get("choices_provider") or kwargs.get("completer"): kwargs.pop("choices", None) converter = kwargs.get("type") @@ -635,13 +641,12 @@ def _resolve_annotation( """ tp, metadata, is_optional = _normalize_annotation(annotation) - is_positional = isinstance(metadata, Argument) or ( - not isinstance(metadata, Option) and not has_default and not is_optional and not is_kw_only - ) + is_positional = isinstance(metadata, Argument) or (metadata is None and not has_default and not is_kw_only) tp, type_kwargs = _resolve_type( tp, is_positional=is_positional, + is_optional=is_optional, has_default=has_default, default=default, metadata=metadata, diff --git a/tests/test_annotated.py b/tests/test_annotated.py index d61bf7df6..2c7920611 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -100,6 +100,8 @@ def _func_literal(self, mode: Literal["fast", "slow"]) -> None: ... def _func_literal_option(self, mode: Literal["fast", "slow"] = "fast") -> None: ... def _func_literal_int(self, level: Literal[1, 2, 3]) -> None: ... def _func_optional(self, name: str | None = None) -> None: ... +def _func_optional_positional(self, val: Annotated[int | None, Argument()]) -> None: ... +def _func_optional_plain(self, val: int | None) -> None: ... def _func_list(self, files: list[str]) -> None: ... def _func_list_default(self, items: list[str] | None = None) -> None: ... def _func_set(self, tags: set[str]) -> None: ... @@ -217,6 +219,10 @@ class TestBuildParser: pytest.param(_func_tuple_fixed, {"option_strings": [], "nargs": 2, "type": int}, id="tuple_fixed"), pytest.param(_func_bare_list, {"option_strings": [], "nargs": "+"}, id="bare_list"), pytest.param(_func_bare_tuple, {"option_strings": [], "nargs": "+"}, id="bare_tuple"), + pytest.param( + _func_optional_positional, {"option_strings": [], "nargs": "?", "type": int}, id="optional_positional" + ), + pytest.param(_func_optional_plain, {"option_strings": [], "nargs": "?", "type": int}, id="optional_plain"), # --- Options --- pytest.param(_func_int_option, {"option_strings": ["--count"], "type": int, "default": 1}, id="int_option"), pytest.param(_func_float_option, {"option_strings": ["--rate"], "type": float, "default": 1.0}, id="float_option"), @@ -688,7 +694,8 @@ class TestResolveAnnotation: ("annotation", "has_default", "expected_positional"), [ pytest.param(str, False, True, id="plain_str"), - pytest.param(str | None, False, False, id="optional_str"), + pytest.param(str | None, False, True, id="optional_str_positional"), + pytest.param(str | None, True, False, id="optional_str_with_default"), pytest.param(Annotated[str, _ARG_META], False, True, id="annotated_argument"), pytest.param(Annotated[str, _OPT_META], False, False, id="annotated_option"), pytest.param(Annotated[str, "some doc"], False, True, id="annotated_no_meta"), @@ -711,6 +718,12 @@ def test_typing_union_optional(self) -> None: ns: dict = {} exec("import typing; t = typing.Union[str, None]", ns) _kwargs, _meta, positional = _resolve_annotation(ns["t"]) + assert positional is True + + def test_typing_union_optional_with_default(self) -> None: + ns: dict = {} + exec("import typing; t = typing.Union[str, None]", ns) + _kwargs, _meta, positional = _resolve_annotation(ns["t"], has_default=True, default=None) assert positional is False def test_annotated_multiple_metadata_picks_first(self) -> None: From 572707621d61f45c5993f4e923e77728b4906f43 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Wed, 20 May 2026 22:33:08 +0100 Subject: [PATCH 15/25] chore: extend verification --- cmd2/annotated.py | 32 +++++++++++++++++++++++++++-- docs/features/annotated.md | 32 +++++++++++++++++------------ tests/test_annotated.py | 42 +++++++++++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 16 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 1cbe5f3eb..de0adb240 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -54,7 +54,7 @@ def do_paint( - ``enum.Enum`` subclass -- ``type=converter``, ``choices`` from member values - ``decimal.Decimal`` -- sets ``type=Decimal`` - ``Literal[...]`` -- sets ``type=converter`` and ``choices`` from literal values -- ``list[T]`` / ``set[T]`` / ``tuple[T, ...]`` -- ``nargs='+'`` (or ``'*'`` if has a default) +- ``list[T]`` / ``set[T]`` / ``tuple[T, ...]`` -- ``nargs='+'`` (or ``'*'`` if has a default or is ``| None``) - ``tuple[T, T]`` (fixed arity, same type) -- ``nargs=N`` with ``type=T`` - ``T | None`` (no default) -- positional with ``nargs='?'`` (accepts 0-or-1 tokens) - ``T | None = None`` -- ``--flag`` option with ``default=None`` @@ -71,6 +71,11 @@ def do_paint( - ``str | int`` -- union of multiple non-None types is ambiguous - ``tuple[int, str, float]`` -- mixed element types are not currently supported because argparse can only apply a single ``type=`` converter per argument +- ``Annotated[T, Argument(nargs=N)]`` where ``N`` produces a list (``'*'``, ``'+'``, + or integer ``>= 1``) and ``T`` is not a collection type. Use ``list[T]`` or + ``tuple[T, ...]`` to match the runtime shape. +- ``Annotated[tuple[T, T], Argument(nargs=N)]`` where ``N`` differs from the number of + elements declared by the tuple type. The tuple already pins ``nargs``. When combining ``Annotated`` with ``Optional``, the union must go *inside*: ``Annotated[T | None, meta]``. Writing @@ -517,7 +522,9 @@ def _resolve_type( Returns ``(base_type, kwargs_dict)``. """ args = get_args(tp) - resolver_has_default = has_default or is_kw_only + # ``has_default``, ``is_kw_only``, and ``is_optional`` all mean "this argument may be absent", + # so collection resolvers should pick ``nargs='*'`` instead of ``'+'``. + resolver_has_default = has_default or is_kw_only or is_optional ctx: dict[str, Any] = { "is_positional": is_positional, "has_default": resolver_has_default, @@ -541,9 +548,30 @@ def _resolve_type( base_type = tp kwargs = {} + resolver_nargs = kwargs.get("nargs") + if metadata: kwargs.update(metadata.to_kwargs()) + type_repr = tp.__name__ if hasattr(tp, "__name__") else str(tp) + nargs_val = kwargs.get("nargs") + + # A fixed-arity type (e.g. ``tuple[T, T]``) declares its own nargs; + # user metadata cannot override it to a different value. + if isinstance(resolver_nargs, int) and nargs_val != resolver_nargs: + raise TypeError( + f"nargs={nargs_val!r} conflicts with the fixed arity of '{type_repr}' (expected nargs={resolver_nargs})." + ) + + # nargs that produces a list of values requires a collection annotation. + # Catches mistakes like ``Annotated[str, Argument(nargs=2)]`` where the + # parameter is typed as a scalar but argparse will hand back a list. + if not kwargs.get("is_collection") and (nargs_val in ("*", "+") or (isinstance(nargs_val, int) and nargs_val >= 1)): + raise TypeError( + f"nargs={nargs_val!r} produces a list of values, but the annotation '{type_repr}' is not a collection type. " + f"Use list[T], tuple[T, ...], or set[T] (optionally with | None) to match." + ) + # Some argparse actions (e.g. count/store_true) do not accept a type converter. action_name = kwargs.get("action") if isinstance(action_name, str) and action_name in _ACTIONS_DISALLOW_TYPE: diff --git a/docs/features/annotated.md b/docs/features/annotated.md index 92544772e..67ed15ee4 100644 --- a/docs/features/annotated.md +++ b/docs/features/annotated.md @@ -78,19 +78,20 @@ them as keyword arguments. The decorator converts Python type annotations into `add_argument()` calls: -| Type annotation | Generated argparse setting | -| -------------------------------------------------------- | --------------------------------------------------- | -| `str` | default (no `type=` needed) | -| `int`, `float` | `type=int` or `type=float` | -| `bool` with a default | boolean optional flag via `BooleanOptionalAction` | -| positional `bool` | parsed from `true/false`, `yes/no`, `on/off`, `1/0` | -| `Path` | `type=Path` | -| `Enum` subclass | `type=converter`, `choices` from member values | -| `decimal.Decimal` | `type=decimal.Decimal` | -| `Literal[...]` | `type=literal-converter`, `choices` from values | -| `Collection[T]` / `list[T]` / `set[T]` / `tuple[T, ...]` | `nargs='+'` (or `'*'` if it has a default) | -| `tuple[T, T]` | fixed `nargs=N` with `type=T` | -| `T \| None` | unwrapped to `T`, treated as optional | +| Type annotation | Generated argparse setting | +| -------------------------------------------------------- | ---------------------------------------------------------- | +| `str` | default (no `type=` needed) | +| `int`, `float` | `type=int` or `type=float` | +| `bool` with a default | boolean optional flag via `BooleanOptionalAction` | +| positional `bool` | parsed from `true/false`, `yes/no`, `on/off`, `1/0` | +| `Path` | `type=Path` | +| `Enum` subclass | `type=converter`, `choices` from member values | +| `decimal.Decimal` | `type=decimal.Decimal` | +| `Literal[...]` | `type=literal-converter`, `choices` from values | +| `Collection[T]` / `list[T]` / `set[T]` / `tuple[T, ...]` | `nargs='+'` (or `'*'` if it has a default or is `\| None`) | +| `tuple[T, T]` | fixed `nargs=N` with `type=T` | +| `T \| None` (no default) | positional with `nargs='?'` (accepts 0-or-1 tokens) | +| `T \| None = None` | `--flag` option with `default=None` | When collection types are used with `@with_annotated`, parsed values are passed to the command function as: @@ -104,6 +105,11 @@ Unsupported patterns raise `TypeError`, including: - unions with multiple non-`None` members such as `str | int` - mixed-type tuples such as `tuple[int, str]` - `Annotated[T, meta] | None`; write `Annotated[T | None, meta]` instead +- `Annotated[T, Argument(nargs=N)]` where `N` is `'*'`, `'+'`, or an integer `>= 1` and `T` is not a + collection type. `nargs` values that produce a list of values need a collection annotation such as + `list[T]` or `tuple[T, ...]`. +- `Annotated[tuple[T, T], Argument(nargs=N)]` where `N` differs from the number of elements declared + by the tuple. The tuple type already pins `nargs`; user metadata cannot change it. The parameter names `dest` and `subcommand` are reserved and may not be used as annotated parameter names. diff --git a/tests/test_annotated.py b/tests/test_annotated.py index 2c7920611..40bc15ba9 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -102,6 +102,9 @@ def _func_literal_int(self, level: Literal[1, 2, 3]) -> None: ... def _func_optional(self, name: str | None = None) -> None: ... def _func_optional_positional(self, val: Annotated[int | None, Argument()]) -> None: ... def _func_optional_plain(self, val: int | None) -> None: ... +def _func_optional_list(self, vals: list[int] | None) -> None: ... +def _func_optional_tuple_ellipsis(self, vals: tuple[int, ...] | None) -> None: ... +def _func_optional_explicit_nargs(self, vals: Annotated[tuple[int, ...] | None, Argument(nargs=2)]) -> None: ... def _func_list(self, files: list[str]) -> None: ... def _func_list_default(self, items: list[str] | None = None) -> None: ... def _func_set(self, tags: set[str]) -> None: ... @@ -112,7 +115,7 @@ def _func_bare_tuple(self, items: tuple) -> None: ... def _func_annotated_arg(self, name: Annotated[str, Argument(help_text="Your name")]) -> None: ... def _func_annotated_option(self, color: Annotated[str, Option("--color", "-c", help_text="Pick")] = "blue") -> None: ... def _func_annotated_metavar(self, name: Annotated[str, Argument(metavar="NAME")]) -> None: ... -def _func_annotated_nargs(self, names: Annotated[str, Argument(nargs=2)]) -> None: ... +def _func_annotated_nargs(self, names: Annotated[tuple[str, ...], Argument(nargs=2)]) -> None: ... def _func_annotated_action(self, verbose: Annotated[bool, Option("--verbose", "-v", action="count")] = False) -> None: ... def _func_annotated_action_non_bool(self, count: Annotated[int, Option("--count", action="count")] = 0) -> None: ... def _func_annotated_required(self, name: Annotated[str, Option("--name", required=True)]) -> None: ... @@ -223,6 +226,17 @@ class TestBuildParser: _func_optional_positional, {"option_strings": [], "nargs": "?", "type": int}, id="optional_positional" ), pytest.param(_func_optional_plain, {"option_strings": [], "nargs": "?", "type": int}, id="optional_plain"), + pytest.param(_func_optional_list, {"option_strings": [], "nargs": "*", "type": int}, id="optional_list"), + pytest.param( + _func_optional_tuple_ellipsis, + {"option_strings": [], "nargs": "*", "type": int}, + id="optional_tuple_ellipsis", + ), + pytest.param( + _func_optional_explicit_nargs, + {"option_strings": [], "nargs": 2, "type": int}, + id="optional_explicit_nargs_overrides", + ), # --- Options --- pytest.param(_func_int_option, {"option_strings": ["--count"], "type": int, "default": 1}, id="int_option"), pytest.param(_func_float_option, {"option_strings": ["--rate"], "type": float, "default": 1.0}, id="float_option"), @@ -749,6 +763,32 @@ def test_tuple_mixed_raises(self) -> None: with pytest.raises(TypeError, match="mixed element types"): _resolve_annotation(tuple[int, str, float]) + @pytest.mark.parametrize( + "annotation", + [ + pytest.param(Annotated[str, Argument(nargs=2)], id="str_nargs_2"), + pytest.param(Annotated[int | None, Argument(nargs="+")], id="optional_int_nargs_plus"), + pytest.param(Annotated[int, Argument(nargs="*")], id="int_nargs_star"), + pytest.param(Annotated[str, Argument(nargs=1)], id="str_nargs_1"), + ], + ) + def test_multi_nargs_on_scalar_raises(self, annotation) -> None: + with pytest.raises(TypeError, match=r"nargs=.* not a collection type"): + _resolve_annotation(annotation) + + @pytest.mark.parametrize( + "annotation", + [ + pytest.param(Annotated[tuple[str, str], Argument(nargs=1)], id="tuple2_nargs_1"), + pytest.param(Annotated[tuple[str, str], Argument(nargs=3)], id="tuple2_nargs_3"), + pytest.param(Annotated[tuple[int, int, int], Argument(nargs="+")], id="tuple3_nargs_plus"), + pytest.param(Annotated[tuple[str, str], Argument(nargs="?")], id="tuple2_nargs_optional"), + ], + ) + def test_nargs_overrides_fixed_arity_raises(self, annotation) -> None: + with pytest.raises(TypeError, match=r"conflicts with the fixed arity"): + _resolve_annotation(annotation) + @pytest.mark.parametrize( "annotation", [ From e6790c3c83d27bbee369b531df445dbec5ddc6a2 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Wed, 20 May 2026 22:40:32 +0100 Subject: [PATCH 16/25] chore: minor clean up --- cmd2/annotated.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index de0adb240..53cf7690d 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -458,7 +458,7 @@ def _resolve_tuple(_tp: Any, args: tuple[Any, ...], *, has_default: bool = False first = args[0] if not all(a == first for a in args[1:]): raise TypeError( - f"tuple[{', '.join(a.__name__ if hasattr(a, '__name__') else str(a) for a in args)}] " + f"tuple[{', '.join(_type_name(a) for a in args)}] " f"has mixed element types which is not currently supported because argparse " f"can only apply a single type= converter per argument. " f"Use tuple[T, T] (same type) or tuple[T, ...] instead." @@ -505,6 +505,22 @@ def _resolve_enum(tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> dict[str, Any } +# -- Helpers ------------------------------------------------------------------ + + +def _type_name(tp: Any) -> str: + """Best-effort type name for diagnostic messages.""" + return tp.__name__ if hasattr(tp, "__name__") else str(tp) + + +def _nargs_yields_list(nargs: Any) -> bool: + """Return ``True`` when an argparse ``nargs`` value produces a list at parse time. + + ``nargs=1`` is included: argparse returns ``[value]``, not the bare value. + """ + return nargs in ("*", "+") or (isinstance(nargs, int) and nargs >= 1) + + def _resolve_type( tp: type, *, @@ -553,22 +569,19 @@ def _resolve_type( if metadata: kwargs.update(metadata.to_kwargs()) - type_repr = tp.__name__ if hasattr(tp, "__name__") else str(tp) nargs_val = kwargs.get("nargs") # A fixed-arity type (e.g. ``tuple[T, T]``) declares its own nargs; # user metadata cannot override it to a different value. if isinstance(resolver_nargs, int) and nargs_val != resolver_nargs: raise TypeError( - f"nargs={nargs_val!r} conflicts with the fixed arity of '{type_repr}' (expected nargs={resolver_nargs})." + f"nargs={nargs_val!r} conflicts with the fixed arity of '{_type_name(tp)}' (expected nargs={resolver_nargs})." ) # nargs that produces a list of values requires a collection annotation. - # Catches mistakes like ``Annotated[str, Argument(nargs=2)]`` where the - # parameter is typed as a scalar but argparse will hand back a list. - if not kwargs.get("is_collection") and (nargs_val in ("*", "+") or (isinstance(nargs_val, int) and nargs_val >= 1)): + if not kwargs.get("is_collection") and _nargs_yields_list(nargs_val): raise TypeError( - f"nargs={nargs_val!r} produces a list of values, but the annotation '{type_repr}' is not a collection type. " + f"nargs={nargs_val!r} produces a list of values, but the annotation '{_type_name(tp)}' is not a collection type. " f"Use list[T], tuple[T, ...], or set[T] (optionally with | None) to match." ) @@ -613,7 +626,7 @@ def _unwrap_optional(tp: type) -> tuple[type, bool]: f"Unexpected single-element Union without None: Union[{non_none[0]}]. " f"Use the type directly instead of wrapping in Union." ) - type_names = " | ".join(a.__name__ if hasattr(a, "__name__") else str(a) for a in non_none) + type_names = " | ".join(_type_name(a) for a in non_none) raise TypeError(f"Union type {type_names} is ambiguous for auto-resolution.") return tp, False From 4fc726c4a5e39403cef97504c454ee8772dc7bab Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Wed, 20 May 2026 23:13:07 +0100 Subject: [PATCH 17/25] chore: a few extra test --- tests/test_annotated.py | 130 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/tests/test_annotated.py b/tests/test_annotated.py index 40bc15ba9..21235e53f 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -12,6 +12,7 @@ from typing import ( Annotated, Literal, + Optional, ) import pytest @@ -79,6 +80,10 @@ class _PlainColor(enum.Enum): ] +class _Port(int): + """Subclass of ``int`` used to verify subclass fallback in type resolution.""" + + # --------------------------------------------------------------------------- # Single-parameter test functions for build_parser_from_function. # Each has exactly one param (besides self) so dest is auto-derived. @@ -134,6 +139,19 @@ def _func_plain_enum(self, color: _PlainColor) -> None: ... def _func_list_int(self, nums: list[int]) -> None: ... def _func_set_int(self, nums: set[int]) -> None: ... def _func_tuple_fixed_triple(self, triple: tuple[int, int, int]) -> None: ... +def _func_list_bool(self, flags: list[bool]) -> None: ... +def _func_set_bool(self, flags: set[bool]) -> None: ... +def _func_list_path(self, files: list[Path]) -> None: ... +def _func_list_enum(self, colors: list[_Color]) -> None: ... +def _func_list_literal(self, modes: list[Literal["fast", "slow"]]) -> None: ... +def _func_tuple_paths(self, src_dst: tuple[Path, Path]) -> None: ... +def _func_tuple_enums(self, pair: tuple[_Color, _Color]) -> None: ... +def _func_optional_str_nondefault(self, name: str | None = "world") -> None: ... +def _func_typing_optional(self, count: Optional[int] = None) -> None: ... # noqa: UP045 +def _func_int_subclass(self, port: _Port) -> None: ... +def _func_store_true_action(self, verbose: Annotated[bool, Option("--verbose", action="store_true")] = False) -> None: ... +def _func_store_false_action(self, quiet: Annotated[bool, Option("--quiet", action="store_false")] = True) -> None: ... +def _func_append_action(self, tag: Annotated[str | None, Option("--tag", action="append")] = None) -> None: ... def _func_multi(self, a: str, b: int, c: int = 1) -> None: ... def _func_grouped( self, @@ -287,6 +305,40 @@ class TestBuildParser: {"option_strings": ["--name"], "default": None}, id="optional_annotated_inside", ), + # --- Collections of complex element types --- + pytest.param(_func_list_bool, {"option_strings": [], "nargs": "+", "type": _parse_bool}, id="list_bool"), + pytest.param(_func_set_bool, {"option_strings": [], "nargs": "+", "type": _parse_bool}, id="set_bool"), + pytest.param(_func_list_path, {"option_strings": [], "nargs": "+", "type": Path}, id="list_path"), + pytest.param( + _func_list_literal, + {"option_strings": [], "nargs": "+", "choices": ["fast", "slow"]}, + id="list_literal", + ), + pytest.param( + _func_list_enum, + {"option_strings": [], "nargs": "+", "choices": _COLOR_CHOICE_ITEMS}, + id="list_enum", + ), + pytest.param(_func_tuple_paths, {"option_strings": [], "nargs": 2, "type": Path}, id="tuple_paths"), + pytest.param( + _func_tuple_enums, + {"option_strings": [], "nargs": 2, "choices": _COLOR_CHOICE_ITEMS}, + id="tuple_enums", + ), + # --- Subclass fallback (Port(int) uses int converter) --- + pytest.param(_func_int_subclass, {"option_strings": [], "type": int}, id="int_subclass"), + # --- Optional with non-None default --- + pytest.param( + _func_optional_str_nondefault, + {"option_strings": ["--name"], "default": "world"}, + id="optional_str_nondefault", + ), + # --- typing.Optional[T] (vs T | None) end-to-end --- + pytest.param( + _func_typing_optional, + {"option_strings": ["--count"], "type": int, "default": None}, + id="typing_optional", + ), ], ) def test_action_attributes(self, func, expected) -> None: @@ -303,6 +355,26 @@ def test_annotated_action_count_non_bool(self) -> None: assert isinstance(action, argparse._CountAction) assert action.default == 0 + def test_annotated_action_store_true(self) -> None: + """``action='store_true'`` strips the inferred bool converter.""" + action = _get_param_action(_func_store_true_action) + assert isinstance(action, argparse._StoreTrueAction) + assert action.type is None + assert action.default is False + + def test_annotated_action_store_false(self) -> None: + """``action='store_false'`` strips the inferred bool converter.""" + action = _get_param_action(_func_store_false_action) + assert isinstance(action, argparse._StoreFalseAction) + assert action.type is None + assert action.default is True + + def test_annotated_action_append(self) -> None: + """``action='append'`` collects repeated flag values into a list.""" + action = _get_param_action(_func_append_action) + assert isinstance(action, argparse._AppendAction) + assert action.option_strings == ["--tag"] + @pytest.mark.parametrize( "func", [ @@ -420,6 +492,20 @@ def test_inferred_enum_choices_match_type_converter(self) -> None: for choice in action.choices: assert isinstance(converter(str(choice)), _Color) + @pytest.mark.parametrize( + "func", + [ + pytest.param(_func_path, id="path_positional"), + pytest.param(_func_path_option, id="path_option"), + pytest.param(_func_list_path, id="list_path"), + pytest.param(_func_tuple_paths, id="tuple_paths"), + ], + ) + def test_path_annotation_wires_path_completer(self, func) -> None: + """A bare ``Path`` annotation (no user metadata) auto-wires ``Cmd.path_complete``.""" + action = _get_param_action(func) + assert action.get_completer() is cmd2.Cmd.path_complete # type: ignore[attr-defined] + # --------------------------------------------------------------------------- # Argument groups and mutually exclusive groups @@ -988,6 +1074,50 @@ def test_non_list_passthrough(self) -> None: assert ns.items == "single_value" +class TestCollectionRuntimeCast: + """End-to-end verify ``parse_args`` returns the declared container type, not a plain list.""" + + def test_set_int_returns_set(self) -> None: + parser = build_parser_from_function(_func_set_int) + ns = parser.parse_args(["1", "2", "2", "3"]) + assert isinstance(ns.nums, set) + assert ns.nums == {1, 2, 3} + + def test_tuple_ellipsis_returns_tuple(self) -> None: + parser = build_parser_from_function(_func_tuple_ellipsis) + ns = parser.parse_args(["1", "2", "3"]) + assert isinstance(ns.values, tuple) + assert ns.values == (1, 2, 3) + + def test_tuple_fixed_returns_tuple(self) -> None: + parser = build_parser_from_function(_func_tuple_fixed) + ns = parser.parse_args(["5", "10"]) + assert isinstance(ns.pair, tuple) + assert ns.pair == (5, 10) + + def test_list_bool_returns_list_of_bools(self) -> None: + parser = build_parser_from_function(_func_list_bool) + ns = parser.parse_args(["true", "no", "on"]) + assert ns.flags == [True, False, True] + + def test_tuple_paths_returns_tuple_of_paths(self) -> None: + parser = build_parser_from_function(_func_tuple_paths) + ns = parser.parse_args(["/tmp/a", "/tmp/b"]) + assert isinstance(ns.src_dst, tuple) + assert ns.src_dst == (Path("/tmp/a"), Path("/tmp/b")) + + def test_append_action_collects_values(self) -> None: + parser = build_parser_from_function(_func_append_action) + ns = parser.parse_args(["--tag", "a", "--tag", "b"]) + assert ns.tag == ["a", "b"] + + def test_int_subclass_uses_int_converter(self) -> None: + """``Port(int)`` falls back to ``int`` converter; argparse returns ``int``, not ``Port``.""" + parser = build_parser_from_function(_func_int_subclass) + ns = parser.parse_args(["8080"]) + assert ns.port == 8080 + + # --------------------------------------------------------------------------- # _filtered_namespace_kwargs edge cases # --------------------------------------------------------------------------- From c0bb4bbd1b0be3c5c1db172f599b2281b9d54637 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Thu, 21 May 2026 22:54:52 +0100 Subject: [PATCH 18/25] chore: remove collection type doc and sort --- cmd2/annotated.py | 6 +++--- docs/features/annotated.md | 30 +++++++++++++++--------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 53cf7690d..c1a931c91 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -495,13 +495,13 @@ def _resolve_enum(tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> dict[str, Any Path: _resolve_path, # Exact-match entries (order among these doesn't affect subclass lookup). bool: _resolve_bool, - int: _make_simple_resolver(int), - float: _make_simple_resolver(float), decimal.Decimal: _make_simple_resolver(decimal.Decimal), + float: _make_simple_resolver(float), + int: _make_simple_resolver(int), + Literal: _resolve_literal, list: _make_collection_resolver(list), set: _make_collection_resolver(set), tuple: _resolve_tuple, - Literal: _resolve_literal, } diff --git a/docs/features/annotated.md b/docs/features/annotated.md index 67ed15ee4..af638d43a 100644 --- a/docs/features/annotated.md +++ b/docs/features/annotated.md @@ -78,25 +78,25 @@ them as keyword arguments. The decorator converts Python type annotations into `add_argument()` calls: -| Type annotation | Generated argparse setting | -| -------------------------------------------------------- | ---------------------------------------------------------- | -| `str` | default (no `type=` needed) | -| `int`, `float` | `type=int` or `type=float` | -| `bool` with a default | boolean optional flag via `BooleanOptionalAction` | -| positional `bool` | parsed from `true/false`, `yes/no`, `on/off`, `1/0` | -| `Path` | `type=Path` | -| `Enum` subclass | `type=converter`, `choices` from member values | -| `decimal.Decimal` | `type=decimal.Decimal` | -| `Literal[...]` | `type=literal-converter`, `choices` from values | -| `Collection[T]` / `list[T]` / `set[T]` / `tuple[T, ...]` | `nargs='+'` (or `'*'` if it has a default or is `\| None`) | -| `tuple[T, T]` | fixed `nargs=N` with `type=T` | -| `T \| None` (no default) | positional with `nargs='?'` (accepts 0-or-1 tokens) | -| `T \| None = None` | `--flag` option with `default=None` | +| Type annotation | Generated argparse setting | +| -------------------------------------- | ---------------------------------------------------------- | +| `str` | default (no `type=` needed) | +| `int`, `float` | `type=int` or `type=float` | +| `bool` with a default | boolean optional flag via `BooleanOptionalAction` | +| positional `bool` | parsed from `true/false`, `yes/no`, `on/off`, `1/0` | +| `Path` | `type=Path` | +| `Enum` subclass | `type=converter`, `choices` from member values | +| `decimal.Decimal` | `type=decimal.Decimal` | +| `Literal[...]` | `type=literal-converter`, `choices` from values | +| `list[T]` / `set[T]` / `tuple[T, ...]` | `nargs='+'` (or `'*'` if it has a default or is `\| None`) | +| `tuple[T, T]` | fixed `nargs=N` with `type=T` | +| `T \| None` (no default) | positional with `nargs='?'` (accepts 0-or-1 tokens) | +| `T \| None = None` | `--flag` option with `default=None` | When collection types are used with `@with_annotated`, parsed values are passed to the command function as: -- `list[T]` and `Collection[T]` as `list` +- `list[T]` as `list` - `set[T]` as `set` - `tuple[T, ...]` as `tuple` From 3746e62923201882cdc15f925006d01c0ff25969 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Fri, 22 May 2026 00:32:49 +0100 Subject: [PATCH 19/25] chore: address comment and update test --- cmd2/annotated.py | 20 ++++-- docs/features/annotated.md | 13 +++- tests/test_annotated.py | 133 ++++++++++++++++++++++++++++++------- 3 files changed, 133 insertions(+), 33 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index c1a931c91..f8f5d6870 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -318,7 +318,6 @@ def _convert(value: str) -> Any: raise argparse.ArgumentTypeError(f"invalid choice: {value!r} (choose from {valid})") _convert.__name__ = "literal" - _convert._cmd2_strict_choice_converter = True # type: ignore[attr-defined] return _convert @@ -341,7 +340,6 @@ def _convert(value: str) -> enum.Enum: _convert.__name__ = enum_class.__name__ _convert._cmd2_enum_class = enum_class # type: ignore[attr-defined] - _convert._cmd2_strict_choice_converter = True # type: ignore[attr-defined] return _convert @@ -596,15 +594,23 @@ def _resolve_type( if is_kw_only and not has_default: kwargs["required"] = True - # An optional positional scalar (``T | None`` without a default) takes 0-or-1 tokens. - if is_optional and is_positional and "nargs" not in kwargs and not kwargs.get("is_collection"): + # An optional positional scalar takes 0-or-1 tokens. This covers both ``T | None`` + # (no default) and a positional given an explicit default; without ``nargs='?'`` + # argparse would still require the latter, contradicting its default value. + if (is_optional or has_default) and is_positional and "nargs" not in kwargs and not kwargs.get("is_collection"): kwargs["nargs"] = "?" + if is_positional and (is_optional or has_default) and isinstance(kwargs.get("nargs"), int): + raise TypeError( + f"A fixed-arity positional (nargs={kwargs['nargs']}) cannot be optional; argparse always " + f"requires it. Drop the default or '| None', make it an option (give it a default without " + f"Argument()), or use a variable-arity type such as tuple[T, ...]." + ) + + # A user-supplied completer/choices_provider drives completion, so drop the inferred + # static ``choices`` list. if kwargs.get("choices_provider") or kwargs.get("completer"): kwargs.pop("choices", None) - converter = kwargs.get("type") - if getattr(converter, "_cmd2_strict_choice_converter", False): - kwargs.pop("type", None) return base_type, kwargs diff --git a/docs/features/annotated.md b/docs/features/annotated.md index af638d43a..b32e9ff19 100644 --- a/docs/features/annotated.md +++ b/docs/features/annotated.md @@ -108,6 +108,11 @@ Unsupported patterns raise `TypeError`, including: - `Annotated[T, Argument(nargs=N)]` where `N` is `'*'`, `'+'`, or an integer `>= 1` and `T` is not a collection type. `nargs` values that produce a list of values need a collection annotation such as `list[T]` or `tuple[T, ...]`. +- an optional fixed-arity positional, such as `Annotated[tuple[int, int], Argument()] = (1, 2)`, + `Annotated[tuple[int, int] | None, Argument()]`, or any positional `Argument(nargs=N)` with a + default or `| None`. argparse cannot make a fixed-arity positional optional (there is no `nargs` + for "absent or exactly `N` tokens"), so use a variable-arity type like `tuple[T, ...]`, drop the + default, or make it an option (give it a default without `Argument()`). - `Annotated[tuple[T, T], Argument(nargs=N)]` where `N` differs from the number of elements declared by the tuple. The tuple type already pins `nargs`; user metadata cannot change it. @@ -155,9 +160,11 @@ When an `Option(action=...)` uses an argparse action that does not accept `type= inferred `type` converter before calling `add_argument()`. This matches argparse behavior and avoids parser-construction errors such as combining `action='count'` with `type=int`. -When a user-supplied `choices_provider` or `completer` overrides an inferred `Enum` or `Literal`, -the restrictive type converter is also dropped so the user-supplied values are not rejected at parse -time. The `Path` converter is permissive and is preserved when a custom completer is provided. +When a user-supplied `choices_provider` or `completer` is given for an inferred `Enum` or `Literal`, +the inferred static `choices` list is dropped so completion is driven by the provider or completer. +The inferred `type` converter is preserved, so parsed values still coerce to the declared type +(`Literal[1, 2]` yields an `int`, an `Enum` yields its member) and values outside the type are +rejected at parse time. ## Decorator options diff --git a/tests/test_annotated.py b/tests/test_annotated.py index 21235e53f..72e888414 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -106,10 +106,10 @@ def _func_literal_option(self, mode: Literal["fast", "slow"] = "fast") -> None: def _func_literal_int(self, level: Literal[1, 2, 3]) -> None: ... def _func_optional(self, name: str | None = None) -> None: ... def _func_optional_positional(self, val: Annotated[int | None, Argument()]) -> None: ... +def _func_positional_with_default(self, arg: Annotated[str, Argument()] = "foo") -> None: ... def _func_optional_plain(self, val: int | None) -> None: ... def _func_optional_list(self, vals: list[int] | None) -> None: ... def _func_optional_tuple_ellipsis(self, vals: tuple[int, ...] | None) -> None: ... -def _func_optional_explicit_nargs(self, vals: Annotated[tuple[int, ...] | None, Argument(nargs=2)]) -> None: ... def _func_list(self, files: list[str]) -> None: ... def _func_list_default(self, items: list[str] | None = None) -> None: ... def _func_set(self, tags: set[str]) -> None: ... @@ -243,6 +243,11 @@ class TestBuildParser: pytest.param( _func_optional_positional, {"option_strings": [], "nargs": "?", "type": int}, id="optional_positional" ), + pytest.param( + _func_positional_with_default, + {"option_strings": [], "nargs": "?", "default": "foo"}, + id="positional_with_default", + ), pytest.param(_func_optional_plain, {"option_strings": [], "nargs": "?", "type": int}, id="optional_plain"), pytest.param(_func_optional_list, {"option_strings": [], "nargs": "*", "type": int}, id="optional_list"), pytest.param( @@ -250,15 +255,14 @@ class TestBuildParser: {"option_strings": [], "nargs": "*", "type": int}, id="optional_tuple_ellipsis", ), - pytest.param( - _func_optional_explicit_nargs, - {"option_strings": [], "nargs": 2, "type": int}, - id="optional_explicit_nargs_overrides", - ), # --- Options --- pytest.param(_func_int_option, {"option_strings": ["--count"], "type": int, "default": 1}, id="int_option"), pytest.param(_func_float_option, {"option_strings": ["--rate"], "type": float, "default": 1.0}, id="float_option"), - pytest.param(_func_bool_false, {"option_strings": ["--verbose", "--no-verbose"]}, id="bool_optional_action"), + pytest.param( + _func_bool_false, + {"option_strings": ["--verbose", "--no-verbose"], "default": False}, + id="bool_optional_action", + ), pytest.param( _func_bool_true, {"option_strings": ["--debug", "--no-debug"], "default": True}, @@ -375,6 +379,28 @@ def test_annotated_action_append(self) -> None: assert isinstance(action, argparse._AppendAction) assert action.option_strings == ["--tag"] + def test_positional_with_default_is_optional(self) -> None: + """A positional with a default takes 0-or-1 tokens and falls back to the default when absent.""" + parser = build_parser_from_function(_func_positional_with_default) + assert parser.parse_args([]).arg == "foo" + assert parser.parse_args(["bar"]).arg == "bar" + + def test_str_default_on_int_option_coerced_at_parse(self) -> None: + """The decorator stores the default literally ('1', see ``default_not_coerced``); at parse + time argparse applies ``type=int`` to the string default, so an absent ``--count`` yields int 1. + """ + parser = build_parser_from_function(_func_default_type_mismatch) + assert parser.parse_args([]).count == 1 + assert parser.parse_args(["--count", "5"]).count == 5 + + def test_typing_optional_parses_end_to_end(self) -> None: + """typing.Optional[int] yields None when absent and coerces to int when provided.""" + parser = build_parser_from_function(_func_typing_optional) + assert parser.parse_args([]).count is None + parsed = parser.parse_args(["--count", "5"]).count + assert parsed == 5 + assert isinstance(parsed, int) + @pytest.mark.parametrize( "func", [ @@ -455,25 +481,64 @@ class TestTypeInferenceBuildParser: def test_choices_provider_overrides_inferred_enum_choices(self) -> None: action = _get_param_action(_func_choices_provider_on_enum) assert action.choices is None - assert action.get_choices_provider() is not None # type: ignore[attr-defined] + assert action.get_choices_provider() is _provider # type: ignore[attr-defined] assert action.get_completer() is None # type: ignore[attr-defined] - def test_choices_provider_strips_strict_enum_converter(self) -> None: - """User-supplied choices_provider on Enum drops the restrictive enum converter.""" + def test_choices_provider_keeps_enum_coercion(self) -> None: + """A choices_provider on an Enum keeps the converter so values still coerce to the member.""" action = _get_param_action(_func_choices_provider_on_enum) - assert action.type is None + assert action.type is not None + assert action.type("red") is _Color.red - def test_choices_provider_strips_strict_literal_converter(self) -> None: - """User-supplied choices_provider on Literal drops the restrictive literal converter.""" + def test_choices_provider_keeps_literal_coercion(self) -> None: + """A choices_provider on a Literal keeps the converter (coercion) but drops the static choices.""" def func( self, - mode: Annotated[Literal["fast", "slow"], Argument(choices_provider=_provider)], + level: Annotated[Literal[1, 2], Argument(choices_provider=_provider)], ) -> None: ... action = _get_param_action(func) - assert action.type is None assert action.choices is None + assert action.type is not None + assert action.type("1") == 1 + + def test_choices_provider_enum_coerces_at_parse(self) -> None: + """End-to-end: an Enum with a choices_provider still parses to the enum member, not a str.""" + parser = build_parser_from_function(_func_choices_provider_on_enum) + assert parser.parse_args(["red"]).color is _Color.red + + def test_choices_provider_literal_int_coerces_at_parse(self) -> None: + """End-to-end: a Literal[int] with a choices_provider parses to int, not a str.""" + + def func( + self, + level: Annotated[Literal[1, 2], Argument(choices_provider=_provider)], + ) -> None: ... + + parser = build_parser_from_function(func) + parsed = parser.parse_args(["1"]).level + assert parsed == 1 + assert isinstance(parsed, int) + + def test_bare_enum_literal_coerce_at_parse(self) -> None: + """Bare Enum/Literal positionals and options coerce to the declared type at parse time. + + Uses identity / isinstance (not ``==``) so a stripped converter returning a raw ``str`` + cannot hide behind StrEnum/IntEnum equality. + """ + assert build_parser_from_function(_func_literal).parse_args(["fast"]).mode == "fast" + assert build_parser_from_function(_func_literal_option).parse_args(["--mode", "slow"]).mode == "slow" + + level = build_parser_from_function(_func_literal_int).parse_args(["2"]).level + assert level == 2 + assert isinstance(level, int) + + assert build_parser_from_function(_func_enum).parse_args(["red"]).color is _Color.red + assert build_parser_from_function(_func_enum_option).parse_args(["--color", "red"]).color is _Color.red + # Non-StrEnum cases: identity defeats the StrEnum/IntEnum ``==`` masking property. + assert build_parser_from_function(_func_int_enum).parse_args(["1"]).color is _IntColor.red + assert build_parser_from_function(_func_plain_enum).parse_args(["red"]).color is _PlainColor.RED def test_completer_keeps_path_converter(self) -> None: """User-supplied completer on Path preserves the (non-restrictive) Path converter.""" @@ -875,6 +940,28 @@ def test_nargs_overrides_fixed_arity_raises(self, annotation) -> None: with pytest.raises(TypeError, match=r"conflicts with the fixed arity"): _resolve_annotation(annotation) + @pytest.mark.parametrize( + ("annotation", "resolve_kwargs"), + [ + pytest.param( + Annotated[tuple[int, int], Argument()], + {"has_default": True, "default": (1, 2)}, + id="fixed_tuple_with_default", + ), + pytest.param(Annotated[tuple[int, int] | None, Argument()], {}, id="fixed_tuple_optional"), + pytest.param( + Annotated[tuple[int, ...], Argument(nargs=2)], + {"has_default": True, "default": (1, 2)}, + id="explicit_nargs_with_default", + ), + pytest.param(Annotated[tuple[int, ...] | None, Argument(nargs=2)], {}, id="explicit_nargs_optional"), + ], + ) + def test_optional_fixed_arity_positional_raises(self, annotation, resolve_kwargs) -> None: + """argparse cannot make a fixed-arity positional optional, so the combination is rejected.""" + with pytest.raises(TypeError, match=r"fixed-arity positional"): + _resolve_annotation(annotation, **resolve_kwargs) + @pytest.mark.parametrize( "annotation", [ @@ -1202,7 +1289,7 @@ def do_add(self, a: int, b: int = 0) -> None: def do_paint( self, item: str, - color: Annotated[_Color, Option("--color", "-c", help_text="Color")] = _Color.blue, + color: Annotated[_Color, Option("--color", "-c", help_text="Color to use")] = _Color.blue, verbose: bool = False, ) -> None: msg = f"Painting {item} {color.value}" @@ -1263,7 +1350,7 @@ def test_help_shows_arguments(self, runtime_app) -> None: def test_help_shows_option_help(self, runtime_app) -> None: out, _ = run_cmd(runtime_app, "help paint") help_text = "\n".join(out) - assert "Color" in help_text or "color" in help_text + assert "Color to use" in help_text class TestRuntimeCompletion: @@ -1569,16 +1656,16 @@ def test_subcommand_executes(self, subcmd_app, command, expected) -> None: assert out == expected @pytest.mark.parametrize( - "command", + ("command", "expected_error"), [ - pytest.param("manage", id="missing_subcmd"), - pytest.param("manage delete", id="invalid_subcmd"), - pytest.param("manage member", id="missing_nested_subcmd"), + pytest.param("manage", "the following arguments are required: SUBCOMMAND", id="missing_subcmd"), + pytest.param("manage delete", "invalid choice: 'delete'", id="invalid_subcmd"), + pytest.param("manage member", "the following arguments are required: SUBCOMMAND", id="missing_nested_subcmd"), ], ) - def test_subcommand_errors(self, subcmd_app, command) -> None: + def test_subcommand_errors(self, subcmd_app, command, expected_error) -> None: _out, err = run_cmd(subcmd_app, command) - assert any("error" in line.lower() or "usage" in line.lower() or "invalid" in line.lower() for line in err) + assert any(expected_error in line for line in err), f"expected {expected_error!r} in {err}" def test_subcommand_help(self, subcmd_app) -> None: out, _err = run_cmd(subcmd_app, "help manage") From f0ad6d7b4ddc97d39e6138033bb45bde4dd688c4 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Fri, 22 May 2026 11:50:09 +0100 Subject: [PATCH 20/25] chore: update doc --- cmd2/annotated.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index f8f5d6870..fc7526a4c 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -84,10 +84,10 @@ def do_paint( Note: ``Path`` and ``Enum`` annotations with ``@with_annotated`` also get automatic tab completion via generated parser metadata. If a user-supplied ``choices_provider`` or ``completer`` is set on an argument, -it always takes priority over the type-inferred completion. For ``Enum`` and -``Literal``, the restrictive type converter is also stripped so user-supplied -values are not rejected at parse time. The ``Path`` converter is permissive -and is preserved when a custom completer is provided. +it drives completion in place of the inferred static ``choices``. The inferred +``type`` converter is kept, so values still coerce to the declared type (an +``Enum`` to its member, ``Literal[1, 2]`` to ``int``) and values outside the +declared type are rejected at parse time. The parameter name ``cmd2_handler`` is reserved for base commands declared with ``with_annotated(base_command=True)`` and may not be used elsewhere. From faa242087e9e58d31a681100ffa47def3bc18f1d Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Fri, 22 May 2026 16:54:30 +0100 Subject: [PATCH 21/25] feat: better *args and **kwargs handling, with extend test --- cmd2/annotated.py | 150 ++++++++++++++++++++++++---- examples/annotated_example.py | 20 ++++ tests/test_annotated.py | 182 +++++++++++++++++++++++++++++++++- 3 files changed, 330 insertions(+), 22 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index fc7526a4c..fcd65a586 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -13,10 +13,14 @@ Basic usage -- parameters without defaults become positional arguments, parameters with defaults become ``--option`` flags. Keyword-only parameters (after ``*``) always become options; without a default they -are required. Underscores in parameter names are auto-converted to +are required. A ``*args`` parameter becomes a variadic positional that +accepts zero or more values (``nargs='*'``), collected into a tuple. +Underscores in parameter names are auto-converted to dashes in the generated flag (``dry_run`` -> ``--dry-run``); pass explicit names via ``Option("--my_flag")`` to opt out. The parameter -name ``dest`` is reserved and cannot be used:: +name ``dest`` is reserved and cannot be used. Positional-only +parameters (before ``/``) and ``**kwargs`` are not supported and raise +``TypeError``:: class MyApp(cmd2.Cmd): @cmd2.with_annotated @@ -56,6 +60,8 @@ def do_paint( - ``Literal[...]`` -- sets ``type=converter`` and ``choices`` from literal values - ``list[T]`` / ``set[T]`` / ``tuple[T, ...]`` -- ``nargs='+'`` (or ``'*'`` if has a default or is ``| None``) - ``tuple[T, T]`` (fixed arity, same type) -- ``nargs=N`` with ``type=T`` +- ``*args: T`` -- variadic positional with ``nargs='*'`` (zero or more), collected into a tuple. + ``T`` is the type of each value (a scalar), not the collected tuple - ``T | None`` (no default) -- positional with ``nargs='?'`` (accepts 0-or-1 tokens) - ``T | None = None`` -- ``--flag`` option with ``default=None`` @@ -68,9 +74,19 @@ def do_paint( Unsupported patterns (raise ``TypeError``): +- A scalar type with no converter (e.g. ``datetime.datetime``, ``uuid.UUID``, + ``bytes``, or any custom class). Without a converter the command-line value + would silently arrive as a plain string, so it is rejected. Supported scalar + types are ``str``, ``int``, ``float``, ``bool``, ``decimal.Decimal``, + ``pathlib.Path``, ``enum.Enum`` subclasses, and ``Literal[...]`` (or a subclass + of one). ``str``/``Any``/``object`` and unannotated parameters pass through as + raw strings. - ``str | int`` -- union of multiple non-None types is ambiguous - ``tuple[int, str, float]`` -- mixed element types are not currently supported because argparse can only apply a single ``type=`` converter per argument +- ``*args: tuple[T, ...]`` (or ``*args: list[T]`` / any collection element) -- on ``*args`` + the annotation is the type of each value, so a collection element would mean a + tuple-of-collections. Annotate the element type instead, e.g. ``*args: str`` - ``Annotated[T, Argument(nargs=N)]`` where ``N`` produces a list (``'*'``, ``'+'``, or integer ``>= 1``) and ``T`` is not a collection type. Use ``list[T]`` or ``tuple[T, ...]`` to match the runtime shape. @@ -511,12 +527,26 @@ def _type_name(tp: Any) -> str: return tp.__name__ if hasattr(tp, "__name__") else str(tp) +#: Scalar annotations that argparse stores as the raw string (no converter needed). +_PASSTHROUGH_TYPES = frozenset({str, object, Any, inspect.Parameter.empty}) + + +def _is_passthrough_type(tp: Any) -> bool: + """Return ``True`` for types stored as a raw string without a dedicated converter. + + Covers ``str`` / ``Any`` / ``object`` / unannotated parameters, and any parametrized + generic we do not specialize (e.g. ``frozenset[T]``, ``dict[K, V]``, ``Sequence[T]``), + which keep their existing scalar-passthrough behavior. + """ + return tp in _PASSTHROUGH_TYPES or get_origin(tp) is not None + + def _nargs_yields_list(nargs: Any) -> bool: """Return ``True`` when an argparse ``nargs`` value produces a list at parse time. ``nargs=1`` is included: argparse returns ``[value]``, not the bare value. """ - return nargs in ("*", "+") or (isinstance(nargs, int) and nargs >= 1) + return nargs in ("*", "+", argparse.REMAINDER) or (isinstance(nargs, int) and nargs >= 1) def _resolve_type( @@ -558,9 +588,16 @@ def _resolve_type( if resolver is not None: kwargs = resolver(tp, args, **ctx) base_type = kwargs.pop("base_type", tp) - else: + elif _is_passthrough_type(tp): base_type = tp kwargs = {} + else: + raise TypeError( + f"Unsupported parameter type {_type_name(tp)!r} for @with_annotated: there is no converter " + f"for it, so command-line values would silently arrive as plain strings. Supported scalar types " + f"are str, int, float, bool, decimal.Decimal, pathlib.Path, enum.Enum subclasses, and Literal[...]; " + f"use one of these (optionally in list/set/tuple) or a subclass of one." + ) resolver_nargs = kwargs.get("nargs") @@ -674,26 +711,31 @@ def _normalize_annotation(annotation: type) -> _NormalizedAnnotation: def _resolve_annotation( - annotation: type, + annotation: Any, *, has_default: bool = False, default: Any = None, is_kw_only: bool = False, + is_variadic: bool = False, ) -> tuple[dict[str, Any], ArgMetadata, bool]: """Decompose a type annotation into ``(type_kwargs, metadata, is_positional)``. Peels ``Annotated`` then ``Optional``. The only supported way to combine ``Annotated`` with ``Optional`` is ``Annotated[T | None, meta]``. Writing ``Annotated[T, meta] | None`` is ambiguous and raises ``TypeError``. + + ``is_variadic`` marks a ``*args`` parameter: it is always positional and + accepts zero or more values (``nargs='*'``). """ tp, metadata, is_optional = _normalize_annotation(annotation) - is_positional = isinstance(metadata, Argument) or (metadata is None and not has_default and not is_kw_only) + # ``*args`` is always a positional that accepts zero or more values. + is_positional = is_variadic or isinstance(metadata, Argument) or (metadata is None and not has_default and not is_kw_only) tp, type_kwargs = _resolve_type( tp, is_positional=is_positional, - is_optional=is_optional, + is_optional=is_optional or is_variadic, has_default=has_default, default=default, metadata=metadata, @@ -779,23 +821,51 @@ def _resolve_parameters( "which is not supported by @with_annotated because parameters are passed as keyword arguments." ) + if param.kind == inspect.Parameter.VAR_KEYWORD: + raise TypeError( + f"Parameter '**{name}' in {func.__qualname__} is variadic keyword (**kwargs), " + "which is not supported by @with_annotated because there is no native way to map " + "command-line arguments onto arbitrary keyword names." + ) + if name in _RESERVED_PARAM_NAMES: raise ValueError( f"Parameter name {name!r} in {func.__qualname__} is reserved by argparse " f"and cannot be used as an annotated parameter name." ) - annotation = hints.get(name, param.annotation) - has_default = param.default is not inspect.Parameter.empty - default = param.default if has_default else None - is_kw_only = param.kind == inspect.Parameter.KEYWORD_ONLY - - kwargs, metadata, positional = _resolve_annotation( - annotation, - has_default=has_default, - default=default, - is_kw_only=is_kw_only, - ) + if param.kind == inspect.Parameter.VAR_POSITIONAL: + # ``*args: T`` is a variadic positional: zero or more values (nargs='*') + # collected into a tuple. The hint gives the element type T (the type of + # each value), so annotating *args with a collection -- e.g. + # ``*args: tuple[str, ...]`` -- would mean each value is itself a tuple + # (a tuple-of-tuples), which cannot be mapped onto a flat command line. + element = hints.get(name, str) + _, element_kwargs = _resolve_type(element, is_positional=True) + if element_kwargs.get("is_collection"): + # Show the parametrized form (e.g. ``tuple[str, ...]``), not the bare origin. + element_display = str(element) if get_origin(element) is not None else _type_name(element) + raise TypeError( + f"Parameter '*{name}' in {func.__qualname__} is annotated with the collection type " + f"'{element_display}'. For *args the annotation is the type of each value, not the " + f"collected tuple, so '*{name}: {element_display}' would mean a tuple of " + f"'{element_display}'. Annotate the element type instead " + f"(e.g. '*{name}: str'); values are always collected into a tuple." + ) + variadic_annotation = types.GenericAlias(tuple, (element, ...)) + kwargs, metadata, positional = _resolve_annotation(variadic_annotation, is_variadic=True) + else: + annotation = hints.get(name, param.annotation) + has_default = param.default is not inspect.Parameter.empty + default = param.default if has_default else None + is_kw_only = param.kind == inspect.Parameter.KEYWORD_ONLY + + kwargs, metadata, positional = _resolve_annotation( + annotation, + has_default=has_default, + default=default, + is_kw_only=is_kw_only, + ) if positional: flags: list[str] = [] @@ -810,6 +880,40 @@ def _resolve_parameters( return resolved +def _var_positional_call_plan(func: Callable[..., Any]) -> tuple[list[str], str | None]: + """Return ``(leading_positional_names, var_positional_name)`` for unpacking ``*args``. + + ``leading_positional_names`` are the positional-or-keyword parameters that + precede ``*args`` (they must be passed positionally, in order, so ``*args`` + can follow). ``var_positional_name`` is the ``*args`` parameter name, or + ``None`` when the function has no ``*args``. + """ + params = list(inspect.signature(func).parameters.values())[1:] # skip self/cls + leading: list[str] = [] + for param in params: + if param.kind is inspect.Parameter.VAR_POSITIONAL: + return leading, param.name + if param.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD: + leading.append(param.name) + return leading, None + + +def _invoke_command_func( + func: Callable[..., Any], + self_arg: Any, + func_kwargs: dict[str, Any], + *, + leading_names: list[str], + var_positional_name: str | None, +) -> Any: + """Call *func* from parsed kwargs, unpacking ``*args`` positionally when present.""" + if var_positional_name is None: + return func(self_arg, **func_kwargs) + positional = [func_kwargs.pop(name) for name in leading_names if name in func_kwargs] + var_values = func_kwargs.pop(var_positional_name, None) or () + return func(self_arg, *positional, *var_values, **func_kwargs) + + def _filtered_namespace_kwargs( ns: argparse.Namespace, *, @@ -1018,12 +1122,15 @@ def build_subcommand_handler( _validate_base_command_params(func) _accepted = set(list(inspect.signature(func).parameters.keys())[1:]) + _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) - return func(self_arg, **filtered) + return _invoke_command_func( + func, self_arg, filtered, leading_names=_leading_names, var_positional_name=_var_positional_name + ) def parser_builder() -> Cmd2ArgumentParser: parser = build_parser_from_function( @@ -1179,6 +1286,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:]) + leading_names, var_positional_name = _var_positional_call_plan(fn) def parser_builder() -> Cmd2ArgumentParser: parser = build_parser_from_function( @@ -1234,7 +1342,9 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: func_kwargs["_unknown"] = unknown func_kwargs.update(kwargs) - result: bool | None = fn(owner, **func_kwargs) + result: bool | None = _invoke_command_func( + fn, owner, func_kwargs, leading_names=leading_names, var_positional_name=var_positional_name + ) return result setattr(cmd_wrapper, constants.CMD_ATTR_PARSER_SOURCE, parser_builder) diff --git a/examples/annotated_example.py b/examples/annotated_example.py index 6adae6a36..eb0945cb7 100755 --- a/examples/annotated_example.py +++ b/examples/annotated_example.py @@ -175,6 +175,26 @@ def do_sum(self, numbers: list[float]) -> None: """ self.poutput(f"{' + '.join(str(n) for n in numbers)} = {sum(numbers)}") + # -- Variadic positional (*args) ----------------------------------------- + # ``*args: T`` becomes a variadic positional (nargs='*') collected into a + # tuple -- zero or more values. A keyword-only option after ``*args`` stays + # an ordinary ``--flag``. + + @with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_cat(self, *files: str, number: bool = False) -> None: + """Concatenate file names. ``*args`` accepts zero or more values. + + Try: + cat a.txt b.txt c.txt + cat a.txt b.txt --number + cat + """ + if not files: + self.poutput("(no files)") + for index, name in enumerate(files, start=1): + self.poutput(f"{index}: {name}" if number else name) + # -- Literal + Decimal --------------------------------------------------- # Literal values become validated choices. Decimal values preserve precision. diff --git a/tests/test_annotated.py b/tests/test_annotated.py index 72e888414..1f0f314dc 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -6,11 +6,14 @@ """ import argparse +import datetime import decimal import enum +import uuid from pathlib import Path from typing import ( Annotated, + Any, Literal, Optional, ) @@ -90,6 +93,7 @@ class _Port(int): # --------------------------------------------------------------------------- +def _func_empty(self) -> None: ... def _func_str(self, name: str) -> None: ... def _func_int_option(self, count: int = 1) -> None: ... def _func_float_option(self, rate: float = 1.0) -> None: ... @@ -126,6 +130,13 @@ def _func_annotated_action_non_bool(self, count: Annotated[int, Option("--count" def _func_annotated_required(self, name: Annotated[str, Option("--name", required=True)]) -> None: ... def _func_annotated_required_auto_flag(self, name: Annotated[str, Option(required=True)]) -> None: ... def _func_annotated_choices(self, food: Annotated[str, Argument(choices=["a", "b"])]) -> None: ... +def _func_star_args(self, *args: str) -> None: ... +def _func_star_args_int(self, *args: int) -> None: ... +def _func_star_args_bare(self, *args) -> None: ... # type: ignore[no-untyped-def] +def _func_star_args_tuple(self, *files: tuple[str, ...]) -> None: ... +def _func_star_args_list(self, *xs: list[str]) -> None: ... +def _func_star_args_bare_list(self, *xs: list) -> None: ... # type: ignore[type-arg] +def _func_var_keyword(self, name: str, **kwargs: str) -> None: ... def _func_dest_param(self, dest: str) -> None: ... def _func_kw_only(self, *, name: str) -> None: ... def _func_kw_only_with_default(self, *, name: str = "world") -> None: ... @@ -164,6 +175,8 @@ def _func_grouped( def _func_positional_only(self, name: str, /) -> None: ... +def _func_positional_only_xy(self, x: str, /, y: int) -> None: ... +def _func_positional_only_mixed(self, x: str, /, y: int, *, z: int = 0) -> None: ... def _provider(cmd: cmd2.Cmd): @@ -293,6 +306,8 @@ class TestBuildParser: id="annotated_required_auto_flag", ), pytest.param(_func_annotated_choices, {"option_strings": [], "choices": ["a", "b"]}, id="annotated_choices"), + pytest.param(_func_star_args, {"option_strings": [], "type": None, "nargs": "*"}, id="star_args"), + pytest.param(_func_star_args_int, {"option_strings": [], "type": int, "nargs": "*"}, id="star_args_int"), # --- Keyword-only --- pytest.param(_func_kw_only, {"option_strings": ["--name"], "required": True}, id="kw_only_required"), pytest.param(_func_kw_only_with_default, {"option_strings": ["--name"], "default": "world"}, id="kw_only_default"), @@ -406,12 +421,28 @@ def test_typing_optional_parses_end_to_end(self) -> None: [ pytest.param(_func_set, id="set"), pytest.param(_func_tuple_ellipsis, id="tuple"), + pytest.param(_func_star_args, id="star_args"), ], ) def test_collection_uses_casting_action(self, func) -> None: action = _get_param_action(func) assert isinstance(action, _CollectionCastingAction) + def test_star_args_bare_defaults_to_str(self) -> None: + """A bare ``*args`` (no element annotation) is treated as ``*args: str``.""" + action = _get_param_action(_func_star_args_bare) + assert action.option_strings == [] + assert action.nargs == "*" + assert action.type is None + + def test_star_args_parses_to_tuple(self) -> None: + """``*args: int`` accepts zero or more values, coerced and collected into a tuple.""" + parser = build_parser_from_function(_func_star_args_int) + assert parser.parse_args([]).args == () + parsed = parser.parse_args(["1", "2", "3"]).args + assert parsed == (1, 2, 3) + assert isinstance(parsed, tuple) + def test_self_skipped(self) -> None: parser = build_parser_from_function(_func_str) dests = {a.dest for a in parser._actions} @@ -426,6 +457,13 @@ def bare() -> None: ... dests = {a.dest for a in parser._actions if a.dest != "help"} assert dests == set() + def test_self_only_method_produces_empty_parser(self) -> None: + """A method whose only parameter is ``self`` produces a parser with no actions.""" + parser = build_parser_from_function(_func_empty) + dests = {a.dest for a in parser._actions if a.dest != "help"} + assert dests == set() + assert parser.parse_args([]) == argparse.Namespace() + def test_get_type_hints_failure_raises(self) -> None: def do_broken(self, name: "NonExistentType"): # noqa: F821 pass @@ -457,6 +495,37 @@ def test_with_annotated_positional_only_param_raises(self) -> None: with pytest.raises(TypeError, match="positional-only"): build_parser_from_function(_func_positional_only) + def test_with_annotated_positional_only_two_params_raises(self) -> None: + with pytest.raises(TypeError, match="positional-only"): + build_parser_from_function(_func_positional_only_xy) + + def test_with_annotated_positional_only_mixed_params_raises(self) -> None: + with pytest.raises(TypeError, match="positional-only"): + build_parser_from_function(_func_positional_only_mixed) + + def test_var_keyword_raises(self) -> None: + """``**kwargs`` cannot be mapped to command-line arguments and is rejected.""" + with pytest.raises(TypeError, match=r"variadic keyword"): + build_parser_from_function(_func_var_keyword) + + @pytest.mark.parametrize( + "func", + [ + pytest.param(_func_star_args_tuple, id="tuple[str, ...]"), + pytest.param(_func_star_args_list, id="list[str]"), + pytest.param(_func_star_args_bare_list, id="bare_list"), + ], + ) + def test_star_args_collection_element_raises(self, func) -> None: + """``*args`` annotated with a collection element is rejected with a targeted hint. + + The annotation on ``*args`` is the type of each value, so a collection element + (e.g. ``*files: tuple[str, ...]``) would mean a tuple-of-collections, which cannot + be parsed. The error must steer the user toward annotating the element type. + """ + with pytest.raises(TypeError, match=r"the type of each value"): + build_parser_from_function(func) + def test_optional_annotated_outside_raises(self) -> None: with pytest.raises(TypeError, match="Annotated"): build_parser_from_function(_func_optional_annotated_outside) @@ -986,6 +1055,42 @@ def test_unsupported_collection_no_nargs(self, annotation) -> None: assert "nargs" not in kwargs assert "action" not in kwargs + @pytest.mark.parametrize( + "annotation", + [ + pytest.param(datetime.datetime, id="datetime"), + pytest.param(datetime.date, id="date"), + pytest.param(uuid.UUID, id="uuid"), + pytest.param(bytes, id="bytes"), + pytest.param(complex, id="complex"), + ], + ) + def test_unsupported_scalar_type_raises(self, annotation) -> None: + """A type with no converter must not silently arrive as a plain string.""" + with pytest.raises(TypeError, match="Unsupported parameter type"): + _resolve_annotation(annotation) + + def test_unsupported_custom_class_raises(self) -> None: + class Money: + def __init__(self, raw: str) -> None: + self.raw = raw + + with pytest.raises(TypeError, match="Unsupported parameter type"): + _resolve_annotation(Money) + + @pytest.mark.parametrize( + "annotation", + [ + pytest.param(str, id="str"), + pytest.param(Any, id="any"), + pytest.param(object, id="object"), + ], + ) + def test_passthrough_scalar_types_keep_no_converter(self, annotation) -> None: + """str / Any / object are stored as the raw string (type stays None).""" + kwargs, _, _ = _resolve_annotation(annotation) + assert "type" not in kwargs + @pytest.mark.parametrize( "annotation", [ @@ -1073,9 +1178,18 @@ class TestLiteralConverter: def test_convert(self, values, input_val, expected) -> None: assert _make_literal_type(values)(input_val) == expected - def test_invalid(self) -> None: + @pytest.mark.parametrize( + ("values", "input_val"), + [ + pytest.param(["fast", "slow"], "medium", id="non_bool_string"), + # bool-like input ("yes"/"on") must still be rejected when the Literal has no bool member + pytest.param(["fast", "slow"], "yes", id="bool_like_no_bool_member"), + pytest.param([1, 2], "on", id="bool_like_int_literal"), + ], + ) + def test_invalid(self, values, input_val) -> None: with pytest.raises(argparse.ArgumentTypeError, match="invalid choice"): - _make_literal_type(["fast", "slow"])("medium") + _make_literal_type(values)(input_val) def test_direct_match_before_bool_coercion(self) -> None: assert _make_literal_type(["yes", "no"])("yes") == "yes" @@ -1317,6 +1431,23 @@ def do_toggle(self, enabled: bool) -> None: def do_raw(self, text: str) -> None: self.poutput(f"raw: {text}") + @with_annotated + def do_echo_all(self, *words: str) -> None: + self.poutput(f"words={list(words)}") + + @with_annotated + def do_total(self, label: str, *nums: int) -> None: + self.poutput(f"{label}={sum(nums)}") + + @with_annotated + def do_cat(self, *files: str, upper: bool = False) -> None: + joined = " ".join(files) + self.poutput(joined.upper() if upper else joined) + + @with_annotated + def do_ping(self) -> None: + self.poutput("pong") + @pytest.fixture def runtime_app() -> _RuntimeAnnotatedApp: @@ -1337,12 +1468,24 @@ class TestRuntimeExecution: pytest.param("paint wall --color red", ["Painting wall red"], id="paint_color"), pytest.param("paint wall --verbose", ["Painting wall blue (verbose)"], id="paint_verbose"), pytest.param("sport football", ["Playing: football"], id="sport_enum"), + pytest.param("echo_all a b c", ["words=['a', 'b', 'c']"], id="star_args_values"), + pytest.param("echo_all", ["words=[]"], id="star_args_empty"), + pytest.param("total score 1 2 3", ["score=6"], id="leading_plus_star_args"), + pytest.param("total score", ["score=0"], id="leading_plus_empty_star_args"), + pytest.param("cat a b c", ["a b c"], id="star_args_with_kwonly_opt"), + pytest.param("cat a b c --upper", ["A B C"], id="star_args_with_kwonly_opt_set"), + pytest.param("ping", ["pong"], id="no_args_command"), ], ) def test_command_execution(self, runtime_app, command, expected) -> None: out, _err = run_cmd(runtime_app, command) assert out == expected + def test_no_args_command_rejects_extra_args(self, runtime_app) -> None: + """A no-parameter command accepts no positionals and errors on extras.""" + _out, err = run_cmd(runtime_app, "ping extra") + assert any("unrecognized" in line.lower() or "error" in line.lower() for line in err) + def test_help_shows_arguments(self, runtime_app) -> None: out, _ = run_cmd(runtime_app, "help greet") assert "name" in "\n".join(out).lower() @@ -1559,6 +1702,35 @@ def test_direct_call_kwargs_override_parsed(self, app) -> None: out = app.stdout.getvalue().splitlines() assert out[-3:] == ["Hello Alice", "Hello Alice", "Hello Alice"] + def test_direct_call_no_arg_command(self) -> None: + """A no-parameter command parses an (empty) statement string on a direct call.""" + + class App(cmd2.Cmd): + @with_annotated + def do_ping(self) -> None: + self.poutput("pong") + + app = App() + app.stdout = cmd2.utils.StdSim(app.stdout) + app.do_ping("") + assert app.stdout.getvalue().splitlines()[-1] == "pong" + + def test_direct_call_star_args_command(self) -> None: + """A *args command parses a statement string positionally on a direct call.""" + + class App(cmd2.Cmd): + @with_annotated + def do_cat(self, *files: str, upper: bool = False) -> None: + joined = "|".join(files) + self.poutput(joined.upper() if upper else joined) + + app = App() + app.stdout = cmd2.utils.StdSim(app.stdout) + app.do_cat("a b c") + assert app.stdout.getvalue().splitlines()[-1] == "a|b|c" + app.do_cat("a b --upper") + assert app.stdout.getvalue().splitlines()[-1] == "A|B" + def test_bare_call_decorator(self) -> None: """@with_annotated() with empty parens works same as @with_annotated.""" @@ -1619,6 +1791,10 @@ def do_manage(self, cmd2_handler, verbose: bool = False) -> None: def manage_add(self, value: str) -> None: self.poutput(f"added: {value}") + @with_annotated(subcommand_to="manage", help="sum values") + def manage_sum(self, *nums: int) -> None: + self.poutput(f"sum: {sum(nums)}") + @with_annotated(subcommand_to="manage", help="list things", aliases=["ls"]) def manage_list(self) -> None: self.poutput("listing all") @@ -1649,6 +1825,8 @@ class TestSubcommands: pytest.param("manage list", ["listing all"], id="list"), pytest.param("manage ls", ["listing all"], id="list_alias"), pytest.param("manage member add Alice", ["member added: Alice"], id="nested_3_levels"), + pytest.param("manage sum 1 2 3", ["sum: 6"], id="subcommand_star_args"), + pytest.param("manage sum", ["sum: 0"], id="subcommand_star_args_empty"), ], ) def test_subcommand_executes(self, subcmd_app, command, expected) -> None: From c087b8b8357d378aefd69bc318ade21df3007c7a Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Fri, 22 May 2026 17:34:53 +0100 Subject: [PATCH 22/25] chore: more *args edge case handling --- cmd2/annotated.py | 51 ++++++++++++++++++++++++++++++++++------- tests/test_annotated.py | 43 ++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 8 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index fcd65a586..d9955caaf 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -61,7 +61,8 @@ def do_paint( - ``list[T]`` / ``set[T]`` / ``tuple[T, ...]`` -- ``nargs='+'`` (or ``'*'`` if has a default or is ``| None``) - ``tuple[T, T]`` (fixed arity, same type) -- ``nargs=N`` with ``type=T`` - ``*args: T`` -- variadic positional with ``nargs='*'`` (zero or more), collected into a tuple. - ``T`` is the type of each value (a scalar), not the collected tuple + ``T`` is the type of each value (a scalar), not the collected tuple. + ``Annotated[T, Argument(...)]`` metadata (help text, metavar, choices) is honored - ``T | None`` (no default) -- positional with ``nargs='?'`` (accepts 0-or-1 tokens) - ``T | None = None`` -- ``--flag`` option with ``default=None`` @@ -87,6 +88,12 @@ def do_paint( - ``*args: tuple[T, ...]`` (or ``*args: list[T]`` / any collection element) -- on ``*args`` the annotation is the type of each value, so a collection element would mean a tuple-of-collections. Annotate the element type instead, e.g. ``*args: str`` +- ``*args: Annotated[T, Option(...)]`` -- ``*args`` is always positional, so ``Option()`` + metadata is rejected; use ``Argument()`` instead +- ``*args: Annotated[T, Argument(nargs=N)]`` -- ``*args`` arity is fixed to ``nargs='*'`` + and cannot be overridden +- a keyword-only parameter (after ``*``) annotated with ``Argument()`` metadata -- ``Argument()`` + marks a positional, which contradicts keyword-only; use ``Option()`` or omit the metadata - ``Annotated[T, Argument(nargs=N)]`` where ``N`` produces a list (``'*'``, ``'+'``, or integer ``>= 1``) and ``T`` is not a collection type. Use ``list[T]`` or ``tuple[T, ...]`` to match the runtime shape. @@ -550,7 +557,7 @@ def _nargs_yields_list(nargs: Any) -> bool: def _resolve_type( - tp: type, + tp: Any, *, is_positional: bool = False, is_optional: bool = False, @@ -837,10 +844,24 @@ def _resolve_parameters( if param.kind == inspect.Parameter.VAR_POSITIONAL: # ``*args: T`` is a variadic positional: zero or more values (nargs='*') # collected into a tuple. The hint gives the element type T (the type of - # each value), so annotating *args with a collection -- e.g. - # ``*args: tuple[str, ...]`` -- would mean each value is itself a tuple - # (a tuple-of-tuples), which cannot be mapped onto a flat command line. - element = hints.get(name, str) + # each value). Peel any Annotated/Optional so we see the real element + # type and any Argument() metadata (help text, metavar, choices, ...). + element, metadata, _element_optional = _normalize_annotation(hints.get(name, str)) + + if isinstance(metadata, Option): + raise TypeError( + f"Parameter '*{name}' in {func.__qualname__} uses Option() metadata, but *args is " + f"always a positional argument. Use Argument() metadata instead." + ) + if isinstance(metadata, Argument) and metadata.nargs is not None: + raise TypeError( + f"Parameter '*{name}' in {func.__qualname__} sets nargs={metadata.nargs!r} via Argument(), " + f"but *args always accepts zero or more values (nargs='*') and its arity cannot be overridden." + ) + + # Annotating *args with a collection -- e.g. ``*args: tuple[str, ...]`` -- would + # mean each value is itself a tuple (a tuple-of-collections), which cannot be + # mapped onto a flat command line. _, element_kwargs = _resolve_type(element, is_positional=True) if element_kwargs.get("is_collection"): # Show the parametrized form (e.g. ``tuple[str, ...]``), not the bare origin. @@ -852,8 +873,15 @@ def _resolve_parameters( f"'{element_display}'. Annotate the element type instead " f"(e.g. '*{name}: str'); values are always collected into a tuple." ) - variadic_annotation = types.GenericAlias(tuple, (element, ...)) - kwargs, metadata, positional = _resolve_annotation(variadic_annotation, is_variadic=True) + + # Each value has type ``element``; values are collected into a tuple (nargs='*'). + # ``is_optional=True`` selects nargs='*' (zero or more); any Argument() metadata + # (help text, metavar, choices) is applied to the variadic positional. + variadic_tuple = types.GenericAlias(tuple, (element, ...)) + _, kwargs = _resolve_type(variadic_tuple, is_positional=True, is_optional=True, metadata=metadata) + kwargs.pop("is_collection", None) + kwargs.pop("base_type", None) + positional = True else: annotation = hints.get(name, param.annotation) has_default = param.default is not inspect.Parameter.empty @@ -867,6 +895,13 @@ def _resolve_parameters( is_kw_only=is_kw_only, ) + if is_kw_only and isinstance(metadata, Argument): + raise TypeError( + f"Parameter '{name}' in {func.__qualname__} is keyword-only but uses Argument() metadata, " + f"which marks it as a positional argument. Keyword-only parameters always become options; " + f"use Option() metadata (or omit the metadata) instead." + ) + if positional: flags: list[str] = [] else: diff --git a/tests/test_annotated.py b/tests/test_annotated.py index 1f0f314dc..d53e5f234 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -136,6 +136,12 @@ def _func_star_args_bare(self, *args) -> None: ... # type: ignore[no-untyped-de def _func_star_args_tuple(self, *files: tuple[str, ...]) -> None: ... def _func_star_args_list(self, *xs: list[str]) -> None: ... def _func_star_args_bare_list(self, *xs: list) -> None: ... # type: ignore[type-arg] +def _func_star_args_meta(self, *files: Annotated[str, Argument(help_text="a file", metavar="FILE")]) -> None: ... +def _func_star_args_meta_choices(self, *modes: Annotated[str, Argument(choices=["a", "b"])]) -> None: ... +def _func_star_args_option_meta(self, *files: Annotated[str, Option("--files")]) -> None: ... +def _func_star_args_nargs_meta(self, *files: Annotated[str, Argument(nargs=2)]) -> None: ... +def _func_kw_only_argument(self, *, name: Annotated[str, Argument()]) -> None: ... +def _func_kw_only_argument_default(self, *, name: Annotated[str, Argument()] = "x") -> None: ... def _func_var_keyword(self, name: str, **kwargs: str) -> None: ... def _func_dest_param(self, dest: str) -> None: ... def _func_kw_only(self, *, name: str) -> None: ... @@ -526,6 +532,43 @@ def test_star_args_collection_element_raises(self, func) -> None: with pytest.raises(TypeError, match=r"the type of each value"): build_parser_from_function(func) + def test_star_args_honors_argument_metadata(self) -> None: + """``Annotated[T, Argument(...)]`` on ``*args`` applies help/metavar to the variadic positional.""" + action = _get_param_action(_func_star_args_meta) + assert action.option_strings == [] + assert action.nargs == "*" + assert action.help == "a file" + assert action.metavar == "FILE" + + def test_star_args_honors_argument_choices(self) -> None: + """``Argument(choices=...)`` on ``*args`` restricts every value to the choices.""" + parser = build_parser_from_function(_func_star_args_meta_choices) + assert parser.parse_args(["a", "b", "a"]).modes == ("a", "b", "a") + with pytest.raises(SystemExit): + parser.parse_args(["a", "nope"]) + + def test_star_args_option_metadata_raises(self) -> None: + """``Option()`` on ``*args`` is rejected; *args is always positional.""" + with pytest.raises(TypeError, match=r"\*args is always a positional"): + build_parser_from_function(_func_star_args_option_meta) + + def test_star_args_nargs_metadata_raises(self) -> None: + """An explicit ``nargs`` on ``*args`` is rejected; its arity is fixed to ``'*'``.""" + with pytest.raises(TypeError, match=r"arity cannot be overridden"): + build_parser_from_function(_func_star_args_nargs_meta) + + @pytest.mark.parametrize( + "func", + [ + pytest.param(_func_kw_only_argument, id="no_default"), + pytest.param(_func_kw_only_argument_default, id="with_default"), + ], + ) + def test_kw_only_with_argument_metadata_raises(self, func) -> None: + """A keyword-only parameter cannot use ``Argument()`` (which marks a positional).""" + with pytest.raises(TypeError, match=r"keyword-only but uses Argument\(\)"): + build_parser_from_function(func) + def test_optional_annotated_outside_raises(self) -> None: with pytest.raises(TypeError, match="Annotated"): build_parser_from_function(_func_optional_annotated_outside) From 5ffbdac6e6eda44d929a9e8711a86b4d4f256998 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Fri, 29 May 2026 13:08:18 +0100 Subject: [PATCH 23/25] chore: large scale refactor --- cmd2/annotated.py | 2144 +++++++++++++++++++++++++-------- docs/features/annotated.md | 186 ++- examples/annotated_example.py | 155 ++- tests/test_annotated.py | 1893 +++++++++++++++++++++++++---- 4 files changed, 3558 insertions(+), 820 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index d9955caaf..fcd86843e 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -4,23 +4,21 @@ This module is experimental and its behavior may change in future releases. -This module provides the :func:`with_annotated` decorator that inspects a -command function's type hints and default values to automatically construct -a ``Cmd2ArgumentParser``. It also provides :class:`Argument` and -:class:`Option` metadata classes for use with ``typing.Annotated`` when -finer control is needed. - -Basic usage -- parameters without defaults become positional arguments, -parameters with defaults become ``--option`` flags. Keyword-only -parameters (after ``*``) always become options; without a default they -are required. A ``*args`` parameter becomes a variadic positional that -accepts zero or more values (``nargs='*'``), collected into a tuple. -Underscores in parameter names are auto-converted to -dashes in the generated flag (``dry_run`` -> ``--dry-run``); pass -explicit names via ``Option("--my_flag")`` to opt out. The parameter -name ``dest`` is reserved and cannot be used. Positional-only -parameters (before ``/``) and ``**kwargs`` are not supported and raise -``TypeError``:: +The :func:`with_annotated` decorator inspects a command function's type hints and +default values to build a ``Cmd2ArgumentParser``. :class:`Argument` and +:class:`Option` metadata classes give finer per-parameter control via +``typing.Annotated``. + +Parameters without defaults become positional arguments; parameters with defaults +become ``--option`` flags; keyword-only parameters (after ``*``) are always options. +A ``bool`` option is a flag, not a value: when absent it means ``False`` (or ``None`` +for ``bool | None``), so it defaults to that and is never ``required``. A ``*args`` +parameter becomes a variadic positional accepting zero or more values (``nargs='*'``), +collected into a tuple. Underscores in a parameter name become dashes in the generated +flag (``dry_run`` -> ``--dry-run``); pass an explicit ``Option("--my_flag")`` to opt out. +Positional-only parameters (before ``/``) and ``**kwargs`` raise ``TypeError``. The parameter +names ``dest`` and ``subcommand`` are reserved; ``cmd2_statement`` receives the parsed +``Statement`` and (with ``base_command=True``) ``cmd2_handler`` receives the subcommand handler:: class MyApp(cmd2.Cmd): @cmd2.with_annotated @@ -51,69 +49,131 @@ def do_paint( How annotations map to argparse settings: - ``str`` -- default string argument -- ``int``, ``float`` -- sets ``type=`` for argparse -- ``bool`` with default -- ``--flag / --no-flag`` via ``BooleanOptionalAction`` +- ``int``, ``float`` -- sets ``type=`` +- ``bool`` option -- ``--flag / --no-flag`` via ``BooleanOptionalAction``; defaults to + ``False`` (or ``None`` for ``bool | None``) when omitted, so it is never ``required`` - positional ``bool`` -- parsed from ``true/false``, ``yes/no``, ``on/off``, ``1/0`` - ``pathlib.Path`` -- sets ``type=Path`` - ``enum.Enum`` subclass -- ``type=converter``, ``choices`` from member values - ``decimal.Decimal`` -- sets ``type=Decimal`` -- ``Literal[...]`` -- sets ``type=converter`` and ``choices`` from literal values -- ``list[T]`` / ``set[T]`` / ``tuple[T, ...]`` -- ``nargs='+'`` (or ``'*'`` if has a default or is ``| None``) +- ``Literal[...]`` -- ``type=converter`` and ``choices`` from the literal values +- ``list[T]`` / ``set[T]`` / ``tuple[T, ...]`` -- ``nargs='+'`` (or ``'*'`` with a default or ``| None``) - ``tuple[T, T]`` (fixed arity, same type) -- ``nargs=N`` with ``type=T`` -- ``*args: T`` -- variadic positional with ``nargs='*'`` (zero or more), collected into a tuple. - ``T`` is the type of each value (a scalar), not the collected tuple. - ``Annotated[T, Argument(...)]`` metadata (help text, metavar, choices) is honored -- ``T | None`` (no default) -- positional with ``nargs='?'`` (accepts 0-or-1 tokens) +- ``*args: T`` -- variadic positional (``nargs='*'``); ``T`` is each value's type, not the + collected tuple. ``Annotated[T, Argument(...)]`` metadata is honored +- ``T | None`` (no default) -- positional with ``nargs='?'`` (0-or-1 tokens) - ``T | None = None`` -- ``--flag`` option with ``default=None`` -Action compatibility note: - -- Some argparse actions (``count``, ``store_true``, ``store_false``, - ``store_const``, ``help``, ``version``) do not accept ``type=``. - If one of these actions is selected via ``Option(action=...)``, any - inferred ``type`` converter is removed before calling ``add_argument()``. +A value option with no default is made ``required`` (omitting it would pass ``None``, +violating a non-Optional hint); annotate it ``T | None`` or give it a default to make it +omittable. + +Explicit ``Option(action=...)`` is type-checked so the parsed result matches the +declared type: + +- ``store_true`` / ``store_false`` -- require a ``bool`` parameter (``type=`` is dropped; + argparse supplies the ``False``/``True`` default) +- ``count`` -- requires an ``int`` parameter; defaults to ``0`` (``None`` for ``int | None``) +- ``append`` / ``extend`` -- require a ``list[T]`` parameter and default to ``[]`` + (``append`` takes one value per flag; ``extend`` takes ``nargs`` values per flag) +- ``store_const`` / ``append_const`` -- store the ``Option(const=...)`` value (``type=`` is dropped). + The action is inferred from the type when ``action=`` is omitted: a scalar ``Option(const=X)`` becomes + ``store_const`` (present -> ``const``, absent -> the default, which must exist or be ``T | None``); a + ``list[T]`` ``Option(const=X)`` becomes ``append_const`` (each flag appends ``const``; defaults to ``[]``). + A scalar ``Option(const=X)`` given an explicit ``nargs`` (e.g. ``nargs='?'``) instead keeps the ``store`` + action for argparse's optional-value idiom (absent -> default, bare flag -> ``const``, ``flag VALUE`` -> + converted ``VALUE``); the ``const`` is stored verbatim and must match the declared type. + ``const`` is validated against the declared type and is rejected on a positional ``Argument`` (argparse + ignores it there) +- a custom :class:`argparse.Action` subclass -- passed straight through to ``add_argument``. + The user's class owns storage, so the collection-casting wrapper is dropped and the action-specific + type/const/collection-shape constraints are skipped. The type-inferred converter, default, and + ``required`` are still applied; the action receives them like any hand-built ``add_argument`` call. + ``action='help'`` and ``action='version'`` are not supported. + +The zero-argument actions above (``store_true`` / ``store_false`` / ``count`` / ``store_const`` / +``append_const``) take no value from the command line, so the value-oriented metadata inferred from +the type is dropped before ``add_argument`` is called: the ``type=`` converter, the static +``choices``, and any inferred tab-completer (e.g. the path completer for ``Path``). There is nothing +to complete or convert on a value-less action. A completer that was only *inferred* from the type is +dropped silently, but a ``completer`` / ``choices_provider`` you supply *explicitly* on such an action +is a contradiction and raises ``TypeError`` (matching argparse, which rejects it outright). Actions +that *do* consume values (``append`` / ``extend`` on a ``list[T]``, or a plain value option) keep the +inferred converter and completer unchanged. + +The metadata classes refuse a handful of ``add_argument`` kwargs that the decorator derives from the +signature itself, so passing them through ``Argument(...)`` / ``Option(...)`` raises ``TypeError``: +``type`` (from the annotation), ``dest`` (from the parameter name), ``help`` (use the ``help_text`` +parameter, which maps to it -- a raw ``help`` would silently shadow it), and -- on ``Argument`` only +-- ``action`` / ``required`` (which have no meaning on a positional). Every other ``add_argument`` +parameter, including those registered via +:func:`~cmd2.argparse_utils.register_argparse_argument_parameter`, passes through unchanged. + +A ``default`` may be supplied either as the function-signature default (``param: T = v``) or as +``Argument(default=v)`` / ``Option(default=v)`` -- the two forms are equivalent. Specifying both at +once raises ``TypeError`` (the value would have two sources of truth), and ``argparse.SUPPRESS`` is +rejected as a default from either source because it would remove the keyword argument the function +expects. + +Parser-level customization is forwarded to :class:`~cmd2.Cmd2ArgumentParser`'s constructor via PEP +692 ``**parser_kwargs: Unpack[Cmd2ParserKwargs]``. Anything the parser ctor accepts -- ``description``, +``epilog``, ``prog``, ``usage``, ``parents``, ``argument_default``, ``prefix_chars``, +``fromfile_prefix_chars``, ``conflict_handler``, ``add_help``, ``allow_abbrev``, ``exit_on_error``, +``formatter_class``, ``ap_completer_type``, and on Python >= 3.14 ``suggest_on_error`` / ``color`` -- +flows straight through; the :class:`Cmd2ParserKwargs` ``TypedDict`` is the single source of truth +and gives type-checkers/IDEs autocomplete on the decorator's call site. ``parser_class`` stays as +its own explicit kwarg because it selects the class itself, not a value passed to it. Two +behaviors layer on top of the raw passthrough: if ``description`` is omitted, the first paragraph +of ``func.__doc__`` (up to the first blank line) is used so docstrings double as help text without +leaking ``:param:`` directives; and ``prog`` is rejected with ``subcommand_to`` because cmd2 +rewrites it from the parent command's hierarchy. Mutually exclusive groups accept +``Group(required=True)`` to require exactly one member; the same flag on a plain ``groups=`` entry +raises ``ValueError`` (argparse's ``add_argument_group`` has no ``required``). Unsupported patterns (raise ``TypeError``): -- A scalar type with no converter (e.g. ``datetime.datetime``, ``uuid.UUID``, - ``bytes``, or any custom class). Without a converter the command-line value - would silently arrive as a plain string, so it is rejected. Supported scalar - types are ``str``, ``int``, ``float``, ``bool``, ``decimal.Decimal``, - ``pathlib.Path``, ``enum.Enum`` subclasses, and ``Literal[...]`` (or a subclass - of one). ``str``/``Any``/``object`` and unannotated parameters pass through as - raw strings. -- ``str | int`` -- union of multiple non-None types is ambiguous -- ``tuple[int, str, float]`` -- mixed element types are not currently supported - because argparse can only apply a single ``type=`` converter per argument -- ``*args: tuple[T, ...]`` (or ``*args: list[T]`` / any collection element) -- on ``*args`` - the annotation is the type of each value, so a collection element would mean a - tuple-of-collections. Annotate the element type instead, e.g. ``*args: str`` -- ``*args: Annotated[T, Option(...)]`` -- ``*args`` is always positional, so ``Option()`` - metadata is rejected; use ``Argument()`` instead +- a non-Optional type with a ``None`` default (e.g. ``name: str = None``); annotate it + ``T | None`` or use a non-None default. ``Any``/``object``/unannotated are exempt +- a scalar type with no converter (e.g. ``datetime.datetime``, ``uuid.UUID``, ``bytes``, + or any custom class), which would silently arrive as a plain string. Supported scalars + are ``str``, ``int``, ``float``, ``bool``, ``decimal.Decimal``, ``pathlib.Path``, + ``enum.Enum`` subclasses, and ``Literal[...]`` (``str``/``Any``/``object`` pass through raw) +- ``str | int`` -- a union of multiple non-None types is ambiguous +- ``tuple[int, str, float]`` -- mixed element types (argparse applies one ``type=`` per argument) +- ``*args: tuple[T, ...]`` (or any collection element) -- the annotation is each value's type, + so a collection element means a tuple-of-collections; annotate the element, e.g. ``*args: str`` +- ``*args: Annotated[T, Option(...)]`` -- ``*args`` is always positional; use ``Argument()`` - ``*args: Annotated[T, Argument(nargs=N)]`` -- ``*args`` arity is fixed to ``nargs='*'`` - and cannot be overridden -- a keyword-only parameter (after ``*``) annotated with ``Argument()`` metadata -- ``Argument()`` - marks a positional, which contradicts keyword-only; use ``Option()`` or omit the metadata -- ``Annotated[T, Argument(nargs=N)]`` where ``N`` produces a list (``'*'``, ``'+'``, - or integer ``>= 1``) and ``T`` is not a collection type. Use ``list[T]`` or - ``tuple[T, ...]`` to match the runtime shape. -- ``Annotated[tuple[T, T], Argument(nargs=N)]`` where ``N`` differs from the number of - elements declared by the tuple type. The tuple already pins ``nargs``. - -When combining ``Annotated`` with ``Optional``, the union must go -*inside*: ``Annotated[T | None, meta]``. Writing -``Annotated[T, meta] | None`` is ambiguous and raises ``TypeError``. - -Note: ``Path`` and ``Enum`` annotations with ``@with_annotated`` also get -automatic tab completion via generated parser metadata. -If a user-supplied ``choices_provider`` or ``completer`` is set on an argument, -it drives completion in place of the inferred static ``choices``. The inferred -``type`` converter is kept, so values still coerce to the declared type (an -``Enum`` to its member, ``Literal[1, 2]`` to ``int``) and values outside the -declared type are rejected at parse time. - -The parameter name ``cmd2_handler`` is reserved for base commands declared with -``with_annotated(base_command=True)`` and may not be used elsewhere. +- a keyword-only parameter annotated with ``Argument()`` -- it marks a positional; use ``Option()`` +- a required option (no default, not ``T | None``) in a ``mutually_exclusive_groups`` group -- + only one member is supplied, so the others arrive as ``None``; give it a default or ``T | None`` +- ``Annotated[T, Argument(nargs=N)]`` producing a list (``'*'``, ``'+'``, integer ``>= 1``) + on a non-collection ``T``; use ``list[T]`` or ``tuple[T, ...]`` to match the runtime shape +- ``Annotated[tuple[T, T], Argument(nargs=N)]`` where ``N`` differs from the tuple's arity +- ``Option(action=...)`` whose result type mismatches the declared type, an unsupported action, + or a non-list action on a collection (use ``append``/``extend``/``append_const`` with ``list[T]``) +- a variable-arity positional (``T | None``, ``list[T]``, ``tuple[T, ...]``) followed by another + positional -- it must come last (``def f(self, a: str, *rest: str)`` is fine) + +When combining ``Annotated`` with ``Optional``, the union should go *inside*: +``Annotated[T | None, meta]``. ``Annotated[T, meta] | None`` is ambiguous and raises -- unless the +inner type already carries the ``None`` (``Annotated[T | None, meta] | None``), in which case the +redundant outer ``| None`` is accepted as equivalent to ``Annotated[T | None, meta]``. + +``Path`` and ``Enum`` annotations also get automatic tab completion. A user-supplied +``choices_provider`` or ``completer`` drives completion in place of the inferred static +``choices``, while the inferred ``type`` converter is kept so values still coerce to the +declared type (an ``Enum`` to its member, ``Literal[1, 2]`` to ``int``) and out-of-type +values are rejected at parse time. An ``Enum`` accepts both member values and member names on the +command line (completion and ``--help`` show the values). + +An explicit ``choices=`` is reconciled with the inferred type rather than fighting it: its values are +run through the inferred ``type`` converter so they match argparse's post-conversion comparison +(``Annotated[int, Option('--n', choices=['1', '2'])]`` becomes ``choices=[1, 2]``, so ``--n 1`` +matches; a value the converter rejects is a build-time ``TypeError``), and an explicit ``choices=`` +takes precedence over a *type-inferred* completer (the ``Path`` completer is dropped so the choices +drive both validation and completion). A ``choices_provider`` / ``completer`` you supply yourself +still wins over ``choices=``. """ import argparse @@ -122,32 +182,84 @@ def do_paint( import functools import inspect import types -from collections.abc import Callable, Container, Sequence +from collections.abc import Callable, Container, Iterable, Sequence +from dataclasses import dataclass, field from pathlib import Path from typing import ( + TYPE_CHECKING, Annotated, Any, ClassVar, Literal, + TypedDict, + TypeVar, Union, + Unpack, get_args, get_origin, get_type_hints, overload, ) +from rich.table import Column + from . import constants -from .argparse_utils import Cmd2ArgumentParser, SubcommandSpec +from .argparse_utils import DEFAULT_ARGUMENT_PARSER, Cmd2ArgumentParser, SubcommandSpec from .completion import CompletionItem from .decorators import _parse_positionals from .exceptions import Cmd2ArgparseError -from .rich_utils import Cmd2HelpFormatter +from .rich_utils import Cmd2HelpFormatter, HelpContent from .types import CmdOrSetT, UnboundChoicesProvider, UnboundCompleter +if TYPE_CHECKING: + from .argparse_completer import ArgparseCompleter + +#: ``nargs`` values accepted by cmd2's patched ``add_argument`` (incl. ranged tuples). +_NargsValue = int | str | tuple[int] | tuple[int, int] | tuple[int, float] + + +class Cmd2ParserKwargs(TypedDict, total=False): + """Forwarded ctor kwargs for :class:`~cmd2.Cmd2ArgumentParser`. + + Single source of truth for the parser-construction kwargs accepted by + :func:`with_annotated` and :func:`build_parser_from_function` via PEP 692 + ``Unpack[Cmd2ParserKwargs]``. Adding a new ctor kwarg to + :class:`~cmd2.Cmd2ArgumentParser` only needs a matching field here -- the + decorator picks it up automatically and IDEs/type-checkers surface it on + the call site. + + All fields are optional (``total=False``); omit a key to use argparse's + default. ``suggest_on_error`` and ``color`` only take effect on + Python >= 3.14, where :class:`~cmd2.Cmd2ArgumentParser` forwards them to + the stdlib parent. + """ + + prog: str | None + usage: str | None + description: HelpContent | None + epilog: HelpContent | None + parents: Sequence[argparse.ArgumentParser] + formatter_class: type[Cmd2HelpFormatter] + prefix_chars: str + fromfile_prefix_chars: str | None + argument_default: Any + conflict_handler: str + add_help: bool + allow_abbrev: bool + exit_on_error: bool + suggest_on_error: bool + color: bool + ap_completer_type: "type[ArgparseCompleter] | None" + + # --------------------------------------------------------------------------- # Metadata classes # --------------------------------------------------------------------------- +#: Sentinel marking "no value assigned" (a builder slot, or an unset ``const``). +#: Defined here so the metadata classes can use it as a default before the builder section. +_UNSET: Any = object() + class _BaseArgMetadata: """Shared fields for ``Argument`` and ``Option`` metadata.""" @@ -163,19 +275,76 @@ class _BaseArgMetadata: "nargs": "nargs", } + #: ``add_argument`` kwargs that ``@with_annotated`` derives from the function signature + #: itself (or exposes under a different name), so the metadata classes refuse to accept them + #: as ``extra_kwargs``: setting them there would silently disagree with (or be overridden by) + #: the inferred value. ``type`` comes from the annotation, ``dest`` from the parameter name, + #: and ``action``/``required`` are the named ``Option`` arguments and have no meaning on a + #: positional ``Argument``. ``help`` is exposed as the ``help_text`` parameter; accepting a + #: raw ``help`` too would silently shadow it (``to_kwargs`` lets ``extra_kwargs`` win). + #: ``default`` is accepted as a named parameter (see :meth:`__init__`) and reconciled with + #: the signature default; it must not appear in ``extra_kwargs`` as well. + _RESERVED_EXTRA_KWARGS: ClassVar[frozenset[str]] = frozenset( + { + "type", + "dest", + "action", + "required", + "help", + } + ) + def __init__( self, *, help_text: str | None = None, - metavar: str | None = None, - nargs: int | str | tuple[int, ...] | None = None, - choices: list[Any] | None = None, + metavar: str | tuple[str, ...] | None = None, + nargs: _NargsValue | None = None, + choices: Iterable[Any] | None = None, choices_provider: UnboundChoicesProvider[CmdOrSetT] | None = None, completer: UnboundCompleter[CmdOrSetT] | None = None, - table_columns: tuple[str, ...] | None = None, + table_columns: Sequence[str | Column] | None = None, suppress_tab_hint: bool | None = None, + const: Any = _UNSET, + default: Any = _UNSET, + **extra_kwargs: Any, ) -> None: - """Initialise shared metadata fields.""" + """Initialise shared metadata fields. + + ``const`` is the value stored when a flag is present without an argument; on an + :class:`Option` it selects ``store_const`` (scalar type) or ``append_const`` + (``list[T]``). It is meaningless for a positional :class:`Argument` (argparse + ignores it there) and is rejected when the parser is built. Left as :data:`_UNSET` + when not given, so an explicit ``const=None`` is distinct from "no const". + + ``default`` is the value the parser stores when the argument is absent; it is + equivalent to writing the same default in the function signature + (``Annotated[T, Option('--x', default=v)]`` is the same as + ``Annotated[T, Option('--x')] = v``). Specifying both the signature default and + the metadata default is a conflict and rejected; :data:`argparse.SUPPRESS` is also + rejected because it removes the kwarg the function expects. + + ``extra_kwargs`` forwards any ``add_argument`` parameter not named above -- in + particular custom parameters registered via + :func:`~cmd2.argparse_utils.register_argparse_argument_parameter`. They pass + straight through to ``add_argument`` (which validates them: an unknown keyword + raises ``TypeError`` when the parser is built), giving parity with a hand-built parser. + """ + reserved = self._RESERVED_EXTRA_KWARGS & extra_kwargs.keys() + if reserved: + name = sorted(reserved)[0] + # Per-key remediation hint for the reserved kwarg. + hint = { + "type": "The converter is derived from the parameter annotation; change the annotation instead.", + "dest": "The dest is the parameter name; rename the parameter instead.", + "action": "Use Option(action=...) (only Option supports an action; Argument is always positional).", + "required": ( + "Use Option(required=True); a positional Argument is always required unless it has " + "a default or is annotated as `T | None`." + ), + "help": "Use the help_text= parameter instead; it maps to argparse's help= and would otherwise be shadowed.", + }[name] + raise TypeError(f"{type(self).__name__}({name}=...) is not accepted by @with_annotated. {hint}") self.help_text = help_text self.metavar = metavar self.nargs = nargs @@ -184,10 +353,17 @@ def __init__( self.completer = completer self.table_columns = table_columns self.suppress_tab_hint = suppress_tab_hint + self.const = const + self.default = default + self.extra_kwargs = extra_kwargs def to_kwargs(self) -> dict[str, Any]: - """Return non-None fields as an argparse kwargs dict.""" - return {kwarg: val for attr, kwarg in self._KWARGS_MAP.items() if (val := getattr(self, attr)) is not None} + """Return non-None mapped fields, an explicit ``const``, and any passthrough ``extra_kwargs``.""" + kwargs = {kwarg: val for attr, kwarg in self._KWARGS_MAP.items() if (val := getattr(self, attr)) is not None} + if self.const is not _UNSET: + kwargs["const"] = self.const + kwargs.update(self.extra_kwargs) + return kwargs class Argument(_BaseArgMetadata): @@ -207,11 +383,19 @@ class Option(_BaseArgMetadata): When omitted, the decorator auto-generates ``--param-name`` (underscores in the parameter name are converted to dashes). + Pass ``const=`` to store a fixed value when the flag is present: on a scalar parameter this + selects ``store_const`` (present -> ``const``, absent -> the default), on a ``list[T]`` it + selects ``append_const`` (each flag appends ``const``). A scalar ``const=`` paired with an + explicit ``nargs`` (e.g. ``nargs='?'``) instead keeps the ``store`` action for argparse's + optional-value idiom (bare flag -> ``const``, ``flag VALUE`` -> the value). ``action=`` may + still be given explicitly; otherwise it is inferred from the type. + Example:: def do_paint( self, color: Annotated[str, Option("--color", "-c", help_text="Color")] = "blue", + verbose: Annotated[int, Option("-v", const=2)] = 0, ): ... """ @@ -219,11 +403,18 @@ def do_paint( def __init__( self, *names: str, - action: str | None = None, + action: str | type[argparse.Action] | None = None, required: bool = False, **kwargs: Any, ) -> None: - """Initialise Option metadata.""" + """Initialise Option metadata. + + ``action`` may be a string (one of the supported actions: ``store_true``, ``store_false``, + ``count``, ``append``, ``extend``, ``store_const``, ``append_const``) or a custom + :class:`argparse.Action` subclass. A custom class is passed straight through to + ``add_argument`` and the user's class owns the storage behaviour; the type-inferred + action, container factory, and the action-specific constraint checks are skipped. + """ super().__init__(**kwargs) self.names = names self.action = action @@ -254,18 +445,29 @@ class Group: def do_connect(self, host: str, port: int = 22): ... """ - def __init__(self, *members: str, title: str | None = None, description: str | None = None) -> None: + def __init__( + self, + *members: str, + title: str | None = None, + description: str | None = None, + required: bool = False, + ) -> None: """Initialise an argument group definition. :param members: parameter names to place in the group (at least one) :param title: optional group title shown as a section header in help :param description: optional group description shown under the title + :param required: only meaningful for ``mutually_exclusive_groups``; when + ``True`` argparse requires exactly one member to be supplied. + Setting this on a group passed to ``groups=`` raises ``ValueError`` + (argparse's ``add_argument_group`` has no ``required`` flag). """ if not members: raise ValueError("Group requires at least one member parameter 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.""" @@ -278,23 +480,38 @@ def _validate_members(self, *, all_param_names: set[str], group_type: str) -> No ArgMetadata = Argument | Option | None _NormalizedAnnotation = tuple[Any, ArgMetadata, bool] -_ResolvedParam = tuple[str, ArgMetadata, bool, list[str], dict[str, Any]] _ArgumentTarget = argparse.ArgumentParser | argparse._MutuallyExclusiveGroup | argparse._ArgumentGroup +@dataclass +class _TypeResult: + """How a declared type maps onto argparse settings. + + Produced by ``_TYPE_TABLE`` entries and consumed by :meth:`_ArgparseArgument._apply_type`. + ``converter``/``choices``/``action``/``completer`` flow to argparse; + ``is_collection``/``fixed_arity`` are scratch the nargs table reads. + """ + + converter: Callable[[str], Any] | None = None + choices: Iterable[Any] | None = None + action: str | type[argparse.Action] | None = None + completer: Any = None + is_collection: bool = False + container_factory: Callable[[list[Any]], Any] | None = None + fixed_arity: int | None = None + + # --------------------------------------------------------------------------- # Type resolvers # --------------------------------------------------------------------------- # -# Each resolver: (tp, args, *, is_positional, has_default, default, metadata) -> dict -# The returned dict is merged into the argparse kwargs. -# Internal keys ('base_type', 'is_collection', 'is_bool_flag') are stripped -# before passing to argparse. +# Each resolver has the signature ``(tp, args, *, is_positional) -> _TypeResult`` and is +# registered in ``_TYPE_TABLE``. ``_resolve_base_type`` looks one up by type; ``_apply_type`` +# copies the resulting ``_TypeResult`` onto the builder's slots and context scratch. # --------------------------------------------------------------------------- _BOOL_TRUE_VALUES = ["1", "true", "t", "yes", "y", "on"] _BOOL_FALSE_VALUES = ["0", "false", "f", "no", "n", "off"] -_ACTIONS_DISALLOW_TYPE = frozenset({"count", "store_true", "store_false", "store_const", "help", "version"}) _BOOL_CHOICES = [CompletionItem(True, text=text) for text in _BOOL_TRUE_VALUES] + [ CompletionItem(False, text=text) for text in _BOOL_FALSE_VALUES ] @@ -389,91 +606,79 @@ def __call__( # -- Individual resolvers ----------------------------------------------------- -def _make_simple_resolver(converter: Callable[..., Any] | type) -> Callable[..., dict[str, Any]]: +def _make_simple_resolver(converter: Callable[..., Any] | type) -> Callable[..., _TypeResult]: """Create a resolver for types that just need ``type=converter``.""" - def _resolve(_tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> dict[str, Any]: - return {"type": converter} + def _resolve(_tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> _TypeResult: + return _TypeResult(converter=converter) return _resolve -def _resolve_path(_tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> dict[str, Any]: +def _resolve_path(_tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> _TypeResult: """Resolve Path and add completer.""" from .cmd2 import Cmd - return {"type": Path, "completer": Cmd.path_complete} + return _TypeResult(converter=Path, completer=Cmd.path_complete) -def _resolve_bool( - _tp: Any, - _args: tuple[Any, ...], - *, - is_positional: bool, - metadata: ArgMetadata, - **_ctx: Any, -) -> dict[str, Any]: - """Resolve bool -- flag or positional depending on context.""" +def _resolve_bool(_tp: Any, _args: tuple[Any, ...], *, is_positional: bool = False, **_ctx: Any) -> _TypeResult: + """Resolve bool: a positional gets a converter, an option a flag action. + + A user ``Option(action=...)`` overrides this later; here we set only the default option action. + """ if not is_positional: - action_str = getattr(metadata, "action", None) if metadata else None - if action_str: - return {"action": action_str} - return {"action": argparse.BooleanOptionalAction} - return {"type": _parse_bool, "choices": list(_BOOL_CHOICES)} + return _TypeResult(action=argparse.BooleanOptionalAction) + return _TypeResult(converter=_parse_bool, choices=list(_BOOL_CHOICES)) -def _resolve_element(tp: Any) -> tuple[Any, dict[str, Any]]: +def _resolve_element(tp: Any) -> _TypeResult: """Resolve a collection element type and reject nested collections.""" - element_type, inner = _resolve_type(tp, is_positional=True) - if inner.get("is_collection"): + inner = _resolve_base_type(tp, is_positional=True) + if inner.is_collection: raise TypeError("Nested collections are not supported") - return element_type, inner + return inner -def _make_collection_resolver(collection_type: type) -> Callable[..., dict[str, Any]]: +def _make_collection_resolver(collection_type: type) -> Callable[..., _TypeResult]: """Create a resolver for single-arg collections (list[T], set[T]).""" - def _resolve(_tp: Any, args: tuple[Any, ...], *, has_default: bool = False, **_ctx: Any) -> dict[str, Any]: - nargs = "*" if has_default else "+" + def _resolve(_tp: Any, args: tuple[Any, ...], **_ctx: Any) -> _TypeResult: if len(args) == 0: - # Bare list/tuple without type args -- treat as list[str]/set[str] - return { - "is_collection": True, - "nargs": nargs, - "base_type": str, - "action": _CollectionCastingAction, - "container_factory": collection_type, - } + # Bare list/set without type args -- treat as list[str]/set[str]. + return _TypeResult(is_collection=True, container_factory=collection_type) if len(args) != 1: raise TypeError( f"{collection_type.__name__}[...] with {len(args)} type arguments is not supported; " f"use {collection_type.__name__}[T] with a single element type." ) - element_type, inner = _resolve_element(args[0]) - return { - **inner, - "is_collection": True, - "nargs": nargs, - "base_type": element_type, - "action": _CollectionCastingAction, - "container_factory": collection_type, - } + element = _resolve_element(args[0]) + return _TypeResult( + converter=element.converter, + choices=element.choices, + completer=element.completer, + is_collection=True, + container_factory=collection_type, + ) return _resolve -def _resolve_tuple(_tp: Any, args: tuple[Any, ...], *, has_default: bool = False, **_ctx: Any) -> dict[str, Any]: - """Resolve tuple[T, ...] and tuple[T1, T2, ...].""" - cast_kwargs = {"action": _CollectionCastingAction, "container_factory": tuple} - - nargs = "*" if has_default else "+" +def _resolve_tuple(_tp: Any, args: tuple[Any, ...], **_ctx: Any) -> _TypeResult: + """Resolve tuple[T, ...] (variable) and tuple[T, T] (fixed arity).""" if not args: - # Bare tuple without type args -- treat as tuple[str, ...] - return {"is_collection": True, "nargs": nargs, "base_type": str, **cast_kwargs} + # Bare tuple without type args -- treat as tuple[str, ...]. + return _TypeResult(is_collection=True, container_factory=tuple) if len(args) == 2 and args[1] is Ellipsis: - element_type, inner = _resolve_element(args[0]) - return {**inner, "is_collection": True, "nargs": nargs, "base_type": element_type, **cast_kwargs} + element = _resolve_element(args[0]) + return _TypeResult( + converter=element.converter, + choices=element.choices, + completer=element.completer, + is_collection=True, + container_factory=tuple, + ) if Ellipsis not in args: first = args[0] @@ -484,8 +689,15 @@ def _resolve_tuple(_tp: Any, args: tuple[Any, ...], *, has_default: bool = False f"can only apply a single type= converter per argument. " f"Use tuple[T, T] (same type) or tuple[T, ...] instead." ) - _, inner = _resolve_element(first) - return {**inner, "is_collection": True, "nargs": len(args), "base_type": first, **cast_kwargs} + element = _resolve_element(first) + return _TypeResult( + converter=element.converter, + choices=element.choices, + completer=element.completer, + is_collection=True, + container_factory=tuple, + fixed_arity=len(args), + ) raise TypeError( "tuple with Ellipsis in an unexpected position is not supported; " @@ -493,23 +705,23 @@ def _resolve_tuple(_tp: Any, args: tuple[Any, ...], *, has_default: bool = False ) -def _resolve_literal(_tp: Any, args: tuple[Any, ...], **_ctx: Any) -> dict[str, Any]: +def _resolve_literal(_tp: Any, args: tuple[Any, ...], **_ctx: Any) -> _TypeResult: """Resolve Literal["a", "b", ...] into converter + choices.""" literal_values = list(args) - return {"type": _make_literal_type(literal_values), "choices": literal_values} + return _TypeResult(converter=_make_literal_type(literal_values), choices=literal_values) -def _resolve_enum(tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> dict[str, Any]: +def _resolve_enum(tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> _TypeResult: """Resolve Enum subclasses into converter + choices.""" - return { - "type": _make_enum_type(tp), - "choices": [CompletionItem(m, text=str(m.value), display_meta=m.name) for m in tp], - } + return _TypeResult( + converter=_make_enum_type(tp), + choices=[CompletionItem(m, text=str(m.value), display_meta=m.name) for m in tp], + ) # -- Registry ----------------------------------------------------------------- -_TYPE_RESOLVERS: dict[Any, Callable[..., dict[str, Any]]] = { +_TYPE_TABLE: dict[Any, Callable[..., _TypeResult]] = { # Subclass-matchable entries first -- iteration order matters for the # issubclass fallback. enum.Enum must precede int (IntEnum <: int). enum.Enum: _resolve_enum, @@ -538,128 +750,35 @@ def _type_name(tp: Any) -> str: _PASSTHROUGH_TYPES = frozenset({str, object, Any, inspect.Parameter.empty}) -def _is_passthrough_type(tp: Any) -> bool: - """Return ``True`` for types stored as a raw string without a dedicated converter. - - Covers ``str`` / ``Any`` / ``object`` / unannotated parameters, and any parametrized - generic we do not specialize (e.g. ``frozenset[T]``, ``dict[K, V]``, ``Sequence[T]``), - which keep their existing scalar-passthrough behavior. - """ - return tp in _PASSTHROUGH_TYPES or get_origin(tp) is not None - - -def _nargs_yields_list(nargs: Any) -> bool: - """Return ``True`` when an argparse ``nargs`` value produces a list at parse time. +def _resolve_base_type(tp: Any, *, is_positional: bool = False) -> _TypeResult: + """Resolve a declared type into a :class:`_TypeResult` via the registry. - ``nargs=1`` is included: argparse returns ``[value]``, not the bare value. - """ - return nargs in ("*", "+", argparse.REMAINDER) or (isinstance(nargs, int) and nargs >= 1) - - -def _resolve_type( - tp: Any, - *, - is_positional: bool = False, - is_optional: bool = False, - has_default: bool = False, - default: Any = None, - metadata: ArgMetadata = None, - is_kw_only: bool = False, -) -> tuple[type, dict[str, Any]]: - """Resolve a type into argparse kwargs via the registry. - - Lookup order: ``get_origin(tp)`` → ``tp`` → ``issubclass`` fallback. - - Returns ``(base_type, kwargs_dict)``. + Lookup order: ``get_origin(tp)`` -> ``tp`` -> ``issubclass`` fallback -> passthrough. + Raises ``TypeError`` for a scalar with no converter. """ args = get_args(tp) - # ``has_default``, ``is_kw_only``, and ``is_optional`` all mean "this argument may be absent", - # so collection resolvers should pick ``nargs='*'`` instead of ``'+'``. - resolver_has_default = has_default or is_kw_only or is_optional - ctx: dict[str, Any] = { - "is_positional": is_positional, - "has_default": resolver_has_default, - "default": default, - "metadata": metadata, - } + resolver = _TYPE_TABLE.get(get_origin(tp)) or _TYPE_TABLE.get(tp) - resolver = _TYPE_RESOLVERS.get(get_origin(tp)) or _TYPE_RESOLVERS.get(tp) - - # Subclass fallback (e.g. MyEnum → enum.Enum, MyPath → pathlib.Path) + # Subclass fallback (e.g. MyEnum -> enum.Enum, MyPath -> pathlib.Path). if resolver is None and isinstance(tp, type): - for parent, candidate in _TYPE_RESOLVERS.items(): + for parent, candidate in _TYPE_TABLE.items(): if isinstance(parent, type) and issubclass(tp, parent): resolver = candidate break if resolver is not None: - kwargs = resolver(tp, args, **ctx) - base_type = kwargs.pop("base_type", tp) - elif _is_passthrough_type(tp): - base_type = tp - kwargs = {} - else: - raise TypeError( - f"Unsupported parameter type {_type_name(tp)!r} for @with_annotated: there is no converter " - f"for it, so command-line values would silently arrive as plain strings. Supported scalar types " - f"are str, int, float, bool, decimal.Decimal, pathlib.Path, enum.Enum subclasses, and Literal[...]; " - f"use one of these (optionally in list/set/tuple) or a subclass of one." - ) - - resolver_nargs = kwargs.get("nargs") - - if metadata: - kwargs.update(metadata.to_kwargs()) - - nargs_val = kwargs.get("nargs") - - # A fixed-arity type (e.g. ``tuple[T, T]``) declares its own nargs; - # user metadata cannot override it to a different value. - if isinstance(resolver_nargs, int) and nargs_val != resolver_nargs: - raise TypeError( - f"nargs={nargs_val!r} conflicts with the fixed arity of '{_type_name(tp)}' (expected nargs={resolver_nargs})." - ) - - # nargs that produces a list of values requires a collection annotation. - if not kwargs.get("is_collection") and _nargs_yields_list(nargs_val): - raise TypeError( - f"nargs={nargs_val!r} produces a list of values, but the annotation '{_type_name(tp)}' is not a collection type. " - f"Use list[T], tuple[T, ...], or set[T] (optionally with | None) to match." - ) - - # Some argparse actions (e.g. count/store_true) do not accept a type converter. - action_name = kwargs.get("action") - if isinstance(action_name, str) and action_name in _ACTIONS_DISALLOW_TYPE: - kwargs.pop("type", None) - - if has_default: - kwargs["default"] = default - - if is_kw_only and not has_default: - kwargs["required"] = True - - # An optional positional scalar takes 0-or-1 tokens. This covers both ``T | None`` - # (no default) and a positional given an explicit default; without ``nargs='?'`` - # argparse would still require the latter, contradicting its default value. - if (is_optional or has_default) and is_positional and "nargs" not in kwargs and not kwargs.get("is_collection"): - kwargs["nargs"] = "?" - - if is_positional and (is_optional or has_default) and isinstance(kwargs.get("nargs"), int): - raise TypeError( - f"A fixed-arity positional (nargs={kwargs['nargs']}) cannot be optional; argparse always " - f"requires it. Drop the default or '| None', make it an option (give it a default without " - f"Argument()), or use a variable-arity type such as tuple[T, ...]." - ) - - # A user-supplied completer/choices_provider drives completion, so drop the inferred - # static ``choices`` list. - if kwargs.get("choices_provider") or kwargs.get("completer"): - kwargs.pop("choices", None) - - return base_type, kwargs + return resolver(tp, args, is_positional=is_positional) + if tp in _PASSTHROUGH_TYPES or get_origin(tp) is not None: + return _TypeResult() + raise TypeError( + f"Unsupported parameter type {_type_name(tp)!r} for @with_annotated: there is no converter " + f"for it, so command-line values would silently arrive as plain strings. Supported scalar types " + f"are str, int, float, bool, decimal.Decimal, pathlib.Path, enum.Enum subclasses, and Literal[...]; " + f"use one of these (optionally in list/set/tuple) or a subclass of one." + ) -def _unwrap_optional(tp: type) -> tuple[type, bool]: +def _unwrap_optional(tp: Any) -> tuple[Any, bool]: """If *tp* is ``T | None``, return ``(T, True)``. Otherwise ``(tp, False)``. Raises ``TypeError`` for ambiguous unions like ``str | int`` or ``str | int | None``. @@ -681,7 +800,7 @@ def _unwrap_optional(tp: type) -> tuple[type, bool]: return tp, False -def _normalize_annotation(annotation: type) -> _NormalizedAnnotation: +def _normalize_annotation(annotation: Any) -> _NormalizedAnnotation: """Normalize an annotation into its inner type, metadata, and optionality.""" tp = annotation metadata: ArgMetadata = None @@ -713,97 +832,1009 @@ def _normalize_annotation(annotation: type) -> _NormalizedAnnotation: # --------------------------------------------------------------------------- -# Annotation resolution +# Annotation resolution -- builder anchored to argparse's add_argument schema # --------------------------------------------------------------------------- -def _resolve_annotation( - annotation: Any, - *, - has_default: bool = False, - default: Any = None, - is_kw_only: bool = False, - is_variadic: bool = False, -) -> tuple[dict[str, Any], ArgMetadata, bool]: - """Decompose a type annotation into ``(type_kwargs, metadata, is_positional)``. - - Peels ``Annotated`` then ``Optional``. The only supported way to combine - ``Annotated`` with ``Optional`` is ``Annotated[T | None, meta]``. - Writing ``Annotated[T, meta] | None`` is ambiguous and raises ``TypeError``. - - ``is_variadic`` marks a ``*args`` parameter: it is always positional and - accepts zero or more values (``nargs='*'``). +class _NargsMode(enum.Enum): + """How an explicit action sets ``nargs`` in the final override pass.""" + + ARITY = "arity" # keep the nargs resolved by the arity table + CLEAR = "clear" # take one value per flag, so emit no nargs + + +@dataclass(frozen=True) +class _ActionPolicy: + """Declarative data for an explicit ``Option(action=...)``. + + Applied verbatim by :meth:`_ArgparseArgument._apply_action`; the type + compatibility check (``requires``) is enforced by the constraint table. """ - tp, metadata, is_optional = _normalize_annotation(annotation) - - # ``*args`` is always a positional that accepts zero or more values. - is_positional = is_variadic or isinstance(metadata, Argument) or (metadata is None and not has_default and not is_kw_only) - - tp, type_kwargs = _resolve_type( - tp, - is_positional=is_positional, - is_optional=is_optional or is_variadic, - has_default=has_default, - default=default, - metadata=metadata, - is_kw_only=is_kw_only, - ) - type_kwargs.pop("is_collection", None) - type_kwargs.pop("base_type", None) + requires: Callable[["_ArgparseArgument"], bool] | None = None # declared type the result needs + requires_label: str = "" # human name of the required type (for the error message) + drop_converter: bool = False # action does not accept type= + nargs_mode: _NargsMode = _NargsMode.ARITY # whether to keep or clear the arity-table nargs + default_factory: Callable[[], Any] | None = None # default when omitted (also -> None for T | None) - return type_kwargs, metadata, is_positional +#: Actions that accumulate values into a list via argparse's native action (replace the casting action). +_LIST_ACTIONS = frozenset({"append", "extend", "append_const"}) +#: Actions that store a fixed ``const`` value: ``store_const`` (scalar) and ``append_const`` (list[T]). +_CONST_ACTIONS = frozenset({"store_const", "append_const"}) -# Parameter names that conflict with argparse internals and cannot be used -# as annotated parameter names. -_RESERVED_PARAM_NAMES = frozenset({"dest", "subcommand"}) +#: Metadata kwargs the builder reasons about itself (the override facts read on demand via the +#: ``_meta_*`` properties); every other ``to_kwargs()`` entry passes straight through to +#: ``extras`` for argparse (help / metavar / choices_provider / completer / ...). +_METADATA_SPECIAL_KEYS = frozenset({"choices", "action", "required", "nargs", "const"}) +#: A rule table maps a subject ``_S`` (a single :class:`_ArgparseArgument`) to a result ``_R``. +_S = TypeVar("_S") +_R = TypeVar("_R") -# --------------------------------------------------------------------------- -# Signature → Parser conversion -# --------------------------------------------------------------------------- +#: One rule-table row: ``(predicate, producer)``. A table is scanned top-to-bottom and the first +#: row whose ``predicate(subject)`` holds yields ``producer(subject)`` (see :func:`_first_match`). +#: Derivation tables produce an output slot's value; constraint tables produce an error or ``None``. +#: (Keyed dispatch -- ``_TYPE_TABLE`` / ``_ACTION_TABLE`` -- is separate: a payload looked up by key.) +_Rule = tuple[Callable[[_S], bool], Callable[[_S], _R]] -def _validate_base_command_params( - func: Callable[..., Any], - *, - skip_params: frozenset[str] | None = None, -) -> None: - """Validate a ``base_command=True`` function has ``cmd2_handler`` and no positional args.""" - if "cmd2_handler" not in inspect.signature(func).parameters: - raise TypeError(f"with_annotated(base_command=True) requires a 'cmd2_handler' parameter in {func.__qualname__}") +def _always(_subject: object) -> bool: + """Predicate for a catch-all row -- always matches (every table ends with one).""" + return True - if skip_params is None: - skip_params = _SKIP_PARAMS - for name, metadata, positional, _flags, _kwargs in _resolve_parameters(func, skip_params=skip_params): - if positional and not isinstance(metadata, Argument): - raise TypeError( - f"Parameter '{name}' in {func.__qualname__} is positional, " - f"which conflicts with subcommand parsing. " - f"Use a keyword-only parameter (after *) or give it a default value." +def _const(value: _R) -> Callable[[object], _R]: + """Return a producer that ignores its subject and always yields *value*.""" + return lambda _subject: value + + +def _first_match(rules: list[_Rule[_S, _R]], subject: _S) -> _R: + """Return ``producer(subject)`` for the first rule whose predicate matches *subject*. + + Every table ends with an ``_always`` catch-all, so a match is guaranteed and only the + matching producer runs (a producer may assume its predicate held). + """ + return next(produce(subject) for predicate, produce in rules if predicate(subject)) + + +class _ArgparseArgument: + """Builder whose output fields mirror ``parser.add_argument(...)``'s schema. + + Constructed by :func:`_resolve_parameters` from the signature-derived inputs and populated by + :meth:`_apply`, which fills each output slot from its decision table (role / nargs / default / + required) or imperative phase (targets / type / metadata / action). Inputs and scratch live + alongside the output slots, but only the named slots are emitted (see :meth:`_emit`). + + Building does *not* validate: validation is deferred to a final pass in :func:`_resolve_parameters`, + which links cross-argument facts (e.g. :attr:`has_following_positional`) onto each argument and then + runs the single :data:`_CONSTRAINTS` table. New behavior is added as a table row, not an ``if`` in + the phases. :meth:`add_to` emits the ``add_argument`` call. + """ + + def __init__( + self, + *, + name: str, + func_qualname: str, + has_default: bool, + param_default: Any, + is_kw_only: bool, + is_variadic: bool, + inner_type: Any, + metadata: ArgMetadata, + is_optional: bool, + kind: inspect._ParameterKind, + is_base_command: bool, + ) -> 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_kw_only = is_kw_only + self.is_variadic = is_variadic + self.inner_type = inner_type # peeled type (after Annotated + Optional) + self.metadata = metadata + self.is_optional = is_optional + self.kind = kind # unsupported kinds (positional-only, **kwargs) are rejected by _CONSTRAINTS + self.is_base_command = is_base_command + # scratch filled by the type table (_apply_type): + self.is_collection = False + self.fixed_arity: int | None = None + # output slots (1:1 with add_argument): + self.is_positional = False + self.flags: list[str] = [] + self.action: str | type[argparse.Action] | None = None + self.nargs: _NargsValue | None = None + self.type: Callable[[str], Any] | None = None + self.choices: Iterable[Any] | None = None + self.default: Any = _UNSET # _UNSET until a default rule/action sets one + self.required: bool = False + self.container_factory: Callable[[list[Any]], Any] | None = None + self.extras: dict[str, Any] = {} + # first type-resolution error, captured so _CONSTRAINTS (not build order) picks the message: + self.build_error: Exception | None = None + # cross-argument facts, linked by _resolve_parameters once the whole list is built: + self.has_following_positional = False + # 1-based indices of the groups=/mutually_exclusive_groups= this parameter belongs to: + self.argument_group_indices: list[int] = [] + self.mutex_group_indices: list[int] = [] + # Derive every output slot now; validation stays deferred to _check_constraints. + self._apply() + + @property + def omittable(self) -> bool: + """Whether the argument may be absent (drives ``nargs`` and required). + + A metadata default (``Option(default=...)``) counts the same as a signature default here. + """ + return self._effective_has_default or self.is_kw_only or self.is_optional or self.is_variadic + + @property + def _is_list(self) -> bool: + """Whether the declared type is ``list``/``list[T]`` -- the shape the list actions need. + + Distinct from :attr:`is_collection` (also true for ``set``/``tuple``): ``append``/``extend``/ + ``append_const`` accumulate specifically into a ``list``. + """ + return get_origin(self.inner_type) is list or self.inner_type is list + + # -- ``*args`` element facts, derived from the ``tuple[element, ...]`` wrapper that + # _resolve_parameters builds for a variadic parameter (only meaningful when ``is_variadic``) -- + + @property + def _var_positional_element(self) -> Any: + """The ``*args`` element type ``T`` (``inner_type`` is the variadic ``tuple[T, ...]``).""" + return get_args(self.inner_type)[0] + + @property + def _var_positional_element_display(self) -> str: + """Display name of the ``*args`` element type (for the collection-element constraint message).""" + element = self._var_positional_element + return str(element) if get_origin(element) is not None else _type_name(element) + + @property + def _var_positional_element_is_collection(self) -> bool: + """Whether the ``*args`` element is itself a collection (``list``/``set``/``tuple``). + + Mirrors the collection entries in :data:`_TYPE_TABLE`; a collection element means ``*args`` + would collect a tuple of collections, which the constraint table rejects. + """ + element = self._var_positional_element + origin = get_origin(element) + return (origin if origin is not None else element) in (list, set, tuple) + + # -- the user's metadata overrides, derived read-only from ``metadata`` (consulted by the + # choices/action/nargs/required tables, the action phase, and the constraints) -- + + @property + def _meta_nargs(self) -> _NargsValue | None: + """An explicit ``Argument/Option(nargs=)``, else ``None``.""" + return self.metadata.nargs if self.metadata is not None else None + + @property + def _meta_choices(self) -> Iterable[Any] | None: + """Explicit ``Argument/Option(choices=)``, else ``None``.""" + return self.metadata.choices if self.metadata is not None else None + + @property + def _has_user_completion(self) -> bool: + """Whether the user supplied a ``choices_provider`` / ``completer`` on the metadata. + + A user-supplied completion source drives completion in place of any static ``choices``. + Distinct from a completer the *type* inferred (e.g. ``Path``'s ``path_complete``), which + yields to an explicit ``choices=`` instead of overriding it. + """ + if self.metadata is None: + return False + return self.metadata.choices_provider is not None or self.metadata.completer is not None + + @property + def _meta_action(self) -> str | type[argparse.Action] | None: + """An explicit ``Option(action=)`` value, else ``None`` (only ``Option`` carries one). + + May be a string (one of the supported actions) or a custom :class:`argparse.Action` + subclass; the constraint and policy rules below key on the string form, so a class + action skips them and is passed straight through to ``add_argument``. + """ + return self.metadata.action if isinstance(self.metadata, Option) else None + + @property + def _meta_action_is_class(self) -> bool: + """Whether ``Option(action=)`` is a custom :class:`argparse.Action` subclass.""" + return isinstance(self._meta_action, type) + + @property + def _meta_required(self) -> bool: + """Whether the user set ``Option(required=True)``.""" + return self.metadata.required if isinstance(self.metadata, Option) else False + + @property + def _const_value(self) -> Any: + """The explicit ``Argument/Option(const=)`` value, or :data:`_UNSET` when none was given.""" + return self.metadata.const if self.metadata is not None else _UNSET + + @property + def _has_const(self) -> bool: + """Whether a ``const`` value was supplied (drives const-action inference and validation).""" + return self._const_value is not _UNSET + + @property + def _meta_default(self) -> Any: + """The explicit ``Argument/Option(default=)`` value, or :data:`_UNSET` when none was given.""" + return self.metadata.default if self.metadata is not None else _UNSET + + @property + def _has_meta_default(self) -> bool: + """Whether a metadata default was supplied (treated like a signature default).""" + return self._meta_default is not _UNSET + + @property + def _effective_has_default(self) -> bool: + """Whether any default applies (signature or metadata). + + A metadata default is the equivalent of a signature default: + ``Annotated[T, Option('--x', default=v)]`` behaves the same as + ``Annotated[T, Option('--x')] = v``. Specifying both is a conflict (see ``_CONSTRAINTS``). + """ + return self.has_default or self._has_meta_default + + @property + def _effective_param_default(self) -> Any: + """The default to emit -- signature default if present, else the metadata default, else ``None``.""" + if self.has_default: + return self.param_default + if self._has_meta_default: + return self._meta_default + return None + + @property + def _effective_action(self) -> str | None: + """The resolved action as a string -- explicit ``Option(action=)`` or const-inferred. + + ``None`` for the type-inferred class actions (``BooleanOptionalAction`` / + ``_CollectionCastingAction``), which carry no :data:`_ACTION_TABLE` policy. Reads the + resolved ``action`` slot, so it is only meaningful after :data:`_ACTION_RULES` run. + """ + return self.action if isinstance(self.action, str) else None + + @property + def _policy(self) -> _ActionPolicy | None: + """The action policy for the effective action (``None`` for no/an unknown/a class action). + + Keyed on :attr:`_effective_action` so a const-inferred ``store_const``/``append_const`` + gets the same policy treatment as an explicit one. + """ + return _ACTION_TABLE.get(self._effective_action) if self._effective_action else None + + @property + def _is_inferred_bool_flag(self) -> bool: + """Whether this is a bool option using the inferred ``BooleanOptionalAction`` (no explicit action). + + Like the explicit flag actions, it supplies its own value when absent (``False``, or ``None`` for + ``bool | None``), so it is neither ``required`` nor needs a user default. Reads the resolved + ``action`` slot, so it is only meaningful after :meth:`_apply_type` / :data:`_ACTION_RULES` run. + """ + return self.action is argparse.BooleanOptionalAction + + def _apply(self) -> None: + """Build this argument by deriving each output slot (no validation here). + + :meth:`_apply_type` seeds the type-inferred baselines and :meth:`_apply_metadata_extras` merges + the user's display kwargs into ``extras``; then every output slot is filled from its value table + (role / action / choices / nargs / default / required). The action *policy* is applied last, as + an override, because ``action=`` is cross-cutting and only makes sense once the rest is known. + Validity is checked later by :func:`_resolve_parameters` (via :meth:`_check_constraints`). + """ + self.is_positional = _first_match(_ROLE_RULES, self) + self._apply_targets() + self._apply_type() + if self.build_error is not None: + # Type unresolved: the remaining phases assume a resolved type, so stop here and let + # _CONSTRAINTS raise the captured error. + return + self._apply_metadata_extras() + self.action = _first_match(_ACTION_RULES, self) + self.choices = _first_match(_CHOICES_RULES, self) + if self._meta_choices is not None and self.choices is not None: + # The choices the user wrote in source are compared by argparse *after* the type + # converter runs, so run them through that converter to land in the same value-space + # (Annotated[int, Option(choices=['1','2'])] -> [1, 2], so `--x 1` matches), and drop a + # type-inferred completer (e.g. Path's) so completion is driven by these choices. + if self.type is not None: + self.choices = self._convert_choices(self.choices, self.type) + if not self._has_user_completion: + self.extras.pop("completer", None) + self.extras.pop("choices_provider", None) + self.nargs = _first_match(_NARGS_RULES, self) + self.default = _first_match(_DEFAULT_RULES, self) + self.required = _first_match(_REQUIRED_RULES, self) + self._apply_action() + + def _convert_choices(self, choices: Iterable[Any], converter: Callable[[str], Any]) -> list[Any]: + """Run string ``choices`` through the inferred ``type`` *converter* so they match post-conversion. + + Non-string choices are assumed already in the target type and left untouched (the simple + converters are idempotent on them). A choice the converter rejects is a build-time error. + """ + converted: list[Any] = [] + for choice in choices: + if not isinstance(choice, str): + converted.append(choice) + continue + try: + converted.append(converter(choice)) + except (ValueError, TypeError, ArithmeticError, argparse.ArgumentTypeError) as exc: + raise TypeError(f"choice {choice!r} on '{self.name}' is not a valid '{_type_name(self.inner_type)}'.") from exc + return converted + + # -- construction (fill output slots from the tables; no business rules here) -- + + def _apply_targets(self) -> None: + """Set ``flags`` for options (positionals keep the empty default).""" + if self.is_positional: + return + self.flags = ( + list(self.metadata.names) + if isinstance(self.metadata, Option) and self.metadata.names + else [f"--{self.name.replace('_', '-')}"] + ) + + def _apply_type(self) -> None: + """Copy the type table's result onto the output slots + type scratch. + + Type resolution is the only build step that can fail (unsupported type, nested collection, ...). + Rather than raise here -- which would let build order decide the message -- the error is captured + so :data:`_CONSTRAINTS` can rank it against more specific rules and raise the winner. + """ + try: + result = _resolve_base_type(self.inner_type, is_positional=self.is_positional) + except TypeError as exc: + self.build_error = exc + return + self.type = result.converter + self.choices = result.choices + # A collection coerces its parsed list into the declared container type; option bool + # gets ``--flag/--no-flag``. Either may be overridden by an explicit ``Option(action=)``. + self.action = _CollectionCastingAction if result.is_collection else result.action + self.container_factory = result.container_factory + if result.completer is not None: + self.extras["completer"] = result.completer + self.is_collection = result.is_collection + self.fixed_arity = result.fixed_arity + + def _apply_metadata_extras(self) -> None: + """Pass the user's display/completion metadata straight through to ``extras``. + + The override facts (choices/action/required/nargs) are read on demand from ``metadata`` via + properties; only these passthrough kwargs need merging into ``extras``. + """ + if self.metadata is None: + return + kwargs = self.metadata.to_kwargs() + self.extras.update({key: value for key, value in kwargs.items() if key not in _METADATA_SPECIAL_KEYS}) + + def _apply_action(self) -> None: + """Apply an explicit ``Option(action=...)`` as the final override pass. + + Runs after type/nargs/default/required are resolved and only sets slots; the action's validity + (type match, unknown action, ...) is enforced by the constraints. + + A custom :class:`argparse.Action` subclass has no :data:`_ACTION_TABLE` policy: it owns its + own storage so the collection casting wrapper is dropped, but the type-inferred converter, + default, and required-ness are kept (the user can override them via :data:`extra_kwargs`). + """ + if self._meta_action_is_class: + # The user's class owns storage; drop the casting wrapper's container_factory kwarg. + self.container_factory = None + return + policy = self._policy + if policy is None: + return + if policy.drop_converter: + # The action stores a fixed value and takes no command-line argument, so the parsed + # string is never converted, validated against choices, or tab-completed -- drop the + # converter, choices, and any value-completion metadata (e.g. the completer inferred + # for a Path type), which argparse rejects on a zero-argument action. + self.type = None + self.choices = None + self.extras.pop("completer", None) + self.extras.pop("choices_provider", None) + if self._effective_action in _LIST_ACTIONS: + # append/extend/append_const use argparse's native list action, not the casting action. + self.container_factory = None + if policy.nargs_mode is _NargsMode.CLEAR: + self.nargs = None # append collects one value per flag, so it takes no nargs + if self.default is _UNSET and policy.default_factory is not None: + # The action carries its own default (count -> 0, append/extend -> []), + # except T | None, where None is the natural absence value. + self.default = None if self.is_optional else policy.default_factory() + if not self._meta_required: + # A supported action supplies a value when absent, so it is never required by default. + self.required = False + + def _check_constraints(self) -> None: + """Raise for the first violated constraint (declarative validation).""" + error = _first_match(_CONSTRAINTS, self) + if error is not None: + raise error + + # -- emission ------------------------------------------------------------ + + def _emit(self) -> tuple[tuple[Any, ...], dict[str, Any]]: + """Return ``(args, kwargs)`` for ``target.add_argument(*args, **kwargs)``.""" + kwargs: dict[str, Any] = dict(self.extras) + if self.type is not None: + kwargs["type"] = self.type + if self.choices is not None: + # Materialize so argparse can re-iterate it (a one-shot generator would be exhausted). + kwargs["choices"] = list(self.choices) + if self.action is not None: + kwargs["action"] = self.action + if self._has_const: + kwargs["const"] = self._const_value + if self.nargs is not None: + kwargs["nargs"] = self.nargs + if self.container_factory is not None: + kwargs["container_factory"] = self.container_factory + if self.default is not _UNSET: + kwargs["default"] = self.default + if self.required: + kwargs["required"] = True + if self.is_positional: + return (self.name,), kwargs + kwargs["dest"] = self.name + return tuple(self.flags), kwargs + + def add_to(self, target: _ArgumentTarget) -> None: + """Add this argument to *target* (a parser, group, or mutex group).""" + args, kwargs = self._emit() + target.add_argument(*args, **kwargs) + + +#: Explicit ``Option(action=...)`` policies. Defined after :class:`_ArgparseArgument` so the +#: ``requires`` predicates can read its ``inner_type`` slot (like the other rule tables below). +_ACTION_TABLE: dict[str, _ActionPolicy] = { + "store_true": _ActionPolicy( + requires=lambda a: a.inner_type is bool, + requires_label="bool", + drop_converter=True, + default_factory=lambda: False, + ), + "store_false": _ActionPolicy( + requires=lambda a: a.inner_type is bool, + requires_label="bool", + drop_converter=True, + default_factory=lambda: True, + ), + "count": _ActionPolicy( + requires=lambda a: a.inner_type is int, requires_label="int", drop_converter=True, default_factory=lambda: 0 + ), + "append": _ActionPolicy( + requires=lambda a: a._is_list, + requires_label="list[T]", + nargs_mode=_NargsMode.CLEAR, + default_factory=list, + ), + "extend": _ActionPolicy( + requires=lambda a: a._is_list, + requires_label="list[T]", + default_factory=list, + ), + # const actions: shape (scalar vs list[T]) and the const value itself are validated by dedicated + # _CONSTRAINTS rows, so requires=None here. Both store a fixed value, so the converter/choices are + # dropped (drop_converter) and they take no command-line value (nargs CLEAR). + "store_const": _ActionPolicy(drop_converter=True, nargs_mode=_NargsMode.CLEAR), + "append_const": _ActionPolicy(drop_converter=True, nargs_mode=_NargsMode.CLEAR, default_factory=list), +} + + +#: Role table: the first matching predicate decides positional (``True``) vs option (``False``). +_ROLE_RULES: list[_Rule[_ArgparseArgument, bool]] = [ + (lambda a: a.is_variadic, _const(True)), # *args is always positional + (lambda a: isinstance(a.metadata, Argument), _const(True)), # Argument() forces positional + (lambda a: isinstance(a.metadata, Option), _const(False)), # Option() forces option + (lambda a: a.is_kw_only, _const(False)), # keyword-only -> option + (lambda a: a.has_default, _const(False)), # a signature default -> option (metadata-only + # default never reaches this row: Argument/Option metadata already pinned the role above) + (_always, _const(True)), # otherwise positional +] + +#: Action table: an explicit ``Option(action=)`` overrides the type-inferred action +#: (collection-casting / ``BooleanOptionalAction`` / none). The action *policy* is applied later. +_ACTION_RULES: list[_Rule[_ArgparseArgument, str | type[argparse.Action] | None]] = [ + (lambda a: a._meta_action is not None, lambda a: a._meta_action), # explicit Option(action=) + # A const with no explicit action selects the const action by type shape: + # list[T] -> append_const (accumulate), any scalar -> store_const (single value). + # Exception: a scalar that *also* sets an explicit nargs (e.g. nargs='?') wants argparse's native + # optional-value-with-const semantics (absent -> default, bare flag -> const, flag VALUE -> converted + # VALUE), so it keeps the type-inferred ``store`` action instead of the value-less store_const -- the + # explicit nargs and the type converter would otherwise be silently dropped. list[T] still infers + # append_const regardless of nargs (append_const takes no value, so nargs is meaningless there). + ( + lambda a: a._has_const and (a.is_collection or a._meta_nargs is None), + lambda a: "append_const" if a.is_collection else "store_const", + ), + (_always, lambda a: a.action), # the type-inferred action baseline +] + +#: Choices table, in precedence order: a *user* choices_provider/completer drives completion (drop +#: static choices); otherwise an explicit metadata ``choices=`` wins (even over a type-inferred +#: completer such as ``Path``'s -- see ``_apply``, which then drops that completer); otherwise a +#: type-inferred completer drives completion (drop choices); otherwise the type-inferred choices. +_CHOICES_RULES: list[_Rule[_ArgparseArgument, Iterable[Any] | None]] = [ + (lambda a: a._has_user_completion, _const(None)), # user completer/provider overrides choices + (lambda a: a._meta_choices is not None, lambda a: a._meta_choices), # explicit Argument/Option choices + (lambda a: bool(a.extras.get("choices_provider") or a.extras.get("completer")), _const(None)), # inferred completer + (_always, lambda a: a.choices), # the type-inferred choices baseline +] + +#: ``nargs`` table -- the sole source of arity. An explicit ``Argument(nargs=)`` wins; otherwise the +#: value shape decides (fixed tuple -> its arity, collection -> ``'+'``/``'*'``, optional scalar +#: positional -> ``'?'``). Action effects (append clears nargs) are applied later by the action phase. +_NARGS_RULES: list[_Rule[_ArgparseArgument, _NargsValue | None]] = [ + (lambda a: a._meta_nargs is not None, lambda a: a._meta_nargs), # an explicit Argument(nargs=) wins + (lambda a: a.fixed_arity is not None, lambda a: a.fixed_arity), # tuple[T, T] pins nargs to its arity + (lambda a: a.is_collection and a.omittable, _const("*")), # list/set/tuple[T, ...] that may be empty + (lambda a: a.is_collection, _const("+")), # collection requiring >= 1 value + (lambda a: a.is_positional and a.omittable, _const("?")), # an optional scalar positional + (_always, _const(None)), # required scalar / any option scalar +] + +#: 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]] = [ + (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`` + # (argparse's ``None``), the natural absence value for the Optional case. + (lambda a: a._is_inferred_bool_flag and not a.is_optional, _const(False)), + (_always, _const(_UNSET)), # nothing to set (the action may still add one) +] + +#: Required table. Positionals never carry ``required=``; the action phase may relax it. +_REQUIRED_RULES: list[_Rule[_ArgparseArgument, bool]] = [ + (lambda a: a.is_positional, _const(False)), + (lambda a: a._meta_required, _const(True)), # explicit Option(required=True) + (lambda a: a._effective_has_default or a.is_optional, _const(False)), # omittable + # A bool option is a flag that supplies its own value (False/None) when absent, so it is never + # required without an explicit Option(required=True) -- same reasoning as the flag actions below. + (lambda a: a._is_inferred_bool_flag, _const(False)), + (_always, _const(True)), # an option with no default and no ``| None`` must be supplied +] + + +#: ``nargs`` values that let a positional consume a variable number of tokens. +_VARIABLE_NARGS = frozenset({"?", "*", "+", argparse.REMAINDER}) + + +# Parameter names that conflict with argparse internals and cannot be used as annotated +# parameter names (checked by _CONSTRAINTS). +_RESERVED_PARAM_NAMES = frozenset({"dest", "subcommand"}) + + +def _const_element_type(a: _ArgparseArgument) -> Any: + """Return the type a const must match: the element ``T`` for ``list[T]``, else the scalar itself.""" + if a.is_collection: + args = get_args(a.inner_type) + element = args[0] if args else str + else: + element = a.inner_type + element, _ = _unwrap_optional(element) + return element + + +def _const_mismatches_type(a: _ArgparseArgument) -> bool: + """Whether a supplied ``const`` is incompatible with the declared (element) type. + + Best-effort, mirroring the decorator's "parsed value matches the annotation" guarantee: + ``Literal``/``Enum`` are checked for membership and the concrete scalars by ``isinstance``; + open types (``str``/``Any``/``object``/the bool flag) and unresolved types are not validated. + A class :class:`argparse.Action` owns its storage semantics, so any ``const`` paired with one + is the user's responsibility and is not type-checked here. + """ + if not a._has_const or a._meta_action_is_class: + return False + try: + result = _resolve_base_type(_const_element_type(a)) + except TypeError: + return False # an unresolved element type is reported by the build_error row instead + const = a._const_value + if result.choices is not None: + accepted = [c.value if isinstance(c, CompletionItem) else c for c in result.choices] + return const not in accepted + converter = result.converter + if converter is int: # bool is an int subclass but not a valid int const + return type(const) is not int + if converter is float: + return not (isinstance(const, (int, float)) and not isinstance(const, bool)) + if converter is decimal.Decimal: + return not isinstance(const, decimal.Decimal) + if converter is Path: + return not isinstance(const, Path) + if _const_element_type(a) is str: # a str parameter stores the const verbatim, so it must be a str + return not isinstance(const, str) + return False # Any / object / unannotated / bool flag: genuinely untyped, nothing to validate + + +# The single validity table, evaluated by :func:`_resolve_parameters` once every argument is built +# and its cross-argument facts are linked. +_CONSTRAINTS: list[_Rule[_ArgparseArgument, Exception | None]] = [ + ( + # Signature shape: positional-only parameters cannot be passed by keyword, which is how + # the decorator forwards parsed values. + lambda a: a.kind == inspect.Parameter.POSITIONAL_ONLY, + lambda a: TypeError( + f"Parameter {a.name!r} in {a.func_qualname} is positional-only, " + "which is not supported by @with_annotated because parameters are passed as keyword arguments." + ), + ), + ( + # Signature shape: **kwargs has no fixed names to map command-line arguments onto. + lambda a: a.kind == inspect.Parameter.VAR_KEYWORD, + lambda a: TypeError( + f"Parameter '**{a.name}' in {a.func_qualname} is variadic keyword (**kwargs), " + "which is not supported by @with_annotated because there is no native way to map " + "command-line arguments onto arbitrary keyword names." + ), + ), + ( + # A name argparse reserves on the namespace; raised as ValueError (a bad name value). + lambda a: a.name in _RESERVED_PARAM_NAMES, + lambda a: ValueError( + f"Parameter name {a.name!r} in {a.func_qualname} is reserved by argparse " + f"and cannot be used as an annotated parameter name." + ), + ), + ( + # *args (is_variadic) is always a plain positional, so Option() metadata is contradictory. + lambda a: a.is_variadic and isinstance(a.metadata, Option), + lambda a: TypeError( + f"Parameter '*{a.name}' in {a.func_qualname} uses Option() metadata, but *args is " + f"always a positional argument. Use Argument() metadata instead." + ), + ), + ( + # *args is fixed at nargs='*'; Argument(nargs=...) cannot override it. + lambda a: a.is_variadic and a._meta_nargs is not None, + lambda a: TypeError( + f"Parameter '*{a.name}' in {a.func_qualname} sets nargs={a._meta_nargs!r} via Argument(), " + f"but *args always accepts zero or more values (nargs='*') and its arity cannot be overridden." + ), + ), + ( + # For *args the annotation is the element type; a collection element would mean a tuple of + # collections + lambda a: a.is_variadic and a._var_positional_element_is_collection, + lambda a: TypeError( + f"Parameter '*{a.name}' in {a.func_qualname} is annotated with the collection type " + f"'{a._var_positional_element_display}'. For *args the annotation is the type of each " + f"value, not the collected tuple, so '*{a.name}: {a._var_positional_element_display}' " + f"would mean a tuple of '{a._var_positional_element_display}'. Annotate the element type " + f"instead (e.g. '*{a.name}: str'); values are always collected into a tuple." + ), + ), + ( + lambda a: a.is_kw_only and isinstance(a.metadata, Argument), + lambda a: TypeError( + f"Parameter '{a.name}' in {a.func_qualname} is keyword-only but uses Argument() metadata, " + f"which marks it as a positional argument. Keyword-only parameters always become options; " + f"use Option() metadata (or omit the metadata) instead." + ), + ), + ( + # const is meaningless on a positional: argparse rejects store_const/append_const there and + # ignores const with nargs='?'. A positional only ever gets its command-line value or its default. + lambda a: a._has_const and a.is_positional, + lambda a: TypeError( + f"Parameter '{a.name}' in {a.func_qualname} sets const=, but const is not supported on a " + f"positional argument (argparse ignores it). Use a default value or '{_type_name(a.inner_type)} | None' " + f"for the absent value, or use Option() to make it a flag." + ), + ), + ( + # const only makes sense with the const actions; pairing it with another explicit action is contradictory. + # Restricted to *known* actions so an unsupported action (e.g. 'store') falls through to the + # "not supported" row below -- that is the more fundamental problem to report first. + # A class action is exempt -- the user's action owns const semantics. + lambda a: ( + isinstance(a._meta_action, str) + and a._has_const + and a._meta_action in _ACTION_TABLE + and a._meta_action not in _CONST_ACTIONS + ), + lambda a: TypeError( + f"Option(const=...) on '{a.name}' cannot be combined with action={a._meta_action!r}. " + f"const is only valid with store_const or append_const (or omit action= to infer it from the type)." + ), + ), + ( + # An explicit const action with no const value: argparse would store None on presence. + lambda a: a._meta_action in _CONST_ACTIONS and not a._has_const, + lambda a: TypeError(f"Option(action={a._meta_action!r}) on '{a.name}' needs a const value. Pass Option(const=...)."), + ), + ( + # append_const accumulates into list[T]; reject it (explicit or inferred) on a non-list type. + lambda a: a._effective_action == "append_const" and not a._is_list, + lambda a: TypeError( + f"const on '{a.name}' accumulates into list[T] (append_const), but '{_type_name(a.inner_type)}' " + f"is not a list. Use list[T] to accumulate, or a scalar type to store a single value (store_const)." + ), + ), + ( + # store_const stores a single value, so a list/collection annotation is the wrong shape. + lambda a: a._effective_action == "store_const" and a.is_collection, + lambda a: TypeError( + f"const on '{a.name}' stores a single value (store_const), but '{_type_name(a.inner_type)}' is a " + f"collection. Use a scalar type, or list[T] to accumulate the const on each flag (append_const)." + ), + ), + ( + # A store_const flag falls back to its default when absent; without a default (and not Optional) + # that absent value is None, violating the declared type. + lambda a: a._effective_action == "store_const" and not (a._effective_has_default or a.is_optional), + lambda a: TypeError( + f"store_const flag '{a.name}' has no value when absent: give it a default or annotate it as " + f"'{_type_name(a.inner_type)} | None'." + ), + ), + ( + # action='store' WITH const is ambiguous, not merely redundant: plain 'store' ignores const + # unless paired with nargs='?', so the intent cannot be inferred -- a value-less const flag + # (store_const) or an optional value (nargs='?'+const)? Reported before the const-type-mismatch + # row below so the ambiguity wins even when the const's type also happens to mismatch. + lambda a: a._meta_action == "store" and a._has_const, + lambda a: TypeError( + f"Option(action='store', const=...) on '{a.name}' is ambiguous: 'store' ignores const unless " + f"combined with nargs='?', so the intent cannot be inferred. Use action='store_const' for a " + f"value-less const flag, or Option(nargs='?', const=...) (no action=) for an optional value." + ), + ), + ( + # A supplied const must match the declared (element) type, keeping the parsed-value guarantee. + _const_mismatches_type, + lambda a: TypeError( + f"const={a._const_value!r} on '{a.name}' does not match the declared type '{_type_name(_const_element_type(a))}'." + ), + ), + ( + # An unknown string action (class actions pass through verbatim). Checked before the + # collection-shape row below so an unsupported action reports "not supported", not the + # misleading "cannot be combined with a collection type". + lambda a: isinstance(a._meta_action, str) and a._meta_action not in _ACTION_TABLE, + lambda a: TypeError( + f"Option(action={a._meta_action!r}) is not supported by @with_annotated. Supported actions are " + f"store_true, store_false, count, append, extend, store_const, and append_const." + ), + ), + ( + # A (known) string action on a collection type must be one of the list actions; a class action + # owns storage and is exempt. + lambda a: isinstance(a._meta_action, str) and a.is_collection and a._meta_action not in _LIST_ACTIONS, + lambda a: TypeError( + f"Option(action={a._meta_action!r}) cannot be combined with a collection type, which installs its " + f"own action. Use action='append'/'extend'/'append_const' with list[T], or drop action= to collect via nargs." + ), + ), + ( + # A user-supplied completer / choices_provider on a value-less action (store_true / store_false / + # count / store_const / append_const) has nothing to complete: the action consumes no command-line + # value. Raw cmd2 raises here, so fail loud rather than silently dropping the user's request. A + # type-*inferred* completer (e.g. Path's) is still dropped silently by _apply_action -- only an + # explicit one is rejected. A class action owns its storage (no _policy), so it is exempt. + lambda a: a._policy is not None and a._policy.drop_converter and a._has_user_completion, + lambda a: TypeError( + f"completer=/choices_provider= on '{a.name}' cannot be used with action={a._effective_action!r}, " + f"which takes no value from the command line, so there is nothing to tab-complete. Remove the " + f"completer/choices_provider, or use a value-consuming action." + ), + ), + ( + lambda a: a._policy is not None and a._policy.requires is not None and not a._policy.requires(a), + lambda a: TypeError( + f"Option(action={a._meta_action!r}) yields {a._policy.requires_label if a._policy else ''}; " + f"annotate the parameter as {a._policy.requires_label if a._policy else ''}." + ), + ), + ( + lambda a: a._meta_action == "append" and a._meta_nargs is not None, + _const( + TypeError( + "Option(action='append') collects one value per flag and cannot set nargs; " + "use action='extend' to take multiple values per flag." ) - if isinstance(metadata, Argument): - raise TypeError( - f"Parameter '{name}' in {func.__qualname__} uses Argument() metadata, " - f"which creates a positional argument that conflicts with subcommand parsing." + ), + ), + ( + lambda a: a._meta_nargs is not None and a.fixed_arity is not None and a._meta_nargs != a.fixed_arity, + lambda a: TypeError( + f"nargs={a._meta_nargs!r} conflicts with the fixed arity of " + f"'{_type_name(a.inner_type)}' (expected nargs={a.fixed_arity})." + ), + ), + ( + # A user nargs that collects a list on a non-collection annotation. A nargs yields a list + # when it is '*'/'+'/REMAINDER, an int >= 1 (argparse returns [value] even for nargs=1), or a + # cmd2 ranged tuple other than (0, 1) + lambda a: ( + a._meta_nargs is not None + and not a.is_collection + and ( + a._meta_nargs in ("*", "+", argparse.REMAINDER) + or (isinstance(a._meta_nargs, int) and a._meta_nargs >= 1) + or (isinstance(a._meta_nargs, tuple) and tuple(a._meta_nargs) != (0, 1)) ) + ), + lambda a: TypeError( + f"nargs={a._meta_nargs!r} produces a list of values, but the annotation " + f"'{_type_name(a.inner_type)}' is not a collection type. " + f"Use list[T], tuple[T, ...], or set[T] (optionally with | None) to match." + ), + ), + ( + lambda a: a.is_positional and a.omittable and isinstance(a.nargs, int), + lambda a: TypeError( + f"A fixed-arity positional (nargs={a.nargs}) cannot be optional; argparse always " + f"requires it. Drop the default or '| None', make it an option (give it a default without " + f"Argument()), or use a variable-arity type such as tuple[T, ...]." + ), + ), + ( + # Conflict: both the signature and the metadata supplied a default. These are two + # 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." + ), + ), + ( + # argparse.SUPPRESS removes the attribute from the parsed namespace when absent, so the + # function would be called without the kwarg it expects. Reject from either source. + lambda a: a._effective_has_default and a._effective_param_default == argparse.SUPPRESS, + lambda a: TypeError( + f"parameter '{a.name}' in {a.func_qualname} uses argparse.SUPPRESS as a default, which is " + f"not supported by @with_annotated: SUPPRESS removes '{a.name}' from the parsed namespace " + f"when absent, but the function expects it as a keyword argument. Use a real default or " + f"annotate the type as '{_type_name(a.inner_type)} | None'." + ), + ), + ( + lambda a: ( + a._effective_has_default + and a._effective_param_default is None + and not a.is_optional + and a.inner_type not in (object, Any, inspect.Parameter.empty) + ), + lambda a: TypeError( + f"parameter '{a.name}' in {a.func_qualname} declared as '{_type_name(a.inner_type)}' has a " + f"default of None, but '{_type_name(a.inner_type)}' is not Optional, so omitting it would pass None " + f"and violate the type hint. Annotate it as '{_type_name(a.inner_type)} | None' to allow None, or " + f"give it a non-None default." + ), + ), + ( + # Cross-argument: a variable-arity positional must be last, else argparse cannot split + # tokens unambiguously (``def f(self, a: str, *rest: str)`` is fine -- the variadic is last). + # ``has_following_positional`` is linked by _resolve_parameters before this table runs. + lambda a: ( + a.is_positional and a.has_following_positional and (a.nargs in _VARIABLE_NARGS or isinstance(a.nargs, tuple)) + ), + lambda a: TypeError( + f"Parameter '{a.name}' in {a.func_qualname} has variable arity (nargs={a.nargs!r}) " + f"but is followed by another positional argument, so argparse cannot assign command-line " + f"tokens unambiguously. Make it the last positional, give the following positional(s) a " + f"default, or make them options." + ), + ), + ( + # base_command only: its parameters become subcommand-level options, so a positional + # conflicts with subcommand dispatch. ``is_base_command`` is set from the decorator's role; + # non-base arguments skip these rows on the first conjunct. + lambda a: a.is_base_command and a.is_positional and not isinstance(a.metadata, Argument), + lambda a: TypeError( + f"Parameter '{a.name}' in {a.func_qualname} is positional, " + f"which conflicts with subcommand parsing. " + f"Use a keyword-only parameter (after *) or give it a default value." + ), + ), + ( + lambda a: a.is_base_command and isinstance(a.metadata, Argument), + lambda a: TypeError( + f"Parameter '{a.name}' in {a.func_qualname} uses Argument() metadata, " + f"which creates a positional argument that conflicts with subcommand parsing." + ), + ), + ( + # Cross-config: a parameter assigned to two argument groups is ambiguous. The membership + # indices are linked by _resolve_parameters from the decorator's groups= before this runs. + lambda a: len(a.argument_group_indices) > 1, + lambda a: ValueError( + f"parameter {a.name!r} cannot be assigned to both argument " + f"group {a.argument_group_indices[0]} and argument group {a.argument_group_indices[1]}" + ), + ), + ( + # Cross-config: a parameter cannot belong to two mutually exclusive groups. + lambda a: len(a.mutex_group_indices) > 1, + lambda a: ValueError(f"parameter {a.name!r} cannot be assigned to multiple mutually exclusive groups"), + ), + ( + # Cross-config: a required member is incompatible with a mutex group -- only one member is + # supplied, so the others arrive as None (violating its non-Optional type), and argparse forbids + # it. This is an argument-typing rule (required-ness comes from the annotation), so it lives here + # rather than with the group graph construction. + lambda a: a.required and bool(a.mutex_group_indices), + lambda a: ValueError( + f"parameter {a.name!r} in mutually exclusive group {a.mutex_group_indices[0]} is required (no default " + f"and not Optional), but mutually exclusive group members must be optional because " + f"only one is supplied on the command line and the others arrive as None. " + f"Give it a default or annotate it as 'T | None'." + ), + ), + ( + # Type resolution failed during build (captured by _apply_type so build order does not pick + # the message). Raised last, so a more specific rule above wins; otherwise the raw type error. + lambda a: a.build_error is not None, + lambda a: a.build_error, + ), + (_always, _const(None)), # no violation +] + + +# --------------------------------------------------------------------------- +# Signature → Parser conversion +# --------------------------------------------------------------------------- -# Parameters that are handled specially by the decorator and should not -# be added to the argparse parser. The first positional parameter (self/cls) -# is always skipped by position; these cover additional decorator-managed names. +# Parameters handled specially by the decorator and not added to the parser. The first positional +# parameter (self/cls) is always skipped by position; these cover additional decorator-managed names. _SKIP_PARAMS = frozenset({"cmd2_handler", "cmd2_statement"}) +def _link_group_membership( + by_name: dict[str, _ArgparseArgument], + specs: tuple[Group, ...] | None, + select: Callable[[_ArgparseArgument], list[int]], +) -> None: + """Append each spec's 1-based index to the *select*-ed membership list of each member argument. + + :func:`_resolve_parameters` validates member references via :meth:`Group._validate_members` + before calling this, so every member name resolves to a built argument. + """ + if not specs: + return + for index, spec in enumerate(specs, start=1): + for name in spec.members: + select(by_name[name]).append(index) + + def _resolve_parameters( func: Callable[..., Any], *, skip_params: frozenset[str] = _SKIP_PARAMS, -) -> list[_ResolvedParam]: - """Resolve a function signature into parser-ready parameter records.""" + base_command: bool = False, + groups: tuple[Group, ...] | None = None, + mutually_exclusive_groups: tuple[Group, ...] | None = None, +) -> list[_ArgparseArgument]: + """Resolve a function signature into a list of argparse-argument builders. + + ``base_command`` marks each argument's context so the base-command rows in :data:`_CONSTRAINTS` + fire (a base command's parameters become subcommand-level options, so positionals are rejected), + and drives the function-level ``cmd2_handler`` check below (a plain ``if``, not a table row, + because its subject is the whole function rather than a single argument). + ``groups``/``mutually_exclusive_groups`` are linked onto each argument as membership facts so the + cross-config rows in :data:`_CONSTRAINTS` (double-assignment, required-member) fire from the one + validity pass. + """ sig = inspect.signature(func) + # Function-level check, before any argument is built: base_command dispatches to subcommands + # through its cmd2_handler parameter, so without one there is nothing to dispatch to. Checked + # here rather than in the per-argument _CONSTRAINTS loop so it also fires when the function + # declares zero parameters, and wins over any per-argument rule on the same function. + if base_command and "cmd2_handler" not in sig.parameters: + raise TypeError(f"with_annotated(base_command=True) requires a 'cmd2_handler' parameter in {func.__qualname__}") try: hints = get_type_hints(func, include_extras=True) except (NameError, AttributeError, TypeError) as exc: @@ -811,7 +1842,7 @@ def _resolve_parameters( f"Failed to resolve type hints for {func.__qualname__}. Ensure all annotations use valid, importable types." ) from exc - resolved: list[_ResolvedParam] = [] + resolved: list[_ArgparseArgument] = [] # Skip the first parameter by position (self/cls for methods) params = list(sig.parameters.items()) @@ -822,96 +1853,46 @@ def _resolve_parameters( if name in skip_params: continue - if param.kind == inspect.Parameter.POSITIONAL_ONLY: - raise TypeError( - f"Parameter {name!r} in {func.__qualname__} is positional-only, " - "which is not supported by @with_annotated because parameters are passed as keyword arguments." - ) - - if param.kind == inspect.Parameter.VAR_KEYWORD: - raise TypeError( - f"Parameter '**{name}' in {func.__qualname__} is variadic keyword (**kwargs), " - "which is not supported by @with_annotated because there is no native way to map " - "command-line arguments onto arbitrary keyword names." - ) - - if name in _RESERVED_PARAM_NAMES: - raise ValueError( - f"Parameter name {name!r} in {func.__qualname__} is reserved by argparse " - f"and cannot be used as an annotated parameter name." - ) - - if param.kind == inspect.Parameter.VAR_POSITIONAL: - # ``*args: T`` is a variadic positional: zero or more values (nargs='*') - # collected into a tuple. The hint gives the element type T (the type of - # each value). Peel any Annotated/Optional so we see the real element - # type and any Argument() metadata (help text, metavar, choices, ...). - element, metadata, _element_optional = _normalize_annotation(hints.get(name, str)) - - if isinstance(metadata, Option): - raise TypeError( - f"Parameter '*{name}' in {func.__qualname__} uses Option() metadata, but *args is " - f"always a positional argument. Use Argument() metadata instead." - ) - if isinstance(metadata, Argument) and metadata.nargs is not None: - raise TypeError( - f"Parameter '*{name}' in {func.__qualname__} sets nargs={metadata.nargs!r} via Argument(), " - f"but *args always accepts zero or more values (nargs='*') and its arity cannot be overridden." - ) - - # Annotating *args with a collection -- e.g. ``*args: tuple[str, ...]`` -- would - # mean each value is itself a tuple (a tuple-of-collections), which cannot be - # mapped onto a flat command line. - _, element_kwargs = _resolve_type(element, is_positional=True) - if element_kwargs.get("is_collection"): - # Show the parametrized form (e.g. ``tuple[str, ...]``), not the bare origin. - element_display = str(element) if get_origin(element) is not None else _type_name(element) - raise TypeError( - f"Parameter '*{name}' in {func.__qualname__} is annotated with the collection type " - f"'{element_display}'. For *args the annotation is the type of each value, not the " - f"collected tuple, so '*{name}: {element_display}' would mean a tuple of " - f"'{element_display}'. Annotate the element type instead " - f"(e.g. '*{name}: str'); values are always collected into a tuple." - ) - - # Each value has type ``element``; values are collected into a tuple (nargs='*'). - # ``is_optional=True`` selects nargs='*' (zero or more); any Argument() metadata - # (help text, metavar, choices) is applied to the variadic positional. - variadic_tuple = types.GenericAlias(tuple, (element, ...)) - _, kwargs = _resolve_type(variadic_tuple, is_positional=True, is_optional=True, metadata=metadata) - kwargs.pop("is_collection", None) - kwargs.pop("base_type", None) - positional = True - else: - annotation = hints.get(name, param.annotation) - has_default = param.default is not inspect.Parameter.empty - default = param.default if has_default else None - is_kw_only = param.kind == inspect.Parameter.KEYWORD_ONLY - - kwargs, metadata, positional = _resolve_annotation( - annotation, - has_default=has_default, - default=default, - is_kw_only=is_kw_only, - ) - - if is_kw_only and isinstance(metadata, Argument): - raise TypeError( - f"Parameter '{name}' in {func.__qualname__} is keyword-only but uses Argument() metadata, " - f"which marks it as a positional argument. Keyword-only parameters always become options; " - f"use Option() metadata (or omit the metadata) instead." - ) - - if positional: - flags: list[str] = [] - else: - flags = ( - list(metadata.names) if isinstance(metadata, Option) and metadata.names else [f"--{name.replace('_', '-')}"] - ) - kwargs["dest"] = name - - resolved.append((name, metadata, positional, flags, kwargs)) - + # *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 + # Peel Annotated then Optional. For *args the annotation is the element type T, modeled as a + # variadic tuple[T, ...]; its own optionality is dropped (*args is always a possibly-empty tuple). + inner_type, metadata, is_optional = _normalize_annotation(hints.get(name, str if is_variadic else param.annotation)) + if is_variadic: + inner_type = types.GenericAlias(tuple, (inner_type, ...)) + is_optional = False + arg = _ArgparseArgument( + name=name, + func_qualname=func.__qualname__, + has_default=has_default, + param_default=param.default if has_default else None, + is_kw_only=param.kind == inspect.Parameter.KEYWORD_ONLY, + is_variadic=is_variadic, + inner_type=inner_type, + metadata=metadata, + is_optional=is_optional, + kind=param.kind, + is_base_command=base_command, + ) + resolved.append(arg) + + # 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] + 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} + # Reject group references to nonexistent parameters before the constraint table runs. + all_param_names = set(by_name) + for spec in groups or (): + spec._validate_members(all_param_names=all_param_names, group_type="groups") + for spec in mutually_exclusive_groups or (): + spec._validate_members(all_param_names=all_param_names, group_type="mutually_exclusive_groups") + _link_group_membership(by_name, groups, lambda a: a.argument_group_indices) + _link_group_membership(by_name, mutually_exclusive_groups, lambda a: a.mutex_group_indices) + for arg in resolved: + arg._check_constraints() return resolved @@ -975,30 +1956,28 @@ def _build_argument_group_targets( parser: argparse.ArgumentParser, *, groups: tuple[Group, ...] | None, - all_param_names: set[str], ) -> tuple[dict[str, _ArgumentTarget], dict[str, argparse._ArgumentGroup]]: - """Build argument groups and return add_argument targets for their members.""" + """Build argument groups and return add_argument targets for their members. + + Member references and double-assignment are validated upstream by :func:`_resolve_parameters` + (via :meth:`Group._validate_members`) and :data:`_CONSTRAINTS` (the ``argument_group_indices`` + fact), so construction can assign each member unconditionally. + """ target_for: dict[str, _ArgumentTarget] = {} argument_group_for: dict[str, argparse._ArgumentGroup] = {} - argument_group_index_for: dict[str, int] = {} if not groups: return target_for, argument_group_for - for index, spec in enumerate(groups, start=1): - spec._validate_members(all_param_names=all_param_names, group_type="groups") - member_names = spec.members - for name in member_names: - if name in argument_group_for: - raise ValueError( - f"parameter {name!r} cannot be assigned to both argument " - f"group {argument_group_index_for[name]} and argument group {index}" - ) - + for spec in groups: + if spec.required: + raise ValueError( + "Group(required=True) is only valid in mutually_exclusive_groups; " + "argparse's add_argument_group has no 'required' flag" + ) group = parser.add_argument_group(title=spec.title, description=spec.description) - for name in member_names: + for name in spec.members: argument_group_for[name] = group - argument_group_index_for[name] = index target_for[name] = group return target_for, argument_group_for @@ -1010,20 +1989,19 @@ def _apply_mutex_group_targets( target_for: dict[str, _ArgumentTarget], argument_group_for: dict[str, argparse._ArgumentGroup], mutually_exclusive_groups: tuple[Group, ...] | None, - all_param_names: set[str], ) -> None: - """Build mutually exclusive groups and update add_argument targets for their members.""" - mutex_target_for: dict[str, argparse._MutuallyExclusiveGroup] = {} + """Build mutually exclusive groups and update add_argument targets for their members. + Member references, double-assignment, and required-member rejections are validated upstream by + :func:`_resolve_parameters` and :data:`_CONSTRAINTS` (the ``mutex_group_indices`` fact); the + remaining check -- a mutex group spanning different argument groups -- stays here because its + subject is the group, not an argument. + """ if not mutually_exclusive_groups: return for index, spec in enumerate(mutually_exclusive_groups, start=1): - spec._validate_members(all_param_names=all_param_names, group_type="mutually_exclusive_groups") member_names = spec.members - for name in member_names: - if name in mutex_target_for: - raise ValueError(f"parameter {name!r} cannot be assigned to multiple mutually exclusive groups") parent_groups = {argument_group_for[name] for name in member_names if name in argument_group_for} if len(parent_groups) > 1: @@ -1033,22 +2011,41 @@ def _apply_mutex_group_targets( ) mutex_parent: _ArgumentTarget = next(iter(parent_groups)) if parent_groups else parser - mutex_group = mutex_parent.add_mutually_exclusive_group() + mutex_group = mutex_parent.add_mutually_exclusive_group(required=spec.required) for name in member_names: - mutex_target_for[name] = mutex_group target_for[name] = mutex_group +def _docstring_first_paragraph(doc: str | None) -> str | None: + """Return the first paragraph of *doc* (everything before the first blank line), or ``None``. + + Used to auto-fill ``description`` from ``func.__doc__`` when the caller didn't pass one. + Subsequent paragraphs are intentionally ignored: rst field directives (``:param:``, ``:return:``) + routinely live below the summary and would render as nonsense in ``--help``. + """ + if not doc: + return None + cleaned = inspect.cleandoc(doc).strip() + if not cleaned: + return None + # Stop at the first blank line OR the first rst field directive (``:param:``, ``:return:``, ...); + # a directive that immediately follows the summary with no blank line would otherwise leak into --help. + summary_lines: list[str] = [] + for line in cleaned.splitlines(): + if not line.strip() or line.lstrip().startswith(":"): + break + summary_lines.append(line) + return "\n".join(summary_lines).strip() or None + + def build_parser_from_function( func: Callable[..., Any], *, skip_params: frozenset[str] = _SKIP_PARAMS, groups: tuple[Group, ...] | None = None, mutually_exclusive_groups: tuple[Group, ...] | None = None, - description: str | None = None, - epilog: str | None = None, - formatter_class: type[Cmd2HelpFormatter] | None = None, parser_class: type[Cmd2ArgumentParser] | None = None, + **parser_kwargs: Unpack[Cmd2ParserKwargs], ) -> Cmd2ArgumentParser: """Inspect a function's signature and build a ``Cmd2ArgumentParser``. @@ -1057,53 +2054,78 @@ def build_parser_from_function( ``Annotated[T, Argument(...)]`` or ``Annotated[T, Option(...)]`` overrides the default behavior. + Any kwarg accepted by :class:`~cmd2.Cmd2ArgumentParser`'s constructor + (``description``, ``epilog``, ``prog``, ``usage``, ``parents``, + ``argument_default``, ``prefix_chars``, ``fromfile_prefix_chars``, + ``conflict_handler``, ``add_help``, ``allow_abbrev``, ``exit_on_error``, + ``formatter_class``, ``ap_completer_type``, plus Python >= 3.14's + ``suggest_on_error`` and ``color``) is forwarded via ``**parser_kwargs``; + see :class:`Cmd2ParserKwargs` for the canonical list and IDE + autocomplete. + + When ``description`` is omitted from ``parser_kwargs``, the first paragraph + of ``func.__doc__`` (up to the first blank line) is used. + :param func: the command function to inspect :param skip_params: parameter names to exclude from the parser :param groups: :class:`Group` instances assigning parameter names to argument groups (for help display) :param mutually_exclusive_groups: :class:`Group` instances of mutually exclusive parameters - :param description: parser description (shown in ``--help``) - :param epilog: parser epilog text (shown at the end of ``--help``) - :param formatter_class: custom help formatter class for the parser - :param parser_class: custom parser class (defaults to the configured default) + :param parser_class: custom parser class (defaults to the configured default). + The chosen class must accept whatever subset of + :class:`Cmd2ParserKwargs` you pass. + :param parser_kwargs: forwarded :class:`Cmd2ParserKwargs` :return: a fully configured ``Cmd2ArgumentParser`` """ - from .argparse_utils import DEFAULT_ARGUMENT_PARSER - parser_cls = parser_class or DEFAULT_ARGUMENT_PARSER - parser_kwargs: dict[str, Any] = {} - if description is not None: - parser_kwargs["description"] = description - if epilog is not None: - parser_kwargs["epilog"] = epilog - if formatter_class is not None: - parser_kwargs["formatter_class"] = formatter_class + if "description" not in parser_kwargs: + auto_description = _docstring_first_paragraph(func.__doc__) + if auto_description is not None: + parser_kwargs["description"] = auto_description parser = parser_cls(**parser_kwargs) - resolved = _resolve_parameters(func, skip_params=skip_params) - - # Phase 2: build group lookup - all_param_names = {name for name, *_rest in resolved} - target_for, argument_group_for = _build_argument_group_targets( - parser, + # _resolve_parameters validates each argument and the cross-argument/cross-config rules (e.g. a + # variable-arity positional must be last; double-assignment and required-mutex-member) once the + # whole list is built and the group memberships are linked. + resolved = _resolve_parameters( + func, + skip_params=skip_params, groups=groups, - all_param_names=all_param_names, + mutually_exclusive_groups=mutually_exclusive_groups, ) + + # ``argument_default=argparse.SUPPRESS`` removes an absent argument from the parsed namespace. + # That is safe only for arguments that are always supplied (required) or carry their own default; + # an *omittable* argument with no default (e.g. a ``T | None`` positional -> nargs='?') would be + # dropped when absent, leaving the function without a keyword argument it expects. ``*args`` is + # exempt: the invocation path substitutes an empty tuple for it. Reject the combination here, + # mirroring the per-argument ``default=argparse.SUPPRESS`` rejection. + if parser_kwargs.get("argument_default") is argparse.SUPPRESS: + dropped = [ + arg.name + for arg in resolved + if arg.default is _UNSET and arg.omittable and not arg.required and not arg.is_variadic + ] + if dropped: + raise TypeError( + f"argument_default=argparse.SUPPRESS is not supported by @with_annotated for {func.__qualname__}: " + f"it would drop {dropped!r} from the parsed namespace when absent, but the function expects " + f"{'them' if len(dropped) > 1 else 'it'} as a keyword argument. Give each an explicit default or " + f"make it required, or drop argument_default=argparse.SUPPRESS." + ) + + # Build the group lookup (member references already validated by _resolve_parameters). + target_for, argument_group_for = _build_argument_group_targets(parser, groups=groups) _apply_mutex_group_targets( parser, target_for=target_for, argument_group_for=argument_group_for, mutually_exclusive_groups=mutually_exclusive_groups, - all_param_names=all_param_names, ) - # Phase 3: add arguments to appropriate targets - for name, _metadata, positional, flags, kwargs in resolved: - target = target_for.get(name, parser) - if positional: - target.add_argument(name, **kwargs) - else: - target.add_argument(*flags, **kwargs) + # Add each argument to its target (its group/mutex group if assigned, else the parser). + for arg in resolved: + arg.add_to(target_for.get(arg.name, parser)) return parser @@ -1122,17 +2144,70 @@ def _derive_subcommand_name(func: Callable[..., Any], subcommand_to: str) -> str return func.__name__[len(expected_prefix) :] -def build_subcommand_handler( +@dataclass(frozen=True) +class _ParserBuildOptions: + """The parser/subcommand configuration shared by every ``with_annotated`` build path. + + These options are a data clump threaded identically through the regular-command and subcommand + builders; bundling them lets :func:`_make_parser_builder` host the single deferred build flow. + """ + + groups: tuple[Group, ...] | None = None + mutually_exclusive_groups: tuple[Group, ...] | None = None + parser_class: type[Cmd2ArgumentParser] | None = None + #: Forwarded :class:`Cmd2ParserKwargs` (description, epilog, prog, ...). + #: Stored as a plain ``dict`` so missing keys yield argparse's defaults + #: rather than this layer second-guessing them. + parser_kwargs: dict[str, Any] = field(default_factory=dict) + subcommand_required: bool = True + subcommand_metavar: str = "SUBCOMMAND" + subcommand_title: str | None = None + subcommand_description: str | None = None + + +def _make_parser_builder( + func: Callable[..., Any], + *, + skip_params: frozenset[str], + base_command: bool, + options: _ParserBuildOptions, +) -> Callable[[], Cmd2ArgumentParser]: + """Return the deferred builder for *func*'s parser (adds the subcommands group when ``base_command``). + + Shared by the regular-command and subcommand decorators so the build flow lives in one place. + """ + + def parser_builder() -> Cmd2ArgumentParser: + parser = build_parser_from_function( + func, + skip_params=skip_params, + groups=options.groups, + mutually_exclusive_groups=options.mutually_exclusive_groups, + parser_class=options.parser_class, + **options.parser_kwargs, + ) + if base_command: + # dict[str, Any] is load-bearing: the typeshed stub types title/metavar as non-None, + # but argparse accepts None at runtime, so splatting avoids a false overload error. + kwargs: dict[str, Any] = { + "dest": "subcommand", + "metavar": options.subcommand_metavar, + "required": options.subcommand_required, + "title": options.subcommand_title, + "description": options.subcommand_description, + } + parser.add_subparsers(**kwargs) + return parser + + return parser_builder + + +def _build_subcommand_handler( func: Callable[..., Any], subcommand_to: str, *, base_command: bool = False, - groups: tuple[Group, ...] | None = None, - mutually_exclusive_groups: tuple[Group, ...] | None = None, - description: str | None = None, - epilog: str | None = None, - formatter_class: type[Cmd2HelpFormatter] | None = None, - parser_class: type[Cmd2ArgumentParser] | None = None, + options: _ParserBuildOptions, ) -> tuple[Callable[..., Any], str, Callable[[], Cmd2ArgumentParser]]: """Build a subcommand handler wrapper and its parser from type annotations. @@ -1143,18 +2218,14 @@ def build_subcommand_handler( :param func: the subcommand handler function :param subcommand_to: parent command name (space-delimited for nesting) :param base_command: if True, the parser also gets ``add_subparsers()`` - :param groups: :class:`Group` instances assigning parameter names to argument groups - :param mutually_exclusive_groups: :class:`Group` instances of mutually exclusive parameters - :param description: parser description (shown in ``--help``) - :param epilog: parser epilog text (shown at the end of ``--help``) - :param formatter_class: custom help formatter class for the parser - :param parser_class: custom parser class (defaults to the configured default) + :param options: shared parser/subcommand configuration (see :class:`_ParserBuildOptions`) :return: ``(handler, subcommand_name, parser_builder)`` """ subcmd_name = _derive_subcommand_name(func, subcommand_to) if base_command: - _validate_base_command_params(func) + # Validate eagerly (decoration time); the base-command rows in _CONSTRAINTS fire here. + _resolve_parameters(func, base_command=True) _accepted = set(list(inspect.signature(func).parameters.keys())[1:]) _leading_names, _var_positional_name = _var_positional_call_plan(func) @@ -1167,20 +2238,7 @@ def handler(self_arg: Any, ns: Any) -> Any: func, self_arg, filtered, leading_names=_leading_names, var_positional_name=_var_positional_name ) - def parser_builder() -> Cmd2ArgumentParser: - parser = build_parser_from_function( - func, - groups=groups, - mutually_exclusive_groups=mutually_exclusive_groups, - description=description, - epilog=epilog, - formatter_class=formatter_class, - parser_class=parser_class, - ) - if base_command: - parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND", required=True) - return parser - + parser_builder = _make_parser_builder(func, skip_params=_SKIP_PARAMS, base_command=base_command, options=options) return handler, subcmd_name, parser_builder @@ -1199,12 +2257,15 @@ def with_annotated( subcommand_to: str | None = ..., help: str | None = ..., aliases: Sequence[str] = ..., + deprecated: bool = ..., groups: tuple[Group, ...] | None = ..., mutually_exclusive_groups: tuple[Group, ...] | None = ..., - description: str | None = ..., - epilog: str | None = ..., - formatter_class: type[Cmd2HelpFormatter] | None = ..., parser_class: type[Cmd2ArgumentParser] | None = ..., + subcommand_required: bool = ..., + subcommand_metavar: str = ..., + subcommand_title: str | None = ..., + subcommand_description: str | None = ..., + **parser_kwargs: Unpack[Cmd2ParserKwargs], ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ... @@ -1218,12 +2279,15 @@ def with_annotated( subcommand_to: str | None = None, help: str | None = None, # noqa: A002 aliases: Sequence[str] = (), + deprecated: bool = False, groups: tuple[Group, ...] | None = None, mutually_exclusive_groups: tuple[Group, ...] | None = None, - description: str | None = None, - epilog: str | None = None, - formatter_class: type[Cmd2HelpFormatter] | None = None, parser_class: type[Cmd2ArgumentParser] | None = None, + subcommand_required: bool = True, + subcommand_metavar: str = "SUBCOMMAND", + subcommand_title: str | None = None, + subcommand_description: str | None = None, + **parser_kwargs: Unpack[Cmd2ParserKwargs], ) -> Callable[..., Any] | Callable[[Callable[..., Any]], Callable[..., Any]]: """Decorate a ``do_*`` method to build its argparse parser from type annotations. @@ -1240,13 +2304,28 @@ def with_annotated( Function must be named ``{parent_underscored}_{subcommand}``. :param help: help text for the subcommand (only valid with ``subcommand_to``) :param aliases: alternative names for the subcommand (only valid with ``subcommand_to``) + :param deprecated: mark the subcommand as deprecated in ``--help`` (only valid with ``subcommand_to``) :param groups: :class:`Group` instances assigning parameter names to argument groups (pass ``title``/``description`` for a titled section) :param mutually_exclusive_groups: :class:`Group` instances of mutually exclusive parameters - :param description: parser description (shown in ``--help``) - :param epilog: parser epilog text (shown at the end of ``--help``) - :param formatter_class: custom help formatter class for the parser :param parser_class: custom parser class (defaults to the configured default) + :param subcommand_required: whether a subcommand must be supplied (only with ``base_command``) + :param subcommand_metavar: metavar shown for the subcommands group (only with ``base_command``) + :param subcommand_title: title for the subcommands ``--help`` section (only with ``base_command``) + :param subcommand_description: description for the subcommands ``--help`` section (only with ``base_command``) + :param parser_kwargs: any kwarg accepted by :class:`~cmd2.Cmd2ArgumentParser`'s + constructor (see :class:`Cmd2ParserKwargs` for the full list and + per-field types). IDEs/type-checkers surface these on the call + site via PEP 692 ``Unpack``. Notable behaviors layered on top of + the raw passthrough: + + - ``description`` -- when omitted, the first paragraph of the + function's docstring (up to the first blank line) is used; + pass an explicit value to override that. + - ``prog`` -- rejected when ``subcommand_to`` is set, because + cmd2's subcommand machinery rewrites ``prog`` from the parent + command hierarchy and any value here would be silently + overwritten. Example:: @@ -1261,8 +2340,8 @@ def do_team(self, *, cmd2_handler): ... def team_create(self, name: str): ... """ - if (help is not None or aliases) and subcommand_to is None: - raise TypeError("'help' and 'aliases' are only valid with subcommand_to") + if (help is not None or aliases or deprecated) and subcommand_to is None: + raise TypeError("'help', 'aliases', and 'deprecated' are only valid with subcommand_to") if subcommand_to is not None: unsupported: list[str] = [] if ns_provider is not None: @@ -1271,6 +2350,10 @@ def team_create(self, name: str): ... unsupported.append("preserve_quotes") if with_unknown_args: unsupported.append("with_unknown_args") + if "prog" in parser_kwargs: + # cmd2's subcommand machinery (``update_prog``) rewrites prog from the parent + # command hierarchy, so any value supplied here would be silently overwritten. + unsupported.append("prog") if unsupported: names = ", ".join(unsupported) raise TypeError( @@ -1278,6 +2361,17 @@ def team_create(self, name: str): ... "Configure these behaviors on the base command instead." ) + options = _ParserBuildOptions( + groups=groups, + mutually_exclusive_groups=mutually_exclusive_groups, + parser_class=parser_class, + parser_kwargs=dict(parser_kwargs), + subcommand_required=subcommand_required, + subcommand_metavar=subcommand_metavar, + subcommand_title=subcommand_title, + subcommand_description=subcommand_description, + ) + def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: if with_unknown_args: unknown_param = inspect.signature(fn).parameters.get("_unknown") @@ -1292,22 +2386,18 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: ) if subcommand_to is not None: - handler, subcmd_name, subcmd_parser_builder = build_subcommand_handler( + handler, subcmd_name, subcmd_parser_builder = _build_subcommand_handler( fn, subcommand_to, base_command=base_command, - groups=groups, - mutually_exclusive_groups=mutually_exclusive_groups, - description=description, - epilog=epilog, - formatter_class=formatter_class, - parser_class=parser_class, + options=options, ) spec = SubcommandSpec( name=subcmd_name, command=subcommand_to, help=help, aliases=tuple(aliases), + deprecated=deprecated, parser_source=subcmd_parser_builder, ) setattr(handler, constants.SUBCMD_ATTR_SPEC, spec) @@ -1317,26 +2407,14 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: skip_params = _SKIP_PARAMS | ({"_unknown"} if with_unknown_args else frozenset()) if base_command: - _validate_base_command_params(fn, skip_params=skip_params) + # Validate eagerly (decoration time); the base-command rows in _CONSTRAINTS fire here. + _resolve_parameters(fn, skip_params=skip_params, base_command=True) # Cache signature introspection at decoration time, not per-invocation accepted = set(list(inspect.signature(fn).parameters.keys())[1:]) leading_names, var_positional_name = _var_positional_call_plan(fn) - def parser_builder() -> Cmd2ArgumentParser: - parser = build_parser_from_function( - fn, - skip_params=skip_params, - groups=groups, - mutually_exclusive_groups=mutually_exclusive_groups, - description=description, - epilog=epilog, - formatter_class=formatter_class, - parser_class=parser_class, - ) - if base_command: - parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND", required=True) - return parser + parser_builder = _make_parser_builder(fn, skip_params=skip_params, base_command=base_command, options=options) @functools.wraps(fn) def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: diff --git a/docs/features/annotated.md b/docs/features/annotated.md index b32e9ff19..d06489e83 100644 --- a/docs/features/annotated.md +++ b/docs/features/annotated.md @@ -117,7 +117,8 @@ Unsupported patterns raise `TypeError`, including: by the tuple. The tuple type already pins `nargs`; user metadata cannot change it. The parameter names `dest` and `subcommand` are reserved and may not be used as annotated parameter -names. +names. `cmd2_statement` receives the parsed [cmd2.Statement][] object, and `cmd2_handler` (only on a +command decorated with `@with_annotated(base_command=True)`) receives the subcommand handler. ## Annotated metadata @@ -155,10 +156,73 @@ Both `Argument` and `Option` accept the same cmd2-specific fields as `add_argume `Option` additionally accepts `action`, `required`, and positional `*names` for custom flag strings (e.g. `Option("--color", "-c")`). -When an `Option(action=...)` uses an argparse action that does not accept `type=` (`count`, -`store_true`, `store_false`, `store_const`, `help`, `version`), `@with_annotated` removes any -inferred `type` converter before calling `add_argument()`. This matches argparse behavior and avoids -parser-construction errors such as combining `action='count'` with `type=int`. +When an `Option(action=...)` uses a zero-argument argparse action that takes no value from the +command line (`count`, `store_true`, `store_false`, `store_const`, `append_const`), +`@with_annotated` removes the value-oriented metadata inferred from the type before calling +`add_argument()`: the `type` converter, the static `choices`, and any inferred tab-completer (such +as the path completer for `Path`) or `choices_provider`. This matches argparse behavior (which +rejects a completer on a value-less action) and avoids parser-construction errors such as combining +`action='count'` with `type=int`. Actions that do consume values (`append` / `extend` on a +`list[T]`, or a plain value option) keep the inferred converter and completer. `action='help'` and +`action='version'` are not supported. + +Pairing `const` with an explicit `nargs` on a scalar `Option` selects argparse's optional-value +idiom instead of `store_const`. `Annotated[str | None, Option("--log", nargs='?', const="CONSOLE")]` +keeps the `store` action and the inferred `type` converter, so the flag is three-way: absent yields +the default, a bare `--log` yields the `const`, and `--log VALUE` yields the converted `VALUE`. The +`const` is stored verbatim (it is not run through the converter), so it must already match the +declared type. Without an explicit `nargs`, `const` alone still infers the value-less `store_const` +(present yields the `const`, and supplying a value is an error). + +`Option(action=...)` also accepts a custom `argparse.Action` subclass. The class is passed straight +through to `add_argument()` and owns storage of the parsed value, so the type-inferred collection +casting and the action-specific type/const/shape constraints are skipped; the inferred `type=` +converter, default, and `required` are still applied so the class receives them like any hand-built +`add_argument()` call. + +```py +class UpperAction(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + setattr(namespace, self.dest, values.upper()) + +@with_annotated +def do_shout(self, name: Annotated[str, Option("--name", action=UpperAction)] = ""): + self.poutput(name) +``` + +`action='help'` and `action='version'` are not supported by `@with_annotated`; use `@with_argparser` +if you need them. + +`Argument()` and `Option()` refuse a handful of `add_argument()` kwargs that the decorator derives +from the function signature itself, so misusing them surfaces as a clear `TypeError` instead of a +silent override. The refused kwargs are: + +- `type` -- comes from the parameter annotation +- `dest` -- comes from the parameter name +- `action` and `required` on `Argument` -- only `Option` accepts them; positional arguments have no + action and are required unless they carry a default or `| None` + +Every other `add_argument()` parameter passes through, including any custom parameter registered via +[register_argparse_argument_parameter][cmd2.argparse_utils.register_argparse_argument_parameter]. + +A `default` may be supplied either through the function signature or as a metadata kwarg. The two +forms are equivalent: + +```py +# Signature default +def do_x(self, name: Annotated[str, Option("--name")] = "HI"): ... + +# Metadata default (same behaviour) +def do_x(self, name: Annotated[str, Option("--name", default="HI")]): ... +``` + +Specifying both at the same time is a conflict and raises `TypeError`. `argparse.SUPPRESS` is +rejected as a default from either source, because suppressing the namespace attribute would call the +function without the keyword argument it expects. + +Parser-construction kwargs such as `add_help`, `prefix_chars`, `fromfile_prefix_chars`, +`argument_default`, `conflict_handler`, and `allow_abbrev` are not exposed by `@with_annotated`. Set +them on a custom `parser_class` subclass and pass it via `parser_class=`. When a user-supplied `choices_provider` or `completer` is given for an inferred `Enum` or `Literal`, the inferred static `choices` list is dropped so completion is driven by the provider or completer. @@ -166,6 +230,21 @@ The inferred `type` converter is preserved, so parsed values still coerce to the (`Literal[1, 2]` yields an `int`, an `Enum` yields its member) and values outside the type are rejected at parse time. +An explicit `choices=` is reconciled with the inferred type rather than fighting it: + +- The values are run through the inferred `type` converter so they match argparse's post-conversion + comparison. `Annotated[int, Option("--n", choices=["1", "2"])]` is normalized to `choices=[1, 2]`, + so `--n 1` is accepted. A choice the converter rejects (`choices=["1", "nope"]` on an `int`) is a + build-time `TypeError`. Values already of the declared type are left as-is. +- An explicit `choices=` takes precedence over a _type-inferred_ completer (such as the `Path` + completer): the choices are kept (so they validate and drive completion) and the inferred + completer is dropped. A `choices_provider`/`completer` you pass yourself still wins over + `choices=`. + +An `Enum` parameter accepts both member **values** and member **names** on the command line +(`Color.RED` with value `"red"` is selected by either `red` or `RED`); tab-completion and `--help` +list the values. + ## Decorator options `@with_annotated` currently supports: @@ -177,14 +256,30 @@ rejected at parse time. - `base_command` -- create a base command whose parser also adds subparsers and exposes `cmd2_handler`. A `cmd2_handler` parameter is only valid on a command decorated with `base_command=True`; declaring one elsewhere raises `TypeError`. -- `help` -- help text for an annotated subcommand -- `aliases` -- aliases for an annotated subcommand +- `subcommand_required` -- whether a subcommand must be supplied (only with `base_command=True`, + default `True`) +- `subcommand_metavar` -- metavar shown for the subcommands group (only with `base_command=True`, + default `"SUBCOMMAND"`) +- `subcommand_title` -- title for the subcommands `--help` section (only with `base_command=True`) +- `subcommand_description` -- description for the subcommands `--help` section (only with + `base_command=True`) +- `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 -- `description` -- parser description shown in `--help` -- `epilog` -- parser epilog shown at the end of `--help` -- `formatter_class` -- a custom help formatter class for the parser - `parser_class` -- a custom parser class (defaults to the configured default) +- `**parser_kwargs` -- every other parser-construction kwarg accepted by `Cmd2ArgumentParser` is + forwarded through PEP 692 + [`Unpack`][typing.Unpack][`[Cmd2ParserKwargs]`][cmd2.annotated.Cmd2ParserKwargs]: `description`, + `epilog`, `prog`, `usage`, `parents`, `argument_default`, `prefix_chars`, `fromfile_prefix_chars`, + `conflict_handler`, `add_help`, `allow_abbrev`, `exit_on_error`, `formatter_class`, + `ap_completer_type`, and on Python ≥ 3.14 `suggest_on_error` / `color`. Two behaviors layer on + top of the raw passthrough: + - `description` -- when omitted, the first paragraph of the function's docstring (up to the + first blank line) is used; pass an explicit value to override. + - `prog` -- rejected when `subcommand_to` is set; cmd2's subcommand machinery rewrites `prog` + from the parent command hierarchy and any value here would be silently overwritten. ```py @with_annotated(with_unknown_args=True) @@ -194,9 +289,17 @@ def do_rawish(self, name: str, _unknown: list[str] | None = None): ## Parser customization -`description`, `epilog`, `formatter_class`, and `parser_class` are passed through to the generated -parser. Argument groups are declared with [Group][cmd2.annotated.Group]; pass `title` and -`description` for a titled help section (omit them for an untitled group): +Every `Cmd2ArgumentParser` constructor kwarg flows straight through `@with_annotated` and +`build_parser_from_function` via PEP 692 +[`Unpack[Cmd2ParserKwargs]`][cmd2.annotated.Cmd2ParserKwargs]. The +[`Cmd2ParserKwargs`][cmd2.annotated.Cmd2ParserKwargs] `TypedDict` is the single source of truth for +the forwarded kwargs and gives type-checkers/IDEs autocomplete on the decorator's call site: adding +a new ctor kwarg to `Cmd2ArgumentParser` only needs a matching field on `Cmd2ParserKwargs`, and the +annotated decorator picks it up automatically. + +`parser_class` stays as its own explicit kwarg because it selects the class itself rather than a +value passed to it. Argument groups are declared with [Group][cmd2.annotated.Group]; pass `title` +and `description` for a titled help section (omit them for an untitled group): ```py from cmd2.annotated import Group, with_annotated @@ -211,8 +314,59 @@ class App(cmd2.Cmd): self.poutput(f"connecting to {host}:{port}") ``` +If you omit `description`, the first paragraph of the function's docstring (everything up to the +first blank line) is used as the parser description; subsequent paragraphs are dropped so rst field +directives like `:param name:` don't leak into `--help`. Pass `description=""` to suppress the +automatic fill, or `description="..."` to override it. + +```py +@with_annotated +def do_greet(self, name: str): + """Greet someone by name. + + :param name: who to greet + """ + self.poutput(f"hello {name}") +# parser.description == "Greet someone by name." +``` + `mutually_exclusive_groups` also takes `Group` instances (their `title`/`description` are ignored, -since argparse mutually-exclusive groups have no header). +since argparse mutually-exclusive groups have no header). Pass `Group(..., required=True)` to make +the mutex group itself required -- argparse will then enforce that exactly one of its members must +be supplied. `required=True` is rejected on a plain (non-mutex) `Group` because `add_argument_group` +has no `required` flag. + +```py +@with_annotated( + mutually_exclusive_groups=(Group("verbose", "quiet", required=True),), +) +def do_run(self, verbose: bool = False, quiet: bool = False): ... +``` + +`parents=` mirrors argparse's standard parents mechanism for sharing argument definitions across +parsers. `argument_default=argparse.SUPPRESS` is accepted only when no argument could be stranded by +it: it removes an absent argument from the parsed namespace, which is safe for an argument that is +always supplied (a required option, a mandatory positional) or that carries its own default, but not +for an _omittable_ argument with no default (for example a `T | None` positional, which becomes +`nargs='?'`). If any such argument is present, `@with_annotated` raises `TypeError` rather than let +the function be called missing a keyword argument it expects (mirroring the per-argument +`default=argparse.SUPPRESS` rejection). `*args` is exempt, since the invocation path substitutes an +empty tuple for it. + +The remaining argparse kwargs cover less-common needs but are wired through unchanged: + +- `prefix_chars="+-"` accepts options that start with `+` (e.g. `+verbose`); pair with an explicit + `Option("+verbose")` to declare such flags. +- `fromfile_prefix_chars="@"` lets a user write `mycmd @args.txt` and have the file's contents + spliced in as arguments. +- `conflict_handler="resolve"` lets a parent parser's option be redefined locally without an error + -- useful with `parents=` when you want to override an inherited flag. +- `add_help=False` drops the auto-added `-h`/`--help` action (cmd2's standard parser keeps it on by + default). +- `allow_abbrev=False` requires users to type the full long-option name (no `--verb` for + `--verbose`). +- `exit_on_error=False` makes parse failures raise `argparse.ArgumentError` instead of calling + `sys.exit`; useful when embedding the parser inside another flow. ## Annotated subcommands @@ -250,8 +404,8 @@ def manage_project_add(self, name: str): [cmd2.annotated.build_parser_from_function][cmd2.annotated.build_parser_from_function] builds the parser directly from a function without registering a command. It accepts the same `groups`, -`mutually_exclusive_groups`, `description`, `epilog`, `formatter_class`, and `parser_class` -arguments as `@with_annotated`. +`mutually_exclusive_groups`, `parser_class`, and forwarded +[`Unpack[Cmd2ParserKwargs]`][cmd2.annotated.Cmd2ParserKwargs] as `@with_annotated`. ```py @with_annotated(preserve_quotes=True) diff --git a/examples/annotated_example.py b/examples/annotated_example.py index eb0945cb7..7322f20f5 100755 --- a/examples/annotated_example.py +++ b/examples/annotated_example.py @@ -6,7 +6,8 @@ the annotated style -- type inference, auto-completion from types, and typed function parameters -- while also demonstrating that all of cmd2's advanced completion features (choices_provider, completer, table_columns, -arg_tokens) remain available via ``Annotated`` metadata. +arg_tokens) remain available via ``Annotated`` metadata, as does argparse's +optional-value idiom (``nargs='?'`` with ``const``). Compare with ``argparse_completion.py`` which uses ``@with_argparser`` for the same completion features. @@ -32,6 +33,7 @@ from cmd2 import ( Choices, Cmd, + CompletionItem, ) from cmd2.annotated import ( Argument, @@ -81,14 +83,18 @@ def __init__(self) -> None: self._sports = ["Basketball", "Football", "Tennis", "Hockey"] self._default_region = "staging" - # -- Type inference: int, float, bool ------------------------------------ - # With @with_argparser you'd manually set type=int and action='store_true'. - # Here the decorator infers everything from the annotations. + # -- Type inference + typed parameters ----------------------------------- + # With @with_argparser you'd set type=int and action='store_true', then read + # args.a / args.verbose off a Namespace. Here the types are inferred from the + # annotations and each parameter arrives as an ordinary typed local variable. @with_annotated @cmd2.with_category(ANNOTATED_CATEGORY) def do_add(self, a: int, b: int = 0, verbose: bool = False) -> None: - """Add two integers. Types are inferred from annotations. + """Add two integers. Types are inferred; parameters are typed locals. + + ``a``/``b`` infer ``type=int`` and ``verbose: bool`` infers a flag -- and + each is a normal typed argument, not a ``Namespace`` attribute to unpack. Examples: add 2 --b 3 @@ -161,6 +167,25 @@ def do_build( parts.append("(no color)") self.poutput(" ".join(parts)) + # -- Count action (-vvv) ------------------------------------------------- + # action='count' turns a flag into a repeatable counter: each occurrence + # adds one, so ``-vvv`` arrives as 3. Set explicitly via Option(action=). + + @with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_log( + self, + message: str, + verbosity: Annotated[int, Option("-v", "--verbose", action="count", help_text="raise verbosity; repeatable")] = 0, + ) -> None: + """Log a message. Repeat ``-v`` to raise verbosity (``-vvv`` -> 3). + + Try: + log hello + log hello -vvv + """ + self.poutput(f"[v={verbosity}] {message}") + # -- List arguments ------------------------------------------------------ # With @with_argparser you'd set type=float and nargs='+'. # Here list[float] does both at once. @@ -195,6 +220,37 @@ def do_cat(self, *files: str, number: bool = False) -> None: for index, name in enumerate(files, start=1): self.poutput(f"{index}: {name}" if number else name) + # -- Optional positional (T | None) -------------------------------------- + # A scalar annotated ``T | None`` becomes an optional positional (nargs='?'): + # zero or one value, defaulting to None when omitted. A very common CLI shape. + + @with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_status(self, service: str | None) -> None: + """Show status for one service, or for all when the positional is omitted. + + Try: + status + status web + """ + self.poutput(f"status: {service or 'all services'}") + + # -- Ranged nargs (cmd2 extension) --------------------------------------- + # cmd2's patched argparse accepts a (min, max) nargs tuple. ``nargs=(2, 4)`` + # takes 2 to 4 values; fewer or more is rejected. Plain argparse cannot do this. + + @with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_plot(self, points: Annotated[list[int], Argument(nargs=(2, 4))]) -> None: + """Plot 2 to 4 integer points. cmd2 allows a ``(min, max)`` nargs range. + + Try: + plot 1 2 + plot 1 2 3 4 + plot 1 # rejected: needs at least 2 + """ + self.poutput(f"plotting {len(points)} points: {points}") + # -- Literal + Decimal --------------------------------------------------- # Literal values become validated choices. Decimal values preserve precision. @@ -215,21 +271,40 @@ def do_deploy( """ self.poutput(f"Deploying {service} in {mode} mode with budget {budget} and timeout {timeout}") - # -- Typed kwargs -------------------------------------------------------- - # With @with_argparser you'd access args.name, args.count on a Namespace. - # Here each parameter is a typed local variable. + # -- Optional value with const (nargs='?') + completion ------------------ + # A scalar Option with nargs='?' + const is argparse's optional-value idiom: + # flag absent -> default, bare flag -> const, ``flag VALUE`` -> converted VALUE. + # A completion provider tab-completes that optional value -- because the + # option still consumes a value, a completer/choices_provider is kept (it is + # only rejected on value-less actions like store_true). The provider suggests + # common sizes without restricting input: ``--size 999`` is still accepted. + + def common_sizes(self) -> Choices: + """choices_provider suggesting common cache sizes (suggestions only, not a constraint).""" + return Choices.from_values(["32", "64", "128", "256", "512"]) @with_annotated @cmd2.with_category(ANNOTATED_CATEGORY) - def do_greet(self, name: str, count: int = 1, loud: bool = False) -> None: - """Greet someone. Parameters are typed -- no Namespace unpacking. + def do_cache( + self, + name: str, + size: Annotated[ + int, + Option("--size", nargs="?", const=64, choices_provider=common_sizes, help_text="cache size in MB"), + ] = 0, + ) -> None: + """Configure caching. ``--size`` takes an optional value and tab-completes it. + + ``--size`` absent -> 0; bare ``--size`` -> 64 (the const); ``--size 256`` + -> 256 (the supplied value, converted to int). Try: - greet Alice --count 3 --loud + cache build + cache build --size + cache build --size # suggests 32 64 128 256 512 + cache build --size 256 """ - for _ in range(count): - msg = f"Hello {name}!" - self.poutput(msg.upper() if loud else msg) + self.poutput(f"{name}: cache size = {size} MB") # -- Advanced: choices_provider + arg_tokens ----------------------------- # These cmd2-specific features still work via Annotated metadata. @@ -276,6 +351,58 @@ def do_score( """ self.poutput(f"{sport}: {play} for {points} point(s)") + # -- Advanced: explicit completer ---------------------------------------- + # A completer wires a completion function onto an argument directly. Unlike + # the Path type (which auto-completes), here a plain ``str`` gets filesystem + # completion only because ``completer=Cmd.path_complete`` asks for it. + + @with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_load( + self, + config: Annotated[str, Argument(completer=Cmd.path_complete, help_text="config file to load")], + ) -> None: + """Load a config. ``completer=`` attaches a completer to a ``str`` arg. + + Try: + load ./ + """ + self.poutput(f"Loading config from {config}") + + # -- Advanced: table_columns --------------------------------------------- + # A choices_provider can return CompletionItems carrying extra data, and + # table_columns names the columns shown alongside each completion. + + def package_choices(self) -> Choices: + """choices_provider returning CompletionItems with a description column.""" + return Choices( + items=[ + CompletionItem("numpy", table_data=["numerical computing"]), + CompletionItem("rich", table_data=["terminal formatting"]), + CompletionItem("cmd2", table_data=["interactive CLIs"]), + ] + ) + + @with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_install( + self, + package: Annotated[ + str, + Argument( + choices_provider=package_choices, + table_columns=["Description"], + help_text="package to install", + ), + ], + ) -> None: + """Install a package. ``table_columns`` adds context columns to completions. + + Try: + install + """ + self.poutput(f"Installing {package}") + # -- Namespace provider -------------------------------------------------- # This mirrors one of @with_argparser's advanced features. diff --git a/tests/test_annotated.py b/tests/test_annotated.py index d53e5f234..c2d787de7 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -9,6 +9,8 @@ import datetime import decimal import enum +import inspect +import types import uuid from pathlib import Path from typing import ( @@ -29,18 +31,47 @@ Group, Option, _apply_mutex_group_targets, + _ArgparseArgument, _build_argument_group_targets, _CollectionCastingAction, _make_enum_type, _make_literal_type, + _normalize_annotation, _parse_bool, - _resolve_annotation, build_parser_from_function, with_annotated, ) +from cmd2.argparse_utils import register_argparse_argument_parameter from .conftest import run_cmd + +def _resolve_annotation(annotation: Any, *, has_default: bool = False, default: Any = None) -> _ArgparseArgument: + """Build and validate a single argument from a bare annotation (test helper). + + The library builds a whole parameter list in ``_resolve_parameters``; these unit tests exercise one + annotation in isolation, mirroring that step: peel the annotation, populate the builder, run the + validity table. A lone argument has no following positional or group membership, so the + cross-argument/cross-config rows in ``_CONSTRAINTS`` are naturally inert. + """ + inner_type, metadata, is_optional = _normalize_annotation(annotation) + arg = _ArgparseArgument( + name="arg", + func_qualname="", + has_default=has_default, + param_default=default, + 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=False, + ) + arg._check_constraints() + return arg + + # --------------------------------------------------------------------------- # Test enums # --------------------------------------------------------------------------- @@ -94,81 +125,7 @@ class _Port(int): def _func_empty(self) -> None: ... -def _func_str(self, name: str) -> None: ... -def _func_int_option(self, count: int = 1) -> None: ... -def _func_float_option(self, rate: float = 1.0) -> None: ... -def _func_bool_false(self, verbose: bool = False) -> None: ... -def _func_bool_true(self, debug: bool = True) -> None: ... -def _func_bool_positional(self, flag: bool) -> None: ... -def _func_path(self, file: Path) -> None: ... -def _func_path_option(self, file: Path = Path(".")) -> None: ... -def _func_decimal(self, amount: decimal.Decimal) -> None: ... -def _func_enum(self, color: _Color) -> None: ... -def _func_enum_option(self, color: _Color = _Color.blue) -> None: ... -def _func_literal(self, mode: Literal["fast", "slow"]) -> None: ... -def _func_literal_option(self, mode: Literal["fast", "slow"] = "fast") -> None: ... -def _func_literal_int(self, level: Literal[1, 2, 3]) -> None: ... -def _func_optional(self, name: str | None = None) -> None: ... -def _func_optional_positional(self, val: Annotated[int | None, Argument()]) -> None: ... -def _func_positional_with_default(self, arg: Annotated[str, Argument()] = "foo") -> None: ... -def _func_optional_plain(self, val: int | None) -> None: ... -def _func_optional_list(self, vals: list[int] | None) -> None: ... -def _func_optional_tuple_ellipsis(self, vals: tuple[int, ...] | None) -> None: ... -def _func_list(self, files: list[str]) -> None: ... -def _func_list_default(self, items: list[str] | None = None) -> None: ... -def _func_set(self, tags: set[str]) -> None: ... -def _func_tuple_ellipsis(self, values: tuple[int, ...]) -> None: ... -def _func_tuple_fixed(self, pair: tuple[int, int]) -> None: ... -def _func_bare_list(self, items: list) -> None: ... -def _func_bare_tuple(self, items: tuple) -> None: ... -def _func_annotated_arg(self, name: Annotated[str, Argument(help_text="Your name")]) -> None: ... -def _func_annotated_option(self, color: Annotated[str, Option("--color", "-c", help_text="Pick")] = "blue") -> None: ... -def _func_annotated_metavar(self, name: Annotated[str, Argument(metavar="NAME")]) -> None: ... -def _func_annotated_nargs(self, names: Annotated[tuple[str, ...], Argument(nargs=2)]) -> None: ... -def _func_annotated_action(self, verbose: Annotated[bool, Option("--verbose", "-v", action="count")] = False) -> None: ... -def _func_annotated_action_non_bool(self, count: Annotated[int, Option("--count", action="count")] = 0) -> None: ... -def _func_annotated_required(self, name: Annotated[str, Option("--name", required=True)]) -> None: ... -def _func_annotated_required_auto_flag(self, name: Annotated[str, Option(required=True)]) -> None: ... -def _func_annotated_choices(self, food: Annotated[str, Argument(choices=["a", "b"])]) -> None: ... -def _func_star_args(self, *args: str) -> None: ... -def _func_star_args_int(self, *args: int) -> None: ... -def _func_star_args_bare(self, *args) -> None: ... # type: ignore[no-untyped-def] -def _func_star_args_tuple(self, *files: tuple[str, ...]) -> None: ... -def _func_star_args_list(self, *xs: list[str]) -> None: ... -def _func_star_args_bare_list(self, *xs: list) -> None: ... # type: ignore[type-arg] -def _func_star_args_meta(self, *files: Annotated[str, Argument(help_text="a file", metavar="FILE")]) -> None: ... -def _func_star_args_meta_choices(self, *modes: Annotated[str, Argument(choices=["a", "b"])]) -> None: ... -def _func_star_args_option_meta(self, *files: Annotated[str, Option("--files")]) -> None: ... -def _func_star_args_nargs_meta(self, *files: Annotated[str, Argument(nargs=2)]) -> None: ... -def _func_kw_only_argument(self, *, name: Annotated[str, Argument()]) -> None: ... -def _func_kw_only_argument_default(self, *, name: Annotated[str, Argument()] = "x") -> None: ... def _func_var_keyword(self, name: str, **kwargs: str) -> None: ... -def _func_dest_param(self, dest: str) -> None: ... -def _func_kw_only(self, *, name: str) -> None: ... -def _func_kw_only_with_default(self, *, name: str = "world") -> None: ... -def _func_underscore_option(self, my_param: str = "x") -> None: ... -def _func_default_type_mismatch(self, count: int = "1") -> None: ... # type: ignore[assignment] -def _func_path_default(self, file: Path = Path("/tmp")) -> None: ... -def _func_optional_annotated_inside(self, name: Annotated[str | None, Option("--name")] = None) -> None: ... -def _func_optional_annotated_outside(self, name: Annotated[str, Option("--name")] | None = None) -> None: ... -def _func_int_enum(self, color: _IntColor) -> None: ... -def _func_plain_enum(self, color: _PlainColor) -> None: ... -def _func_list_int(self, nums: list[int]) -> None: ... -def _func_set_int(self, nums: set[int]) -> None: ... -def _func_tuple_fixed_triple(self, triple: tuple[int, int, int]) -> None: ... -def _func_list_bool(self, flags: list[bool]) -> None: ... -def _func_set_bool(self, flags: set[bool]) -> None: ... -def _func_list_path(self, files: list[Path]) -> None: ... -def _func_list_enum(self, colors: list[_Color]) -> None: ... -def _func_list_literal(self, modes: list[Literal["fast", "slow"]]) -> None: ... -def _func_tuple_paths(self, src_dst: tuple[Path, Path]) -> None: ... -def _func_tuple_enums(self, pair: tuple[_Color, _Color]) -> None: ... -def _func_optional_str_nondefault(self, name: str | None = "world") -> None: ... -def _func_typing_optional(self, count: Optional[int] = None) -> None: ... # noqa: UP045 -def _func_int_subclass(self, port: _Port) -> None: ... -def _func_store_true_action(self, verbose: Annotated[bool, Option("--verbose", action="store_true")] = False) -> None: ... -def _func_store_false_action(self, quiet: Annotated[bool, Option("--quiet", action="store_false")] = True) -> None: ... -def _func_append_action(self, tag: Annotated[str | None, Option("--tag", action="append")] = None) -> None: ... def _func_multi(self, a: str, b: int, c: int = 1) -> None: ... def _func_grouped( self, @@ -189,18 +146,6 @@ def _provider(cmd: cmd2.Cmd): return [] -def _func_choices_provider_on_enum( - self, - color: Annotated[_Color, Argument(choices_provider=_provider)], -) -> None: ... - - -def _func_completer_on_path( - self, - file: Annotated[Path, Argument(completer=cmd2.Cmd.path_complete)], -) -> None: ... - - # --------------------------------------------------------------------------- # Helper # --------------------------------------------------------------------------- @@ -208,8 +153,6 @@ def _func_completer_on_path( def _get_param_action(func: object) -> argparse.Action: """Build parser from a single-param function and return its action.""" - import inspect - sig = inspect.signature(func) # type: ignore[arg-type] param_names = [n for n in sig.parameters if n != "self"] assert len(param_names) == 1, f"Expected 1 param besides self, got {param_names}" @@ -220,6 +163,60 @@ def _get_param_action(func: object) -> argparse.Action: raise ValueError(f"No action with dest={param_names[0]!r}") +# Templates used by ``_make_func``: one per parameter kind. ``code.replace`` swaps +# the literal name ``value`` for whatever ``name=`` the caller asks for, so the +# resulting signature carries the right dest without resorting to ``exec``. +def _stub_pos(self, value): ... # type: ignore[no-untyped-def] +def _stub_kw(self, *, value): ... # type: ignore[no-untyped-def] +def _stub_var(self, *value): ... # type: ignore[no-untyped-def] + + +_STUB_TEMPLATES = {"pos": _stub_pos, "kw": _stub_kw, "var": _stub_var} +_MISSING: Any = object() + + +def _make_func( + annotation: Any, + *, + name: str = "value", + default: Any = _MISSING, + kind: str = "pos", +) -> Any: + """Construct a one-parameter ``self``-method carrying just an annotation. + + ``kind`` is ``"pos"`` (positional-or-keyword), ``"kw"`` (keyword-only), or + ``"var"`` (``*args``). Used by tests as a stand-in for the throwaway + ``def do_x(self, value: T): ...`` stubs that only exist to be fed to + ``build_parser_from_function``. + """ + template = _STUB_TEMPLATES[kind] + code = template.__code__ + new_varnames = tuple(n if n != "value" else name for n in code.co_varnames) + new_code = code.replace(co_varnames=new_varnames) + f = types.FunctionType(new_code, template.__globals__, name=f"_stub_{name}") + # ``_MISSING`` for ``annotation`` produces an unannotated parameter (still ``-> None``). + f.__annotations__ = {"return": type(None)} if annotation is _MISSING else {name: annotation, "return": type(None)} + if default is not _MISSING: + if kind == "pos": + f.__defaults__ = (default,) + elif kind == "kw": + f.__kwdefaults__ = {name: default} + else: + raise ValueError(f"default not supported for kind={kind!r}") + return f + + +def _action_for(annotation: Any, **kwargs: Any) -> argparse.Action: + """Build a one-param function with the given annotation and return its action.""" + return _get_param_action(_make_func(annotation, **kwargs)) + + +def _assert_build_error(annotation: Any, *, match: str | None = None, **kwargs: Any) -> None: + """Assert ``build_parser_from_function`` rejects a one-param function with this annotation.""" + with pytest.raises(TypeError, match=match): + build_parser_from_function(_make_func(annotation, **kwargs)) + + def _complete_cmd(app: cmd2.Cmd, line: str, text: str) -> list[str]: begidx = len(line) - len(text) endidx = len(line) @@ -227,6 +224,11 @@ def _complete_cmd(app: cmd2.Cmd, line: str, text: str) -> list[str]: return list(completions.to_strings()) +# Register a custom add_argument parameter so we can verify that Argument()/Option() +# forward arbitrary registered parameters (parity with hand-built parsers). +register_argparse_argument_parameter("annotated_custom_attr") + + # --------------------------------------------------------------------------- # Core: build_parser_from_function produces correct action attributes # --------------------------------------------------------------------------- @@ -239,128 +241,273 @@ class TestBuildParser: ("func", "expected"), [ # --- Positionals --- - pytest.param(_func_str, {"option_strings": [], "type": None}, id="str_positional"), - pytest.param(_func_path, {"option_strings": [], "type": Path}, id="path_positional"), - pytest.param(_func_decimal, {"option_strings": [], "type": decimal.Decimal}, id="decimal_positional"), - pytest.param(_func_bool_positional, {"option_strings": [], "type": _parse_bool}, id="bool_positional"), - pytest.param(_func_enum, {"option_strings": [], "choices": _COLOR_CHOICE_ITEMS}, id="enum_positional"), - pytest.param(_func_literal, {"option_strings": [], "choices": ["fast", "slow"]}, id="literal_positional"), - pytest.param(_func_literal_int, {"option_strings": [], "choices": [1, 2, 3]}, id="literal_int_positional"), - pytest.param(_func_int_enum, {"option_strings": [], "choices": _INT_COLOR_CHOICE_ITEMS}, id="int_enum_positional"), + pytest.param(_make_func(str, name="name"), {"option_strings": [], "type": None}, id="str_positional"), + pytest.param(_make_func(Path, name="file"), {"option_strings": [], "type": Path}, id="path_positional"), + pytest.param( + _make_func(decimal.Decimal, name="amount"), + {"option_strings": [], "type": decimal.Decimal}, + id="decimal_positional", + ), + pytest.param(_make_func(bool, name="flag"), {"option_strings": [], "type": _parse_bool}, id="bool_positional"), + pytest.param( + _make_func(_Color, name="color"), {"option_strings": [], "choices": _COLOR_CHOICE_ITEMS}, id="enum_positional" + ), + pytest.param( + _make_func(Literal["fast", "slow"], name="mode"), + {"option_strings": [], "choices": ["fast", "slow"]}, + id="literal_positional", + ), + pytest.param( + _make_func(Literal[1, 2, 3], name="level"), + {"option_strings": [], "choices": [1, 2, 3]}, + id="literal_int_positional", + ), + pytest.param( + _make_func(_IntColor, name="color"), + {"option_strings": [], "choices": _INT_COLOR_CHOICE_ITEMS}, + id="int_enum_positional", + ), pytest.param( - _func_plain_enum, {"option_strings": [], "choices": _PLAIN_COLOR_CHOICE_ITEMS}, id="plain_enum_positional" + _make_func(_PlainColor, name="color"), + {"option_strings": [], "choices": _PLAIN_COLOR_CHOICE_ITEMS}, + id="plain_enum_positional", ), - pytest.param(_func_list_int, {"option_strings": [], "nargs": "+", "type": int}, id="list_int"), - pytest.param(_func_set_int, {"option_strings": [], "nargs": "+", "type": int}, id="set_int"), - pytest.param(_func_tuple_fixed_triple, {"option_strings": [], "nargs": 3, "type": int}, id="tuple_fixed_triple"), - pytest.param(_func_list, {"option_strings": [], "nargs": "+"}, id="list_positional"), - pytest.param(_func_set, {"option_strings": [], "nargs": "+"}, id="set_positional"), - pytest.param(_func_tuple_ellipsis, {"option_strings": [], "nargs": "+", "type": int}, id="tuple_ellipsis"), - pytest.param(_func_tuple_fixed, {"option_strings": [], "nargs": 2, "type": int}, id="tuple_fixed"), - pytest.param(_func_bare_list, {"option_strings": [], "nargs": "+"}, id="bare_list"), - pytest.param(_func_bare_tuple, {"option_strings": [], "nargs": "+"}, id="bare_tuple"), + pytest.param(_make_func(list[int], name="nums"), {"option_strings": [], "nargs": "+", "type": int}, id="list_int"), + pytest.param(_make_func(set[int], name="nums"), {"option_strings": [], "nargs": "+", "type": int}, id="set_int"), pytest.param( - _func_optional_positional, {"option_strings": [], "nargs": "?", "type": int}, id="optional_positional" + _make_func(tuple[int, int, int], name="triple"), + {"option_strings": [], "nargs": 3, "type": int}, + id="tuple_fixed_triple", ), + pytest.param(_make_func(list[str], name="files"), {"option_strings": [], "nargs": "+"}, id="list_positional"), + pytest.param(_make_func(set[str], name="tags"), {"option_strings": [], "nargs": "+"}, id="set_positional"), pytest.param( - _func_positional_with_default, + _make_func(tuple[int, ...], name="values"), + {"option_strings": [], "nargs": "+", "type": int}, + id="tuple_ellipsis", + ), + pytest.param( + _make_func(tuple[int, int], name="pair"), {"option_strings": [], "nargs": 2, "type": int}, id="tuple_fixed" + ), + pytest.param(_make_func(list, name="items"), {"option_strings": [], "nargs": "+"}, id="bare_list"), + pytest.param(_make_func(tuple, name="items"), {"option_strings": [], "nargs": "+"}, id="bare_tuple"), + pytest.param( + _make_func(Annotated[int | None, Argument()], name="val"), + {"option_strings": [], "nargs": "?", "type": int}, + id="optional_positional", + ), + pytest.param( + _make_func(Annotated[str, Argument()], name="arg", default="foo"), {"option_strings": [], "nargs": "?", "default": "foo"}, id="positional_with_default", ), - pytest.param(_func_optional_plain, {"option_strings": [], "nargs": "?", "type": int}, id="optional_plain"), - pytest.param(_func_optional_list, {"option_strings": [], "nargs": "*", "type": int}, id="optional_list"), pytest.param( - _func_optional_tuple_ellipsis, + _make_func(int | None, name="val"), {"option_strings": [], "nargs": "?", "type": int}, id="optional_plain" + ), + pytest.param( + _make_func(list[int] | None, name="vals"), + {"option_strings": [], "nargs": "*", "type": int}, + id="optional_list", + ), + pytest.param( + _make_func(tuple[int, ...] | None, name="vals"), {"option_strings": [], "nargs": "*", "type": int}, id="optional_tuple_ellipsis", ), # --- Options --- - pytest.param(_func_int_option, {"option_strings": ["--count"], "type": int, "default": 1}, id="int_option"), - pytest.param(_func_float_option, {"option_strings": ["--rate"], "type": float, "default": 1.0}, id="float_option"), pytest.param( - _func_bool_false, + _make_func(int, name="count", default=1), + {"option_strings": ["--count"], "type": int, "default": 1}, + id="int_option", + ), + pytest.param( + _make_func(float, name="rate", default=1.0), + {"option_strings": ["--rate"], "type": float, "default": 1.0}, + id="float_option", + ), + pytest.param( + _make_func(bool, name="verbose", default=False), {"option_strings": ["--verbose", "--no-verbose"], "default": False}, id="bool_optional_action", ), pytest.param( - _func_bool_true, + _make_func(bool, name="debug", default=True), {"option_strings": ["--debug", "--no-debug"], "default": True}, id="bool_optional_action_true", ), - pytest.param(_func_path_option, {"option_strings": ["--file"], "type": Path}, id="path_option"), pytest.param( - _func_enum_option, + _make_func(Path, name="file", default=Path(".")), + {"option_strings": ["--file"], "type": Path}, + id="path_option", + ), + pytest.param( + _make_func(_Color, name="color", default=_Color.blue), {"option_strings": ["--color"], "choices": _COLOR_CHOICE_ITEMS, "default": _Color.blue}, id="enum_option", ), pytest.param( - _func_literal_option, {"option_strings": ["--mode"], "choices": ["fast", "slow"]}, id="literal_option" + _make_func(Literal["fast", "slow"], name="mode", default="fast"), + {"option_strings": ["--mode"], "choices": ["fast", "slow"]}, + id="literal_option", + ), + pytest.param( + _make_func(str | None, name="name", default=None), + {"option_strings": ["--name"], "default": None}, + id="optional_str", + ), + pytest.param( + _make_func(list[str] | None, name="items", default=None), + {"option_strings": ["--items"], "nargs": "*"}, + id="list_with_default", ), - pytest.param(_func_optional, {"option_strings": ["--name"], "default": None}, id="optional_str"), - pytest.param(_func_list_default, {"option_strings": ["--items"], "nargs": "*"}, id="list_with_default"), # --- Annotated metadata --- - pytest.param(_func_annotated_arg, {"option_strings": [], "help": "Your name"}, id="annotated_help"), pytest.param( - _func_annotated_option, {"option_strings": ["--color", "-c"], "help": "Pick"}, id="annotated_custom_flags" + _make_func(Annotated[str, Argument(help_text="Your name")], name="name"), + {"option_strings": [], "help": "Your name"}, + id="annotated_help", + ), + pytest.param( + _make_func(Annotated[str, Option("--color", "-c", help_text="Pick")], name="color", default="blue"), + {"option_strings": ["--color", "-c"], "help": "Pick"}, + id="annotated_custom_flags", + ), + pytest.param( + _make_func(Annotated[str, Argument(metavar="NAME")], name="name"), + {"option_strings": [], "metavar": "NAME"}, + id="annotated_metavar", + ), + pytest.param( + # argparse accepts a tuple metavar to label each value of a multi-value argument. + _make_func(Annotated[tuple[int, int], Argument(metavar=("LO", "HI"))], name="span"), + {"option_strings": [], "metavar": ("LO", "HI")}, + id="annotated_tuple_metavar", + ), + pytest.param( + _make_func(Annotated[tuple[str, ...], Argument(nargs=2)], name="names"), + {"option_strings": [], "nargs": 2}, + id="annotated_nargs", + ), + pytest.param( + _make_func(Annotated[str, Option("--name", required=True)], name="name"), + {"option_strings": ["--name"], "required": True}, + id="annotated_required", ), - pytest.param(_func_annotated_metavar, {"option_strings": [], "metavar": "NAME"}, id="annotated_metavar"), - pytest.param(_func_annotated_nargs, {"option_strings": [], "nargs": 2}, id="annotated_nargs"), - pytest.param(_func_annotated_required, {"option_strings": ["--name"], "required": True}, id="annotated_required"), pytest.param( - _func_annotated_required_auto_flag, + _make_func(Annotated[str, Option(required=True)], name="name"), {"option_strings": ["--name"], "required": True}, id="annotated_required_auto_flag", ), - pytest.param(_func_annotated_choices, {"option_strings": [], "choices": ["a", "b"]}, id="annotated_choices"), - pytest.param(_func_star_args, {"option_strings": [], "type": None, "nargs": "*"}, id="star_args"), - pytest.param(_func_star_args_int, {"option_strings": [], "type": int, "nargs": "*"}, id="star_args_int"), + # A value option with no default and no ``| None`` must be required (else omitting it + # would pass None, violating the non-Optional type hint). + pytest.param( + _make_func(Annotated[str, Option("-c")], name="color"), + {"option_strings": ["-c"], "required": True}, + id="option_no_default_required", + ), + # A bool option is a flag, not a value: absence means ``False``, so it defaults to False + # and is NOT required (a required bool flag would be unsatisfiable for a short-only flag). + pytest.param( + _make_func(Annotated[bool, Option("-f")], name="flag"), + {"option_strings": ["-f"], "required": False, "default": False}, + id="bool_option_no_default_defaults_false", + ), + # ``| None`` opts out of required: None is a valid value when omitted. + pytest.param( + _make_func(Annotated[str | None, Option("-c")], name="color"), + {"option_strings": ["-c"], "required": False, "default": None}, + id="option_optional_no_default_not_required", + ), + pytest.param( + _make_func(str | None, name="name", kind="kw"), + {"option_strings": ["--name"], "required": False, "default": None}, + id="kw_only_optional_not_required", + ), + pytest.param( + _make_func(Annotated[str, Argument(choices=["a", "b"])], name="food"), + {"option_strings": [], "choices": ["a", "b"]}, + id="annotated_choices", + ), + pytest.param( + _make_func(str, name="args", kind="var"), {"option_strings": [], "type": None, "nargs": "*"}, id="star_args" + ), + pytest.param( + _make_func(int, name="args", kind="var"), {"option_strings": [], "type": int, "nargs": "*"}, id="star_args_int" + ), # --- Keyword-only --- - pytest.param(_func_kw_only, {"option_strings": ["--name"], "required": True}, id="kw_only_required"), - pytest.param(_func_kw_only_with_default, {"option_strings": ["--name"], "default": "world"}, id="kw_only_default"), + pytest.param( + _make_func(str, name="name", kind="kw"), + {"option_strings": ["--name"], "required": True}, + id="kw_only_required", + ), + pytest.param( + _make_func(str, name="name", default="world", kind="kw"), + {"option_strings": ["--name"], "default": "world"}, + id="kw_only_default", + ), # --- Underscore in flag names --- - pytest.param(_func_underscore_option, {"option_strings": ["--my-param"], "default": "x"}, id="underscore_flag"), + pytest.param( + _make_func(str, name="my_param", default="x"), + {"option_strings": ["--my-param"], "default": "x"}, + id="underscore_flag", + ), # --- Default type preservation --- pytest.param( - _func_default_type_mismatch, {"option_strings": ["--count"], "default": "1"}, id="default_not_coerced" + _make_func(int, name="count", default="1"), + {"option_strings": ["--count"], "default": "1"}, + id="default_not_coerced", + ), + pytest.param( + _make_func(Path, name="file", default=Path("/tmp")), + {"option_strings": ["--file"], "default": Path("/tmp")}, + id="path_default", ), - pytest.param(_func_path_default, {"option_strings": ["--file"], "default": Path("/tmp")}, id="path_default"), # --- Optional + Annotated (union inside) --- pytest.param( - _func_optional_annotated_inside, + _make_func(Annotated[str | None, Option("--name")], name="name", default=None), {"option_strings": ["--name"], "default": None}, id="optional_annotated_inside", ), # --- Collections of complex element types --- - pytest.param(_func_list_bool, {"option_strings": [], "nargs": "+", "type": _parse_bool}, id="list_bool"), - pytest.param(_func_set_bool, {"option_strings": [], "nargs": "+", "type": _parse_bool}, id="set_bool"), - pytest.param(_func_list_path, {"option_strings": [], "nargs": "+", "type": Path}, id="list_path"), pytest.param( - _func_list_literal, + _make_func(list[bool], name="flags"), {"option_strings": [], "nargs": "+", "type": _parse_bool}, id="list_bool" + ), + pytest.param( + _make_func(set[bool], name="flags"), {"option_strings": [], "nargs": "+", "type": _parse_bool}, id="set_bool" + ), + pytest.param( + _make_func(list[Path], name="files"), {"option_strings": [], "nargs": "+", "type": Path}, id="list_path" + ), + pytest.param( + _make_func(list[Literal["fast", "slow"]], name="modes"), {"option_strings": [], "nargs": "+", "choices": ["fast", "slow"]}, id="list_literal", ), pytest.param( - _func_list_enum, + _make_func(list[_Color], name="colors"), {"option_strings": [], "nargs": "+", "choices": _COLOR_CHOICE_ITEMS}, id="list_enum", ), - pytest.param(_func_tuple_paths, {"option_strings": [], "nargs": 2, "type": Path}, id="tuple_paths"), pytest.param( - _func_tuple_enums, + _make_func(tuple[Path, Path], name="src_dst"), + {"option_strings": [], "nargs": 2, "type": Path}, + id="tuple_paths", + ), + pytest.param( + _make_func(tuple[_Color, _Color], name="pair"), {"option_strings": [], "nargs": 2, "choices": _COLOR_CHOICE_ITEMS}, id="tuple_enums", ), # --- Subclass fallback (Port(int) uses int converter) --- - pytest.param(_func_int_subclass, {"option_strings": [], "type": int}, id="int_subclass"), + pytest.param(_make_func(_Port, name="port"), {"option_strings": [], "type": int}, id="int_subclass"), # --- Optional with non-None default --- pytest.param( - _func_optional_str_nondefault, + _make_func(str | None, name="name", default="world"), {"option_strings": ["--name"], "default": "world"}, id="optional_str_nondefault", ), # --- typing.Optional[T] (vs T | None) end-to-end --- pytest.param( - _func_typing_optional, + _make_func(Optional[int], name="count", default=None), # noqa: UP045 {"option_strings": ["--count"], "type": int, "default": None}, id="typing_optional", ), @@ -372,37 +519,43 @@ def test_action_attributes(self, func, expected) -> None: assert getattr(action, key) == value, f"{key}: expected {value!r}, got {getattr(action, key)!r}" def test_annotated_action_count(self) -> None: - action = _get_param_action(_func_annotated_action) + action = _get_param_action( + _make_func(Annotated[int, Option("--verbose", "-v", action="count")], name="verbose", default=0) + ) assert isinstance(action, argparse._CountAction) def test_annotated_action_count_non_bool(self) -> None: - action = _get_param_action(_func_annotated_action_non_bool) + action = _get_param_action(_make_func(Annotated[int, Option("--count", action="count")], name="count", default=0)) assert isinstance(action, argparse._CountAction) assert action.default == 0 def test_annotated_action_store_true(self) -> None: """``action='store_true'`` strips the inferred bool converter.""" - action = _get_param_action(_func_store_true_action) + action = _get_param_action( + _make_func(Annotated[bool, Option("--verbose", action="store_true")], name="verbose", default=False) + ) assert isinstance(action, argparse._StoreTrueAction) assert action.type is None assert action.default is False def test_annotated_action_store_false(self) -> None: """``action='store_false'`` strips the inferred bool converter.""" - action = _get_param_action(_func_store_false_action) + action = _get_param_action( + _make_func(Annotated[bool, Option("--quiet", action="store_false")], name="quiet", default=True) + ) assert isinstance(action, argparse._StoreFalseAction) assert action.type is None assert action.default is True def test_annotated_action_append(self) -> None: """``action='append'`` collects repeated flag values into a list.""" - action = _get_param_action(_func_append_action) + action = _get_param_action(_make_func(Annotated[list[str], Option("--tag", action="append")], name="tag")) assert isinstance(action, argparse._AppendAction) assert action.option_strings == ["--tag"] def test_positional_with_default_is_optional(self) -> None: """A positional with a default takes 0-or-1 tokens and falls back to the default when absent.""" - parser = build_parser_from_function(_func_positional_with_default) + parser = build_parser_from_function(_make_func(Annotated[str, Argument()], name="arg", default="foo")) assert parser.parse_args([]).arg == "foo" assert parser.parse_args(["bar"]).arg == "bar" @@ -410,13 +563,13 @@ def test_str_default_on_int_option_coerced_at_parse(self) -> None: """The decorator stores the default literally ('1', see ``default_not_coerced``); at parse time argparse applies ``type=int`` to the string default, so an absent ``--count`` yields int 1. """ - parser = build_parser_from_function(_func_default_type_mismatch) + parser = build_parser_from_function(_make_func(int, name="count", default="1")) assert parser.parse_args([]).count == 1 assert parser.parse_args(["--count", "5"]).count == 5 def test_typing_optional_parses_end_to_end(self) -> None: """typing.Optional[int] yields None when absent and coerces to int when provided.""" - parser = build_parser_from_function(_func_typing_optional) + parser = build_parser_from_function(_make_func(Optional[int], name="count", default=None)) # noqa: UP045 assert parser.parse_args([]).count is None parsed = parser.parse_args(["--count", "5"]).count assert parsed == 5 @@ -425,9 +578,9 @@ def test_typing_optional_parses_end_to_end(self) -> None: @pytest.mark.parametrize( "func", [ - pytest.param(_func_set, id="set"), - pytest.param(_func_tuple_ellipsis, id="tuple"), - pytest.param(_func_star_args, id="star_args"), + pytest.param(_make_func(set[str], name="tags"), id="set"), + pytest.param(_make_func(tuple[int, ...], name="values"), id="tuple"), + pytest.param(_make_func(str, name="args", kind="var"), id="star_args"), ], ) def test_collection_uses_casting_action(self, func) -> None: @@ -436,21 +589,21 @@ def test_collection_uses_casting_action(self, func) -> None: def test_star_args_bare_defaults_to_str(self) -> None: """A bare ``*args`` (no element annotation) is treated as ``*args: str``.""" - action = _get_param_action(_func_star_args_bare) + action = _get_param_action(_make_func(_MISSING, name="args", kind="var")) assert action.option_strings == [] assert action.nargs == "*" assert action.type is None def test_star_args_parses_to_tuple(self) -> None: """``*args: int`` accepts zero or more values, coerced and collected into a tuple.""" - parser = build_parser_from_function(_func_star_args_int) + parser = build_parser_from_function(_make_func(int, name="args", kind="var")) assert parser.parse_args([]).args == () parsed = parser.parse_args(["1", "2", "3"]).args assert parsed == (1, 2, 3) assert isinstance(parsed, tuple) def test_self_skipped(self) -> None: - parser = build_parser_from_function(_func_str) + parser = build_parser_from_function(_make_func(str, name="name")) dests = {a.dest for a in parser._actions} assert "self" not in dests @@ -478,18 +631,18 @@ def do_broken(self, name: "NonExistentType"): # noqa: F821 build_parser_from_function(do_broken) def test_validate_base_command_type_hints_failure_raises(self) -> None: - """_validate_base_command_params should raise, not swallow, type hint failures.""" - from cmd2.annotated import _validate_base_command_params + """Base-command validation should raise, not swallow, type hint failures.""" + from cmd2.annotated import _resolve_parameters def do_broken(self, cmd2_handler, name: "NonExistentType"): # noqa: F821 pass with pytest.raises(TypeError, match="Failed to resolve type hints"): - _validate_base_command_params(do_broken) + _resolve_parameters(do_broken, base_command=True) def test_dest_param_raises(self) -> None: with pytest.raises(ValueError, match="dest"): - build_parser_from_function(_func_dest_param) + build_parser_from_function(_make_func(str, name="dest")) def test_subcommand_param_raises(self) -> None: def func(self, subcommand: str) -> None: ... @@ -517,24 +670,26 @@ def test_var_keyword_raises(self) -> None: @pytest.mark.parametrize( "func", [ - pytest.param(_func_star_args_tuple, id="tuple[str, ...]"), - pytest.param(_func_star_args_list, id="list[str]"), - pytest.param(_func_star_args_bare_list, id="bare_list"), + pytest.param(_make_func(tuple[str, ...], name="files", kind="var"), id="tuple[str, ...]"), + pytest.param(_make_func(list[str], name="xs", kind="var"), id="list[str]"), + pytest.param(_make_func(list, name="xs", kind="var"), id="bare_list"), ], ) def test_star_args_collection_element_raises(self, func) -> None: """``*args`` annotated with a collection element is rejected with a targeted hint. - The annotation on ``*args`` is the type of each value, so a collection element - (e.g. ``*files: tuple[str, ...]``) would mean a tuple-of-collections, which cannot - be parsed. The error must steer the user toward annotating the element type. + The annotation on ``*args`` is each value's type, so a collection element (e.g. + ``*files: tuple[str, ...]``) would mean a tuple-of-collections. The error must steer + the user toward annotating the element type. """ with pytest.raises(TypeError, match=r"the type of each value"): build_parser_from_function(func) def test_star_args_honors_argument_metadata(self) -> None: """``Annotated[T, Argument(...)]`` on ``*args`` applies help/metavar to the variadic positional.""" - action = _get_param_action(_func_star_args_meta) + action = _get_param_action( + _make_func(Annotated[str, Argument(help_text="a file", metavar="FILE")], name="files", kind="var") + ) assert action.option_strings == [] assert action.nargs == "*" assert action.help == "a file" @@ -542,7 +697,7 @@ def test_star_args_honors_argument_metadata(self) -> None: def test_star_args_honors_argument_choices(self) -> None: """``Argument(choices=...)`` on ``*args`` restricts every value to the choices.""" - parser = build_parser_from_function(_func_star_args_meta_choices) + parser = build_parser_from_function(_make_func(Annotated[str, Argument(choices=["a", "b"])], name="modes", kind="var")) assert parser.parse_args(["a", "b", "a"]).modes == ("a", "b", "a") with pytest.raises(SystemExit): parser.parse_args(["a", "nope"]) @@ -550,18 +705,18 @@ def test_star_args_honors_argument_choices(self) -> None: def test_star_args_option_metadata_raises(self) -> None: """``Option()`` on ``*args`` is rejected; *args is always positional.""" with pytest.raises(TypeError, match=r"\*args is always a positional"): - build_parser_from_function(_func_star_args_option_meta) + build_parser_from_function(_make_func(Annotated[str, Option("--files")], name="files", kind="var")) def test_star_args_nargs_metadata_raises(self) -> None: """An explicit ``nargs`` on ``*args`` is rejected; its arity is fixed to ``'*'``.""" with pytest.raises(TypeError, match=r"arity cannot be overridden"): - build_parser_from_function(_func_star_args_nargs_meta) + build_parser_from_function(_make_func(Annotated[str, Argument(nargs=2)], name="files", kind="var")) @pytest.mark.parametrize( "func", [ - pytest.param(_func_kw_only_argument, id="no_default"), - pytest.param(_func_kw_only_argument_default, id="with_default"), + pytest.param(_make_func(Annotated[str, Argument()], name="name", kind="kw"), id="no_default"), + pytest.param(_make_func(Annotated[str, Argument()], name="name", default="x", kind="kw"), id="with_default"), ], ) def test_kw_only_with_argument_metadata_raises(self, func) -> None: @@ -569,9 +724,63 @@ def test_kw_only_with_argument_metadata_raises(self, func) -> None: with pytest.raises(TypeError, match=r"keyword-only but uses Argument\(\)"): build_parser_from_function(func) + def test_option_no_default_is_enforced_at_parse_time(self) -> None: + """Omitting a no-default, non-Optional option errors instead of silently passing None.""" + parser = build_parser_from_function(_make_func(Annotated[str, Option("-c")], name="color")) + with pytest.raises(SystemExit): + parser.parse_args([]) + assert parser.parse_args(["-c", "red"]).color == "red" + + def test_optional_option_no_default_yields_none_when_omitted(self) -> None: + """``| None`` opts out of required: omitting it yields None, a valid value for the hint.""" + parser = build_parser_from_function(_make_func(Annotated[str | None, Option("-c")], name="color")) + assert parser.parse_args([]).color is None + assert parser.parse_args(["-c", "red"]).color == "red" + + def test_generator_choices_are_materialized(self) -> None: + """A single-use iterable (generator) as choices must survive repeated argparse iteration.""" + parser = build_parser_from_function( + _make_func(Annotated[str, Argument(choices=(c for c in ["a", "b"]))]), + ) + assert parser.parse_args(["a"]).value == "a" + assert parser.parse_args(["b"]).value == "b" + with pytest.raises(SystemExit): + parser.parse_args(["c"]) + + @pytest.mark.parametrize( + ("func", "dest", "default"), + [ + pytest.param( + _make_func(Annotated[bool, Option("-v", action="store_true")], name="verbose"), + "verbose", + False, + id="store_true", + ), + pytest.param(_make_func(Annotated[int, Option("-l", action="count")], name="level"), "level", 0, id="count"), + ], + ) + def test_flag_action_no_default_not_required(self, func, dest, default) -> None: + """Flag-style actions carry their own implicit default, so a missing default does not force required.""" + parser = build_parser_from_function(func) + action = _get_param_action(func) + assert action.required is False + assert getattr(parser.parse_args([]), dest) == default + + def test_bool_option_no_default_is_usable_flag(self) -> None: + """A bool option is a flag (absence -> False), not a required value. + + Marking it required would make a short-only flag unsatisfiable: ``-f`` can only set True + and there is no negation form, so a required ``-f`` could never be False. + """ + parser = build_parser_from_function(_make_func(Annotated[bool, Option("-f")], name="flag")) + action = _get_param_action(_make_func(Annotated[bool, Option("-f")], name="flag")) + assert action.required is False + assert parser.parse_args([]).flag is False + assert parser.parse_args(["-f"]).flag is True + def test_optional_annotated_outside_raises(self) -> None: with pytest.raises(TypeError, match="Annotated"): - build_parser_from_function(_func_optional_annotated_outside) + build_parser_from_function(_make_func(Annotated[str, Option("--name")] | None, name="name", default=None)) def test_annotated_ambiguous_union_raises(self) -> None: """Annotated[str | int, meta] must raise -- ambiguous inner union.""" @@ -591,14 +800,14 @@ class TestTypeInferenceBuildParser: """Type-inference behavior and override precedence when building parser actions.""" def test_choices_provider_overrides_inferred_enum_choices(self) -> None: - action = _get_param_action(_func_choices_provider_on_enum) + action = _get_param_action(_make_func(Annotated[_Color, Argument(choices_provider=_provider)], name="color")) assert action.choices is None assert action.get_choices_provider() is _provider # type: ignore[attr-defined] assert action.get_completer() is None # type: ignore[attr-defined] def test_choices_provider_keeps_enum_coercion(self) -> None: """A choices_provider on an Enum keeps the converter so values still coerce to the member.""" - action = _get_param_action(_func_choices_provider_on_enum) + action = _get_param_action(_make_func(Annotated[_Color, Argument(choices_provider=_provider)], name="color")) assert action.type is not None assert action.type("red") is _Color.red @@ -617,7 +826,7 @@ def func( def test_choices_provider_enum_coerces_at_parse(self) -> None: """End-to-end: an Enum with a choices_provider still parses to the enum member, not a str.""" - parser = build_parser_from_function(_func_choices_provider_on_enum) + parser = build_parser_from_function(_make_func(Annotated[_Color, Argument(choices_provider=_provider)], name="color")) assert parser.parse_args(["red"]).color is _Color.red def test_choices_provider_literal_int_coerces_at_parse(self) -> None: @@ -639,32 +848,42 @@ def test_bare_enum_literal_coerce_at_parse(self) -> None: Uses identity / isinstance (not ``==``) so a stripped converter returning a raw ``str`` cannot hide behind StrEnum/IntEnum equality. """ - assert build_parser_from_function(_func_literal).parse_args(["fast"]).mode == "fast" - assert build_parser_from_function(_func_literal_option).parse_args(["--mode", "slow"]).mode == "slow" + assert build_parser_from_function(_make_func(Literal["fast", "slow"], name="mode")).parse_args(["fast"]).mode == "fast" + assert ( + build_parser_from_function(_make_func(Literal["fast", "slow"], name="mode", default="fast")) + .parse_args(["--mode", "slow"]) + .mode + == "slow" + ) - level = build_parser_from_function(_func_literal_int).parse_args(["2"]).level + level = build_parser_from_function(_make_func(Literal[1, 2, 3], name="level")).parse_args(["2"]).level assert level == 2 assert isinstance(level, int) - assert build_parser_from_function(_func_enum).parse_args(["red"]).color is _Color.red - assert build_parser_from_function(_func_enum_option).parse_args(["--color", "red"]).color is _Color.red + assert build_parser_from_function(_make_func(_Color, name="color")).parse_args(["red"]).color is _Color.red + assert ( + build_parser_from_function(_make_func(_Color, name="color", default=_Color.blue)) + .parse_args(["--color", "red"]) + .color + is _Color.red + ) # Non-StrEnum cases: identity defeats the StrEnum/IntEnum ``==`` masking property. - assert build_parser_from_function(_func_int_enum).parse_args(["1"]).color is _IntColor.red - assert build_parser_from_function(_func_plain_enum).parse_args(["red"]).color is _PlainColor.RED + assert build_parser_from_function(_make_func(_IntColor, name="color")).parse_args(["1"]).color is _IntColor.red + assert build_parser_from_function(_make_func(_PlainColor, name="color")).parse_args(["red"]).color is _PlainColor.RED def test_completer_keeps_path_converter(self) -> None: """User-supplied completer on Path preserves the (non-restrictive) Path converter.""" - action = _get_param_action(_func_completer_on_path) + action = _get_param_action(_make_func(Annotated[Path, Argument(completer=cmd2.Cmd.path_complete)], name="file")) assert action.type is Path def test_completer_overrides_inferred_path_completion(self) -> None: - action = _get_param_action(_func_completer_on_path) + action = _get_param_action(_make_func(Annotated[Path, Argument(completer=cmd2.Cmd.path_complete)], name="file")) assert action.get_choices_provider() is None # type: ignore[attr-defined] assert action.get_completer() is cmd2.Cmd.path_complete # type: ignore[attr-defined] def test_inferred_enum_choices_match_type_converter(self) -> None: """Enum choices must be convertible by the type converter.""" - action = _get_param_action(_func_enum) + action = _get_param_action(_make_func(_Color, name="color")) converter = action.type for choice in action.choices: assert isinstance(converter(str(choice)), _Color) @@ -672,10 +891,10 @@ def test_inferred_enum_choices_match_type_converter(self) -> None: @pytest.mark.parametrize( "func", [ - pytest.param(_func_path, id="path_positional"), - pytest.param(_func_path_option, id="path_option"), - pytest.param(_func_list_path, id="list_path"), - pytest.param(_func_tuple_paths, id="tuple_paths"), + pytest.param(_make_func(Path, name="file"), id="path_positional"), + pytest.param(_make_func(Path, name="file", default=Path(".")), id="path_option"), + pytest.param(_make_func(list[Path], name="files"), id="list_path"), + pytest.param(_make_func(tuple[Path, Path], name="src_dst"), id="tuple_paths"), ], ) def test_path_annotation_wires_path_completer(self, func) -> None: @@ -742,6 +961,42 @@ def func(self, verbose: bool = False, quiet: bool = False, json: bool = False, c parser = build_parser_from_function(func, mutually_exclusive_groups=(Group("verbose", "quiet"), Group("json", "csv"))) assert len(parser._mutually_exclusive_groups) == 2 + def test_required_member_in_mutex_group_raises(self) -> None: + """A required (no default, non-Optional) option in a mutex group is rejected with a clear error. + + argparse forbids required members in a mutex group, and it would be type-unsafe: only one + member is supplied, so the others arrive as None. The message must steer toward making it optional. + """ + + def func(self, local: Annotated[str, Option("--local")], remote: str | None = None) -> None: ... + + with pytest.raises(ValueError, match=r"mutually exclusive group members must be optional"): + build_parser_from_function(func, mutually_exclusive_groups=(Group("local", "remote"),)) + + def test_optional_members_in_mutex_group_build(self) -> None: + """Mutex members that are Optional or have defaults build fine (the regression guard).""" + + def func(self, local: Annotated[str | None, Option("--local")] = None, remote: str = "x") -> None: ... + + parser = build_parser_from_function(func, mutually_exclusive_groups=(Group("local", "remote"),)) + assert len(parser._mutually_exclusive_groups) == 1 + + def test_bool_flag_members_in_mutex_group_build(self) -> None: + """Plain bool flags (no default) are not required, so they belong in a mutex group.""" + + def func(self, verbose: Annotated[bool, Option("--verbose")], quiet: Annotated[bool, Option("--quiet")]) -> None: ... + + parser = build_parser_from_function(func, mutually_exclusive_groups=(Group("verbose", "quiet"),)) + assert len(parser._mutually_exclusive_groups) == 1 + + def test_nonexistent_member_reported_over_required_member(self) -> None: + """A typo'd member name surfaces as 'nonexistent', not masked by the required-member check.""" + + def func(self, local: Annotated[str, Option("--local")], remote: str | None = None) -> None: ... + + with pytest.raises(ValueError, match=r"nonexistent parameter 'ghost'"): + build_parser_from_function(func, mutually_exclusive_groups=(Group("local", "ghost"),)) + def test_argument_group(self) -> None: """Arguments in a group appear under a shared heading in help.""" @@ -811,9 +1066,7 @@ def test_group_requires_members(self) -> None: Group(title="empty") def test_description_and_epilog(self) -> None: - def func(self, name: str) -> None: ... - - parser = build_parser_from_function(func, description="my description", epilog="my epilog") + parser = build_parser_from_function(_make_func(str), description="my description", epilog="my epilog") assert parser.description == "my description" assert parser.epilog == "my epilog" @@ -823,20 +1076,54 @@ def test_custom_formatter_class(self) -> None: class MyFormatter(Cmd2HelpFormatter): pass - def func(self, name: str) -> None: ... - - parser = build_parser_from_function(func, formatter_class=MyFormatter) + parser = build_parser_from_function(_make_func(str), formatter_class=MyFormatter) assert parser.formatter_class is MyFormatter def test_custom_parser_class(self) -> None: class MyParser(cmd2.Cmd2ArgumentParser): pass - def func(self, name: str) -> None: ... - - parser = build_parser_from_function(func, parser_class=MyParser) + parser = build_parser_from_function(_make_func(str), parser_class=MyParser) assert isinstance(parser, MyParser) + def test_ap_completer_type(self) -> None: + from cmd2.argparse_completer import ArgparseCompleter + + class MyCompleter(ArgparseCompleter): + pass + + parser = build_parser_from_function(_make_func(str), ap_completer_type=MyCompleter) + assert parser.ap_completer_type is MyCompleter + + def test_ap_completer_type_defaults_to_none(self) -> None: + assert build_parser_from_function(_make_func(str)).ap_completer_type is None + + def test_ap_completer_type_via_decorator(self) -> None: + from cmd2 import constants + from cmd2.argparse_completer import ArgparseCompleter + + class MyCompleter(ArgparseCompleter): + pass + + @with_annotated(ap_completer_type=MyCompleter) + def do_run(self, name: str) -> None: ... + + builder = getattr(do_run, constants.CMD_ATTR_PARSER_SOURCE) + assert builder().ap_completer_type is MyCompleter + + def test_ap_completer_type_threads_to_subcommand(self) -> None: + from cmd2 import constants + from cmd2.argparse_completer import ArgparseCompleter + + class MyCompleter(ArgparseCompleter): + pass + + @with_annotated(subcommand_to="team", ap_completer_type=MyCompleter) + def team_create(self, name: str) -> None: ... + + spec = getattr(team_create, constants.SUBCMD_ATTR_SPEC) + assert spec.parser_source().ap_completer_type is MyCompleter + def test_customization_via_decorator(self) -> None: """description/epilog/titled Group flow through @with_annotated end-to-end.""" @@ -891,62 +1178,44 @@ def test_validate_group_members_rejects_nonexistent_param(self) -> None: def test_build_argument_group_targets(self) -> None: parser = argparse.ArgumentParser() - target_for, argument_group_for = _build_argument_group_targets( - parser, - groups=(Group("src", "dst"),), - all_param_names={"src", "dst", "recursive"}, - ) + target_for, argument_group_for = _build_argument_group_targets(parser, groups=(Group("src", "dst"),)) assert set(target_for) == {"src", "dst"} assert set(argument_group_for) == {"src", "dst"} assert target_for["src"] is argument_group_for["src"] assert target_for["dst"] is argument_group_for["dst"] - def test_build_argument_group_targets_rejects_duplicate_assignment(self) -> None: - parser = argparse.ArgumentParser() + def test_duplicate_argument_group_assignment_raises(self) -> None: + # Double-assignment is enforced by _CONSTRAINTS (the argument_group_indices membership fact), + # so it surfaces through the real build path rather than _build_argument_group_targets itself. + def func(self, *, verbose: bool = False) -> None: ... + with pytest.raises(ValueError, match="argument group 1 and argument group 2"): - _build_argument_group_targets( - parser, - groups=(Group("verbose"), Group("verbose")), - all_param_names={"verbose"}, - ) + build_parser_from_function(func, groups=(Group("verbose"), Group("verbose"))) def test_apply_mutex_group_targets(self) -> None: parser = argparse.ArgumentParser() - target_for, argument_group_for = _build_argument_group_targets( - parser, - groups=(Group("json", "csv"),), - all_param_names={"json", "csv", "plain"}, - ) + target_for, argument_group_for = _build_argument_group_targets(parser, groups=(Group("json", "csv"),)) _apply_mutex_group_targets( parser, target_for=target_for, argument_group_for=argument_group_for, mutually_exclusive_groups=(Group("json", "csv"),), - all_param_names={"json", "csv", "plain"}, ) assert target_for["json"] is target_for["csv"] assert isinstance(target_for["json"], argparse._MutuallyExclusiveGroup) - def test_apply_mutex_group_targets_rejects_duplicate_assignment(self) -> None: - parser = argparse.ArgumentParser() + def test_duplicate_mutex_group_assignment_raises(self) -> None: + # Double-assignment is enforced by _CONSTRAINTS (the mutex_group_indices membership fact). + def func(self, *, verbose: bool = False) -> None: ... + with pytest.raises(ValueError, match="multiple mutually exclusive groups"): - _apply_mutex_group_targets( - parser, - target_for={}, - argument_group_for={}, - mutually_exclusive_groups=(Group("verbose"), Group("verbose")), - all_param_names={"verbose"}, - ) + build_parser_from_function(func, mutually_exclusive_groups=(Group("verbose"), Group("verbose"))) def test_apply_mutex_group_targets_rejects_cross_group_members(self) -> None: parser = argparse.ArgumentParser() - _target_for, argument_group_for = _build_argument_group_targets( - parser, - groups=(Group("src"), Group("dst")), - all_param_names={"src", "dst"}, - ) + _target_for, argument_group_for = _build_argument_group_targets(parser, groups=(Group("src"), Group("dst"))) with pytest.raises(ValueError, match="different argument groups"): _apply_mutex_group_targets( @@ -954,7 +1223,6 @@ def test_apply_mutex_group_targets_rejects_cross_group_members(self) -> None: target_for={}, argument_group_for=argument_group_for, mutually_exclusive_groups=(Group("src", "dst"),), - all_param_names={"src", "dst"}, ) @@ -981,34 +1249,35 @@ class TestResolveAnnotation: ], ) def test_classification(self, annotation, has_default, expected_positional) -> None: - _kwargs, _meta, positional = _resolve_annotation(annotation, has_default=has_default) - assert positional is expected_positional + # A non-None default is supplied when has_default (default=None on a non-Optional type + # is itself rejected); classification depends only on whether a default exists, not its value. + extra = {"default": 0} if has_default else {} + arg = _resolve_annotation(annotation, has_default=has_default, **extra) + assert arg.is_positional is expected_positional def test_optional_wrapping_annotated_with_none_inside(self) -> None: """Optional[Annotated[T | None, meta]] is allowed (inner type contains None).""" ann = Annotated[str | None, _OPT_META] | None - _kwargs, meta, positional = _resolve_annotation(ann) - assert meta is _OPT_META - assert positional is False + arg = _resolve_annotation(ann) + assert arg.metadata is _OPT_META + assert arg.is_positional is False def test_typing_union_optional(self) -> None: ns: dict = {} exec("import typing; t = typing.Union[str, None]", ns) - _kwargs, _meta, positional = _resolve_annotation(ns["t"]) - assert positional is True + assert _resolve_annotation(ns["t"]).is_positional is True def test_typing_union_optional_with_default(self) -> None: ns: dict = {} exec("import typing; t = typing.Union[str, None]", ns) - _kwargs, _meta, positional = _resolve_annotation(ns["t"], has_default=True, default=None) - assert positional is False + assert _resolve_annotation(ns["t"], has_default=True, default=None).is_positional is False def test_annotated_multiple_metadata_picks_first(self) -> None: meta1 = Argument(help_text="first") meta2 = Option("--x", help_text="second") - kwargs, meta, _ = _resolve_annotation(Annotated[str, meta1, meta2]) - assert meta is meta1 - assert kwargs.get("help") == "first" + arg = _resolve_annotation(Annotated[str, meta1, meta2]) + assert arg.metadata is meta1 + assert arg.extras.get("help") == "first" # --------------------------------------------------------------------------- @@ -1094,7 +1363,7 @@ def test_nested_collection_raises(self, annotation) -> None: ], ) def test_unsupported_collection_no_nargs(self, annotation) -> None: - kwargs, _, _ = _resolve_annotation(annotation) + _flags, kwargs = _resolve_annotation(annotation)._emit() assert "nargs" not in kwargs assert "action" not in kwargs @@ -1131,7 +1400,7 @@ def __init__(self, raw: str) -> None: ) def test_passthrough_scalar_types_keep_no_converter(self, annotation) -> None: """str / Any / object are stored as the raw string (type stays None).""" - kwargs, _, _ = _resolve_annotation(annotation) + _flags, kwargs = _resolve_annotation(annotation)._emit() assert "type" not in kwargs @pytest.mark.parametrize( @@ -1288,6 +1557,20 @@ def provider(cmd): def test_completer_in_kwargs(self) -> None: assert Argument(completer=cmd2.Cmd.path_complete).to_kwargs()["completer"] is cmd2.Cmd.path_complete + def test_extra_kwarg_forwarded_in_to_kwargs(self) -> None: + """A registered custom add_argument parameter is forwarded via **kwargs.""" + assert Argument(annotated_custom_attr="v").to_kwargs()["annotated_custom_attr"] == "v" + assert Option("--x", annotated_custom_attr="v").to_kwargs()["annotated_custom_attr"] == "v" + + def test_registered_custom_param_set_on_action(self) -> None: + """A registered custom parameter reaches the resulting argparse Action.""" + action = _action_for(Annotated[str, Argument(annotated_custom_attr="v")]) + assert action.get_annotated_custom_attr() == "v" # type: ignore[attr-defined] + + def test_unregistered_kwarg_raises_at_build(self) -> None: + """An unknown (unregistered) keyword is rejected when the parser is built.""" + _assert_build_error(Annotated[str, Argument(definitely_not_registered="v")]) + # --------------------------------------------------------------------------- # _CollectionCastingAction @@ -1322,42 +1605,42 @@ class TestCollectionRuntimeCast: """End-to-end verify ``parse_args`` returns the declared container type, not a plain list.""" def test_set_int_returns_set(self) -> None: - parser = build_parser_from_function(_func_set_int) + parser = build_parser_from_function(_make_func(set[int], name="nums")) ns = parser.parse_args(["1", "2", "2", "3"]) assert isinstance(ns.nums, set) assert ns.nums == {1, 2, 3} def test_tuple_ellipsis_returns_tuple(self) -> None: - parser = build_parser_from_function(_func_tuple_ellipsis) + parser = build_parser_from_function(_make_func(tuple[int, ...], name="values")) ns = parser.parse_args(["1", "2", "3"]) assert isinstance(ns.values, tuple) assert ns.values == (1, 2, 3) def test_tuple_fixed_returns_tuple(self) -> None: - parser = build_parser_from_function(_func_tuple_fixed) + parser = build_parser_from_function(_make_func(tuple[int, int], name="pair")) ns = parser.parse_args(["5", "10"]) assert isinstance(ns.pair, tuple) assert ns.pair == (5, 10) def test_list_bool_returns_list_of_bools(self) -> None: - parser = build_parser_from_function(_func_list_bool) + parser = build_parser_from_function(_make_func(list[bool], name="flags")) ns = parser.parse_args(["true", "no", "on"]) assert ns.flags == [True, False, True] def test_tuple_paths_returns_tuple_of_paths(self) -> None: - parser = build_parser_from_function(_func_tuple_paths) + parser = build_parser_from_function(_make_func(tuple[Path, Path], name="src_dst")) ns = parser.parse_args(["/tmp/a", "/tmp/b"]) assert isinstance(ns.src_dst, tuple) assert ns.src_dst == (Path("/tmp/a"), Path("/tmp/b")) def test_append_action_collects_values(self) -> None: - parser = build_parser_from_function(_func_append_action) + parser = build_parser_from_function(_make_func(Annotated[list[str], Option("--tag", action="append")], name="tag")) ns = parser.parse_args(["--tag", "a", "--tag", "b"]) assert ns.tag == ["a", "b"] def test_int_subclass_uses_int_converter(self) -> None: """``Port(int)`` falls back to ``int`` converter; argparse returns ``int``, not ``Port``.""" - parser = build_parser_from_function(_func_int_subclass) + parser = build_parser_from_function(_make_func(_Port, name="port")) ns = parser.parse_args(["8080"]) assert ns.port == 8080 @@ -1920,6 +2203,20 @@ def test_base_command_missing_handler_raises(self) -> None: def do_bad(self, verbose: bool = False) -> None: pass + def test_base_command_missing_handler_raises_with_no_parameters(self) -> None: + """A zero-parameter base command with no cmd2_handler must still raise. + + Guards the function-level ``cmd2_handler`` check (a plain ``if`` in ``_resolve_parameters``, + not a :data:`_CONSTRAINTS` row): the per-argument :data:`_CONSTRAINTS` loop never runs when + no arguments exist, so this case is the sole reason the missing-handler check lives at + function scope. + """ + with pytest.raises(TypeError, match="cmd2_handler"): + + @with_annotated(base_command=True) + def do_bad(self) -> None: + pass + def test_cmd2_handler_without_base_command_raises(self) -> None: """A 'cmd2_handler' parameter is only valid when base_command=True.""" with pytest.raises(TypeError, match="base_command=True"): @@ -1933,6 +2230,7 @@ def do_bad(self, cmd2_handler, name: str = "") -> None: [ pytest.param({"help": "not allowed"}, id="help_only"), pytest.param({"aliases": ["x"]}, id="aliases_only"), + pytest.param({"deprecated": True}, id="deprecated_only"), ], ) def test_subcmd_only_params_without_subcommand_to_raises(self, kwargs) -> None: @@ -2023,3 +2321,1084 @@ def team_create(self, name: str = "") -> None: ... assert spec.help == expected_help assert spec.aliases == expected_aliases assert isinstance(spec.parser_source(), argparse.ArgumentParser) + + @pytest.mark.parametrize("deprecated", [True, False]) + def test_subcommand_deprecated_flows_to_spec(self, deprecated) -> None: + from cmd2 import constants + + @with_annotated(subcommand_to="team", deprecated=deprecated) + def team_create(self, name: str = "") -> None: ... + + spec = getattr(team_create, constants.SUBCMD_ATTR_SPEC) + assert spec.deprecated is deprecated + + +# --------------------------------------------------------------------------- +# A non-Optional type with a None default is rejected (None would violate the hint) +# --------------------------------------------------------------------------- + + +class TestNoneDefaultRejection: + @pytest.mark.parametrize( + ("annotation", "kind"), + [ + pytest.param(str, "pos", id="str"), + pytest.param(int, "pos", id="int"), + pytest.param(list[str], "pos", id="list"), + pytest.param(Annotated[str, Option("-n")], "pos", id="option"), + pytest.param(Annotated[str, Argument()], "pos", id="argument"), + pytest.param(str, "kw", id="kw_only"), + ], + ) + def test_none_default_raises(self, annotation, kind) -> None: + _assert_build_error(annotation, default=None, kind=kind, match="not Optional") + + def test_optional_none_default_builds(self) -> None: + parser = build_parser_from_function(_make_func(str | None, default=None)) + assert parser.parse_args([]).value is None + + @pytest.mark.parametrize( + "annotation", + [ + pytest.param(Any, id="any"), + pytest.param(object, id="object"), + pytest.param(_MISSING, id="unannotated"), + ], + ) + def test_none_accepting_types_exempt(self, annotation) -> None: + assert build_parser_from_function(_make_func(annotation, default=None)).parse_args([]).value is None + + +# --------------------------------------------------------------------------- +# Tightened action= contract (result type must match the declared type) +# --------------------------------------------------------------------------- + + +class TestActionTightening: + @pytest.mark.parametrize( + ("annotation", "pattern"), + [ + pytest.param(Annotated[str, Option("-t", action="append")], "list", id="append_on_scalar"), + pytest.param(Annotated[set[str], Option("-t", action="append")], "list", id="append_on_set"), + pytest.param(Annotated[tuple[str, str], Option("-t", action="append")], "list", id="append_on_tuple"), + pytest.param(Annotated[str, Option("-t", action="extend")], "list", id="extend_on_scalar"), + pytest.param(Annotated[bool, Option("-c", action="count")], "int", id="count_on_bool"), + pytest.param(Annotated[str, Option("-c", action="store_const")], "const", id="store_const"), + pytest.param(Annotated[list[str], Option("-c", action="append_const")], "const", id="append_const"), + pytest.param(Annotated[str, Option("-c", action="frobnicate")], "not supported", id="unknown_action"), + pytest.param(Annotated[list[str], Option("-c", action="store_true")], "collection", id="noncollection_action"), + pytest.param(Annotated[int, Option("-c", action="store")], "not supported", id="explicit_store"), + pytest.param(Annotated[int, Option("-c", action="store", const=5)], "ambiguous", id="store_with_const"), + # The ambiguity wins even when the const's type also mismatches the declared type. + pytest.param(Annotated[int, Option("-c", action="store", const="x")], "ambiguous", id="store_with_mistyped_const"), + ], + ) + def test_action_type_mismatch_raises(self, annotation, pattern) -> None: + _assert_build_error(annotation, match=pattern) + + def test_append_on_list_accumulates(self) -> None: + parser = build_parser_from_function(_make_func(Annotated[list[str], Option("--tag", action="append")])) + assert parser.parse_args([]).value == [] + assert parser.parse_args(["--tag", "a", "--tag", "b"]).value == ["a", "b"] + + def test_extend_on_list_flattens_and_converts(self) -> None: + parser = build_parser_from_function(_make_func(Annotated[list[int], Option("-n", action="extend")])) + assert parser.parse_args([]).value == [] + assert parser.parse_args(["-n", "1", "2", "-n", "3"]).value == [1, 2, 3] + + def test_count_no_default_yields_zero(self) -> None: + annotation = Annotated[int, Option("-l", action="count")] + action = _action_for(annotation) + assert action.required is False + assert build_parser_from_function(_make_func(annotation)).parse_args([]).value == 0 + + def test_append_with_nargs_raises(self) -> None: + _assert_build_error(Annotated[list[str], Option("--tag", action="append", nargs=2)], match="nargs") + + @pytest.mark.parametrize( + ("annotation", "omit_value"), + [ + pytest.param(Annotated[int | None, Option("-c", action="count")], None, id="count_optional"), + pytest.param(Annotated[list[str] | None, Option("-t", action="append")], None, id="append_optional"), + pytest.param(Annotated[bool | None, Option("-v", action="store_true")], None, id="store_true_optional"), + pytest.param(Annotated[bool | None, Option("-q", action="store_false")], None, id="store_false_optional"), + ], + ) + def test_self_defaulting_action_optional_yields_none(self, annotation, omit_value) -> None: + assert build_parser_from_function(_make_func(annotation)).parse_args([]).value is omit_value + + @pytest.mark.parametrize( + ("annotation", "omit_value"), + [ + pytest.param(Annotated[bool, Option("-v", action="store_true")], False, id="store_true"), + pytest.param(Annotated[bool, Option("-q", action="store_false")], True, id="store_false"), + ], + ) + def test_store_bool_action_non_optional_default(self, annotation, omit_value) -> None: + """A non-Optional store_true/store_false still defaults to its natural absence value.""" + assert build_parser_from_function(_make_func(annotation)).parse_args([]).value is omit_value + + +# --------------------------------------------------------------------------- +# const= on Option (store_const / append_const), inferred from the declared type +# --------------------------------------------------------------------------- + + +class TestConstOption: + def test_const_to_kwargs(self) -> None: + assert Option("-c", const=5).to_kwargs()["const"] == 5 + + def test_const_none_to_kwargs_preserved(self) -> None: + """An explicit const=None is kept (distinct from no const given).""" + assert Option("-c", const=None).to_kwargs()["const"] is None + assert "const" not in Option("-c").to_kwargs() + + def test_const_on_scalar_infers_store_const(self) -> None: + p = build_parser_from_function(_make_func(Annotated[int, Option("-v", const=2)], default=0)) + assert p.parse_args([]).value == 0 + assert p.parse_args(["-v"]).value == 2 + + def test_const_on_list_infers_append_const(self) -> None: + p = build_parser_from_function(_make_func(Annotated[list[str], Option("--tag", const="x")])) + assert p.parse_args([]).value == [] + assert p.parse_args(["--tag", "--tag"]).value == ["x", "x"] + + def test_explicit_store_const(self) -> None: + annotation = Annotated[int, Option("-v", action="store_const", const=2)] + assert build_parser_from_function(_make_func(annotation, default=0)).parse_args(["-v"]).value == 2 + + def test_explicit_append_const(self) -> None: + annotation = Annotated[list[str], Option("--tag", action="append_const", const="x")] + assert build_parser_from_function(_make_func(annotation)).parse_args(["--tag"]).value == ["x"] + + def test_store_const_optional_absent_is_none(self) -> None: + p = build_parser_from_function(_make_func(Annotated[str | None, Option("-m", const="fast")], default=None)) + assert p.parse_args([]).value is None + assert p.parse_args(["-m"]).value == "fast" + + def test_const_enum_member_stored_as_member(self) -> None: + class Color(enum.Enum): + RED = "red" + GREEN = "green" + + annotation = Annotated[Color | None, Option("-c", const=Color.RED)] + assert build_parser_from_function(_make_func(annotation, default=None)).parse_args(["-c"]).value is Color.RED + + @pytest.mark.parametrize( + ("annotation", "default", "expected"), + [ + # const matching the declared (non-str) scalar type is accepted and stored verbatim. + pytest.param(Annotated[float, Option("-x", const=1.5)], 0.0, 1.5, id="float_const"), + pytest.param(Annotated[float, Option("-x", const=5)], 0.0, 5, id="float_const_int_ok"), + pytest.param( + Annotated[decimal.Decimal, Option("-x", const=decimal.Decimal("1.5"))], + decimal.Decimal(0), + decimal.Decimal("1.5"), + id="decimal_const", + ), + pytest.param(Annotated[Path, Option("-x", const=Path("/tmp"))], Path("."), Path("/tmp"), id="path_const"), + ], + ) + def test_const_matching_scalar_type_accepted(self, annotation, default, expected) -> None: + p = build_parser_from_function(_make_func(annotation, default=default)) + assert p.parse_args(["-x"]).value == expected + + def test_path_store_const_drops_inferred_completer(self) -> None: + """A Path const infers store_const (a zero-arg action); the inferred path completer must be + dropped, else argparse rejects a completer on an action that takes no value.""" + p = build_parser_from_function(_make_func(Annotated[Path, Option("-x", const=Path("/tmp"))], default=Path("."))) + assert p.parse_args([]).value == Path(".") + assert p.parse_args(["-x"]).value == Path("/tmp") + + def test_path_append_const_drops_inferred_completer(self) -> None: + """append_const on list[Path] is also a zero-arg action; its inferred completer is dropped too.""" + p = build_parser_from_function( + _make_func(Annotated[list[Path], Option("-x", action="append_const", const=Path("/tmp"))]) + ) + assert p.parse_args(["-x", "-x"]).value == [Path("/tmp"), Path("/tmp")] + + # --- error cases --- + + @pytest.mark.parametrize( + ("annotation", "default", "match"), + [ + pytest.param(Annotated[int, Option("-n", const="notanint")], 0, "const", id="type_mismatch_scalar"), + pytest.param(Annotated[Literal["a", "b"], Option("-m", const="c")], "a", "const", id="not_a_literal_member"), + pytest.param(Annotated[float, Option("-x", const="s")], 0.0, "const", id="type_mismatch_float"), + pytest.param(Annotated[float, Option("-x", const=True)], 0.0, "const", id="float_const_rejects_bool"), + pytest.param( + Annotated[decimal.Decimal, Option("-x", const=5)], decimal.Decimal(0), "const", id="type_mismatch_decimal" + ), + pytest.param(Annotated[Path, Option("-x", const="/tmp")], Path("."), "const", id="type_mismatch_path"), + pytest.param( + Annotated[list[str], Option("-x", action="store_const", const="v")], + _MISSING, + "'list' is a collection", + id="store_const_on_list", + ), + pytest.param( + Annotated[str, Option("-x", action="append_const", const="v")], + "", + "'str' is not a list", + id="append_const_on_scalar", + ), + pytest.param(Annotated[str, Option("-x", action="store_const")], "", "const", id="action_without_const"), + pytest.param(Annotated[int, Option("-x", action="count", const=2)], 0, "const", id="incompatible_action"), + pytest.param(Annotated[set[str] | None, Option("-x", const="v")], None, "'set' is not a list", id="const_on_set"), + pytest.param(Annotated[int, Option("-x", const=5)], _MISSING, "default", id="non_optional_no_default"), + ], + ) + def test_const_error(self, annotation, default, match) -> None: + _assert_build_error(annotation, default=default, match=match) + + +class TestNargsOptionalConst: + """A scalar Option with an explicit nargs + const is argparse's optional-value-with-fallback idiom. + + It must NOT collapse to a value-less store_const (which would drop the explicit nargs and the type + converter); it keeps the ``store`` action so absent -> default, bare flag -> const, flag VALUE -> value. + """ + + def test_str_optional_nargs_const_three_way(self) -> None: + annotation = Annotated[str | None, Option("--log", nargs="?", const="CONSOLE")] + p = build_parser_from_function(_make_func(annotation, name="log", default="OFF")) + act = next(a for a in p._actions if a.dest == "log") + assert isinstance(act, argparse._StoreAction) # not _StoreConstAction + assert act.nargs == "?" + assert act.const == "CONSOLE" + assert p.parse_args([]).log == "OFF" # absent -> default + assert p.parse_args(["--log"]).log == "CONSOLE" # bare flag -> const + assert p.parse_args(["--log", "FILE"]).log == "FILE" # flag VALUE -> value + + def test_int_optional_nargs_const_converts_supplied_value(self) -> None: + """The supplied VALUE still runs through the inferred converter; the const is stored verbatim.""" + annotation = Annotated[int | None, Option("--n", nargs="?", const=99)] + p = build_parser_from_function(_make_func(annotation, name="n", default=0)) + assert p.parse_args([]).n == 0 + assert p.parse_args(["--n"]).n == 99 + assert p.parse_args(["--n", "7"]).n == 7 # type=int applied to the value + + def test_nargs_const_still_validates_const_type(self) -> None: + """const must still match the declared type even on the nargs='?' path (stored verbatim).""" + _assert_build_error(Annotated[int, Option("--n", nargs="?", const="notint")], name="n", default=0, match="const") + + def test_scalar_const_without_nargs_still_store_const(self) -> None: + """Regression guard: const alone (no nargs) keeps inferring the value-less store_const.""" + p = build_parser_from_function(_make_func(Annotated[str, Option("--log", const="X")], name="log", default="off")) + act = next(a for a in p._actions if a.dest == "log") + assert isinstance(act, argparse._StoreConstAction) + assert p.parse_args(["--log"]).log == "X" + with pytest.raises(SystemExit): + p.parse_args(["--log", "Y"]) # value-less action rejects a supplied value + + +class TestZeroArgActionRejectsUserCompletion: + """A user-supplied completer/choices_provider on a value-less action has nothing to complete. + + Raw cmd2 raises in this case, so @with_annotated must fail loud rather than silently dropping the + user's request. A *type-inferred* completer (e.g. Path's) is still dropped silently -- only an + explicit one is rejected. + """ + + def _provider(self) -> list[str]: + return ["a", "b"] + + @pytest.mark.parametrize("action", ["store_true", "store_false"]) + def test_bool_flag_action_rejects_user_completer(self, action) -> None: + annotation = Annotated[bool, Option("--flag", action=action, completer=cmd2.Cmd.path_complete)] + _assert_build_error(annotation, name="flag", default=False, match="cannot be used with action") + + def test_count_action_rejects_user_choices_provider(self) -> None: + annotation = Annotated[ + int, Option("-v", action="count", choices_provider=TestZeroArgActionRejectsUserCompletion._provider) + ] + _assert_build_error(annotation, name="v", default=0, match="cannot be used with action") + + def test_store_const_rejects_user_completer(self) -> None: + annotation = Annotated[str, Option("-m", action="store_const", const="x", completer=cmd2.Cmd.path_complete)] + _assert_build_error(annotation, name="m", default="off", match="cannot be used with action") + + def test_append_const_rejects_user_choices_provider(self) -> None: + annotation = Annotated[ + list[str], + Option( + "--tag", action="append_const", const="x", choices_provider=TestZeroArgActionRejectsUserCompletion._provider + ), + ] + _assert_build_error(annotation, name="tag", match="cannot be used with action") + + def test_value_action_keeps_user_completer(self) -> None: + """Control: a value-consuming option still accepts a user completer (not a zero-arg action).""" + annotation = Annotated[str, Option("--path", completer=cmd2.Cmd.path_complete)] + p = build_parser_from_function(_make_func(annotation, name="path", default="")) + act = next(a for a in p._actions if a.dest == "path") + assert act.get_completer() is cmd2.Cmd.path_complete + + +class TestConstArgumentRejected: + def test_argument_const_raises(self) -> None: + _assert_build_error(Annotated[str, Argument(const="x")], match="const") + + def test_positional_const_via_no_metadata_path(self) -> None: + """A const on a parameter that resolves to a positional is rejected, not silently ignored.""" + + def do_x(self, name: Annotated[str, Argument(const="x")], other: str = "y") -> None: ... + + with pytest.raises(TypeError, match="const"): + build_parser_from_function(do_x) + + +# --------------------------------------------------------------------------- +# Custom ``argparse.Action`` subclasses pass through to ``add_argument`` +# --------------------------------------------------------------------------- + + +class _UpperAction(argparse.Action): + """Test action: store the upper-case value.""" + + def __call__( + self, + _parser: argparse.ArgumentParser, + ns: argparse.Namespace, + values: Any, + _option_string: str | None = None, + ) -> None: + setattr(ns, self.dest, values.upper() if isinstance(values, str) else values) + + +class _FlagAction(argparse.Action): + """Test action: a presence-flag that takes no command-line value.""" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(nargs=0, **kwargs) + + def __call__( + self, + _parser: argparse.ArgumentParser, + ns: argparse.Namespace, + _values: Any, + _option_string: str | None = None, + ) -> None: + setattr(ns, self.dest, True) + + +class _ListAction(argparse.Action): + """Test action: store values as a plain list (no container_factory wrap).""" + + def __call__( + self, + _parser: argparse.ArgumentParser, + ns: argparse.Namespace, + values: Any, + _option_string: str | None = None, + ) -> None: + setattr(ns, self.dest, list(values)) + + +class _ConstAction(argparse.Action): + """Test action: store ``self.const`` on presence.""" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(nargs=0, **kwargs) + + def __call__( + self, + _parser: argparse.ArgumentParser, + ns: argparse.Namespace, + _values: Any, + _option_string: str | None = None, + ) -> None: + setattr(ns, self.dest, self.const) + + +class TestCustomActionClass: + def test_scalar_class_action_passes_through(self) -> None: + """A class action runs verbatim; the type-inferred passthrough doesn't get in the way.""" + + def f(self, name: Annotated[str, Option("--name", action=_UpperAction)] = "") -> None: ... + + parser = build_parser_from_function(f) + assert parser.parse_args(["--name", "hi"]).name == "HI" + + def test_class_action_overrides_inferred_boolean_optional(self) -> None: + """A class action on a bool option replaces the inferred ``BooleanOptionalAction``.""" + + def f(self, loud: Annotated[bool, Option("--loud", action=_FlagAction)] = False) -> None: ... + + parser = build_parser_from_function(f) + assert parser.parse_args(["--loud"]).loud is True + + def test_class_action_on_list_drops_container_factory(self) -> None: + """A class action on ``list[T]`` doesn't receive the casting action's ``container_factory``.""" + + def f(self, xs: Annotated[list[str], Option("--xs", action=_ListAction, nargs="+")] = ()) -> None: ... + + parser = build_parser_from_function(f) + assert parser.parse_args(["--xs", "a", "b"]).xs == ["a", "b"] + + def test_class_action_with_const_skips_type_check(self) -> None: + """``const`` paired with a class action is not validated against the declared type.""" + sentinel = object() + + def f(self, mode: Annotated[str, Option("-m", action=_ConstAction, const=sentinel)] = "x") -> None: ... + + parser = build_parser_from_function(f) + assert parser.parse_args(["-m"]).mode is sentinel + + def test_class_action_uses_inferred_converter_on_scalar(self) -> None: + """The inferred ``type=int`` reaches the class action so values still coerce.""" + + class CaptureAction(argparse.Action): + def __call__(self, _p: Any, ns: Any, values: Any, _o: str | None = None) -> None: + setattr(ns, self.dest, ("seen", values)) + + def f(self, n: Annotated[int, Option("-n", action=CaptureAction)] = 0) -> None: ... + + parser = build_parser_from_function(f) + assert parser.parse_args(["-n", "5"]).n == ("seen", 5) + + def test_unknown_string_action_still_rejected(self) -> None: + """A typo in an action string is still caught -- only class actions are exempt.""" + _assert_build_error(Annotated[str, Option("-x", action="frobnicate")], match="not supported") + + +# --------------------------------------------------------------------------- +# ``extra_kwargs`` rejects argparse kwargs that the decorator derives elsewhere +# --------------------------------------------------------------------------- + + +class TestReservedExtraKwargs: + @pytest.mark.parametrize("kw", ["type", "dest", "action", "required"]) + def test_argument_rejects_reserved_kwarg(self, kw: str) -> None: + with pytest.raises(TypeError, match=kw): + Argument(**{kw: "x"}) + + @pytest.mark.parametrize("kw", ["type", "dest"]) + def test_option_rejects_reserved_kwarg(self, kw: str) -> None: + with pytest.raises(TypeError, match=kw): + Option("--x", **{kw: "x"}) + + def test_option_keeps_named_action_and_required(self) -> None: + """``action=`` and ``required=`` are named parameters of ``Option`` and must still work.""" + opt = Option("--x", action="store_true", required=True) + assert opt.action == "store_true" + assert opt.required is True + + def test_error_message_includes_remediation_hint(self) -> None: + """The error names the offending kwarg and points at the signature-derived source.""" + with pytest.raises(TypeError) as excinfo: + Option("--x", type=int) + msg = str(excinfo.value) + assert "type" in msg + assert "annotation" in msg + + def test_unknown_registered_kwarg_still_passes_through(self) -> None: + """A user-registered custom add_argument kwarg still flows through ``extra_kwargs``.""" + # The custom parameter ``annotated_custom_attr`` is registered at module import. + action = _action_for(Annotated[str, Argument(annotated_custom_attr="value")]) + assert action.get_annotated_custom_attr() == "value" # type: ignore[attr-defined] + + +# --------------------------------------------------------------------------- +# ``default=`` as a metadata kwarg: equivalent to a signature default, with conflict and +# SUPPRESS rejected. +# --------------------------------------------------------------------------- + + +class TestMetadataDefault: + def test_option_metadata_default_equivalent_to_signature_default(self) -> None: + """``Option(default=v)`` (no sig default) behaves the same as ``Option() = v``.""" + + def f_meta(self, name: Annotated[str, Option("--name", default="HI")]) -> None: ... + def f_sig(self, name: Annotated[str, Option("--name")] = "HI") -> None: ... + + p_meta = build_parser_from_function(f_meta) + p_sig = build_parser_from_function(f_sig) + assert p_meta.parse_args([]).name == p_sig.parse_args([]).name == "HI" + assert p_meta.parse_args(["--name", "X"]).name == p_sig.parse_args(["--name", "X"]).name == "X" + + def test_argument_metadata_default_equivalent_to_signature_default(self) -> None: + """``Argument(default=v)`` (no sig default) behaves the same as ``Argument() = v``.""" + + def f_meta(self, name: Annotated[str, Argument(default="POS")]) -> None: ... + def f_sig(self, name: Annotated[str, Argument()] = "POS") -> None: ... + + p_meta = build_parser_from_function(f_meta) + p_sig = build_parser_from_function(f_sig) + assert p_meta.parse_args([]).name == p_sig.parse_args([]).name == "POS" + assert p_meta.parse_args(["X"]).name == p_sig.parse_args(["X"]).name == "X" + + def test_metadata_default_makes_option_not_required(self) -> None: + """A metadata default removes ``required`` -- same as a signature default.""" + action = _action_for(Annotated[str, Option("--x", default="HI")]) + assert action.required is False + + def test_metadata_default_makes_positional_nargs_optional(self) -> None: + """A metadata default on a positional sets ``nargs='?'`` -- same as a signature default.""" + action = _action_for(Annotated[str, Argument(default="HI")]) + assert action.nargs == "?" + + def test_default_conflict_signature_and_metadata_raises(self) -> None: + """Both a signature default and a metadata default is a conflict.""" + + def f(self, name: Annotated[str, Option("--name", default="HI")] = "hello") -> None: ... + + with pytest.raises(TypeError, match="default in both"): + build_parser_from_function(f) + + def test_default_conflict_message_names_both_values(self) -> None: + """The conflict error message includes both candidate values.""" + + def f(self, name: Annotated[str, Option("--name", default="meta")] = "sig") -> None: ... + + with pytest.raises(TypeError) as excinfo: + build_parser_from_function(f) + msg = str(excinfo.value) + assert "'sig'" in msg + assert "'meta'" in msg + + def test_argparse_suppress_metadata_default_rejected(self) -> None: + """``default=argparse.SUPPRESS`` in metadata is rejected (kwarg would vanish).""" + + def f(self, name: Annotated[str, Option("--name", default=argparse.SUPPRESS)]) -> None: ... + + with pytest.raises(TypeError, match="SUPPRESS"): + build_parser_from_function(f) + + def test_argparse_suppress_signature_default_rejected(self) -> None: + """``= argparse.SUPPRESS`` in the signature is rejected as well.""" + + def f(self, name: str = argparse.SUPPRESS) -> None: ... + + with pytest.raises(TypeError, match="SUPPRESS"): + build_parser_from_function(f) + + def test_metadata_default_none_on_non_optional_rejected(self) -> None: + """The 'None default on non-Optional' rule applies to a metadata default as well.""" + _assert_build_error(Annotated[str, Option("--x", default=None)], match="None") + + def test_metadata_default_none_on_optional_accepted(self) -> None: + """``default=None`` on a ``T | None`` annotation is fine (consistent with signature defaults).""" + action = _action_for(Annotated[str | None, Option("--x", default=None)]) + assert action.default is None + assert action.required is False + + def test_metadata_default_with_explicit_action(self) -> None: + """A metadata default flows through to the action layer (here: store_const).""" + annotation = Annotated[int, Option("-v", action="store_const", const=2, default=0)] + parser = build_parser_from_function(_make_func(annotation)) + assert parser.parse_args([]).value == 0 + assert parser.parse_args(["-v"]).value == 2 + + +# --------------------------------------------------------------------------- +# A variable-arity positional must be the last positional +# --------------------------------------------------------------------------- + + +class TestPositionalOrdering: + def test_optional_positional_before_required_raises(self) -> None: + def do_x(self, a: str | None, b: str) -> None: ... + + with pytest.raises(TypeError, match="variable arity"): + build_parser_from_function(do_x) + + def test_list_positional_before_positional_raises(self) -> None: + def do_x(self, items: list[str], b: str) -> None: ... + + with pytest.raises(TypeError, match="variable arity"): + build_parser_from_function(do_x) + + def test_required_positional_before_star_args_builds(self) -> None: + def do_x(self, a: str, *args: str) -> None: ... + + parser = build_parser_from_function(do_x) + ns = parser.parse_args(["one", "two", "three"]) + assert ns.a == "one" + assert ns.args == ("two", "three") + + def test_ranged_nargs_collection_before_positional_raises(self) -> None: + def do_x(self, items: Annotated[list[str], Argument(nargs=(1, 3))], b: str) -> None: ... + + with pytest.raises(TypeError, match="variable arity"): + build_parser_from_function(do_x) + + +# --------------------------------------------------------------------------- +# Ranged nargs (min, max) tuples +# --------------------------------------------------------------------------- + + +class TestRangedNargs: + def test_ranged_nargs_on_scalar_raises(self) -> None: + _assert_build_error(Annotated[str, Argument(nargs=(2, 4))], match="not a collection type") + + def test_ranged_nargs_on_list_builds(self) -> None: + parser = build_parser_from_function(_make_func(Annotated[list[str], Argument(nargs=(2, 4))])) + assert parser.parse_args(["a", "b", "c"]).value == ["a", "b", "c"] + + def test_ranged_nargs_zero_one_on_scalar_builds(self) -> None: + # cmd2 collapses the (0, 1) range to OPTIONAL ('?'), which yields a single + # optional value -- not a list -- so it is allowed on a scalar, like nargs='?'. + parser = build_parser_from_function(_make_func(Annotated[str, Argument(nargs=(0, 1))])) + assert parser.parse_args(["x"]).value == "x" + assert parser.parse_args([]).value is None + + +# --------------------------------------------------------------------------- +# Subcommands group configuration (required / metavar / title / description) +# --------------------------------------------------------------------------- + + +class TestSubcommandGroupConfig: + @staticmethod + def _base_parser(**subcommand_kwargs): + from cmd2 import constants + + @with_annotated(base_command=True, **subcommand_kwargs) + def do_root(self, cmd2_handler) -> None: ... + + builder = getattr(do_root, constants.CMD_ATTR_PARSER_SOURCE) + return builder() + + @staticmethod + def _subparsers_action(parser): + return next(a for a in parser._actions if isinstance(a, argparse._SubParsersAction)) + + def test_defaults_required_with_subcommand_metavar(self) -> None: + action = self._subparsers_action(self._base_parser()) + assert action.required is True + assert action.metavar == "SUBCOMMAND" + + def test_subcommand_required_false(self) -> None: + action = self._subparsers_action(self._base_parser(subcommand_required=False)) + assert action.required is False + + def test_subcommand_metavar_override(self) -> None: + action = self._subparsers_action(self._base_parser(subcommand_metavar="CMD")) + assert action.metavar == "CMD" + + def test_subcommand_title_and_description(self) -> None: + parser = self._base_parser(subcommand_title="Commands", subcommand_description="pick one") + group = next((g for g in parser._action_groups if g.title == "Commands"), None) + assert group is not None + assert group.description == "pick one" + + +# --------------------------------------------------------------------------- +# Rich objects are accepted for description / epilog (HelpContent) +# --------------------------------------------------------------------------- + + +class TestRichHelpContent: + def test_rich_description_and_epilog_accepted(self) -> None: + from rich.text import Text + + desc = Text("a rich description") + parser = build_parser_from_function(_make_func(str), description=desc, epilog=Text("epilog")) + assert parser.description is desc + + +# --------------------------------------------------------------------------- +# Docstring auto-extraction for parser description +# --------------------------------------------------------------------------- + + +class TestDocstringDescription: + """The first paragraph of ``func.__doc__`` fills ``description`` when none is given.""" + + def test_first_paragraph_used_when_no_description(self) -> None: + def func(self, name: str) -> None: + """Summary line for the command. + + More detail here that should not appear in description. + + :param name: a name + """ + + parser = build_parser_from_function(func) + assert parser.description == "Summary line for the command." + + def test_multiline_first_paragraph_preserved(self) -> None: + def func(self, name: str) -> None: + """First line continues + onto the second line without a blank gap. + + Detail paragraph below the blank line is dropped. + """ + + parser = build_parser_from_function(func) + assert parser.description == "First line continues\nonto the second line without a blank gap." + + def test_explicit_description_overrides_docstring(self) -> None: + def func(self, name: str) -> None: + """Auto summary.""" + + parser = build_parser_from_function(func, description="explicit") + assert parser.description == "explicit" + + def test_no_docstring_means_no_description(self) -> None: + def func(self, name: str) -> None: ... + + parser = build_parser_from_function(func) + assert parser.description is None + + def test_empty_docstring_means_no_description(self) -> None: + def func(self, name: str) -> None: + """ """ + + parser = build_parser_from_function(func) + assert parser.description is None + + def test_field_directive_without_blank_line_does_not_leak(self) -> None: + """A ``:param:`` directly under the summary (no blank line) is stripped, not leaked.""" + + def func(self, name: str) -> None: + """Summary line. + :param name: should not leak into the description + """ + + parser = build_parser_from_function(func) + assert parser.description == "Summary line." + + def test_decorator_uses_docstring(self) -> None: + from cmd2 import constants + + @with_annotated + def do_run(self, name: str) -> None: + """Run the thing. + + Extra detail. + """ + + builder = getattr(do_run, constants.CMD_ATTR_PARSER_SOURCE) + assert builder().description == "Run the thing." + + def test_subcommand_uses_docstring(self) -> None: + from cmd2 import constants + + @with_annotated(subcommand_to="team") + def team_add(self, name: str) -> None: + """Add a member to the team.""" + + spec = getattr(team_add, constants.SUBCMD_ATTR_SPEC) + assert spec.parser_source().description == "Add a member to the team." + + +# --------------------------------------------------------------------------- +# Group(required=...) for mutually exclusive groups +# --------------------------------------------------------------------------- + + +class TestMutuallyExclusiveGroupRequired: + """``Group(required=True)`` reaches ``add_mutually_exclusive_group(required=True)``.""" + + def test_required_mutex_group_flag_set(self) -> None: + def func(self, verbose: bool = False, quiet: bool = False) -> None: ... + + parser = build_parser_from_function(func, mutually_exclusive_groups=(Group("verbose", "quiet", required=True),)) + assert parser._mutually_exclusive_groups[0].required is True + + def test_default_mutex_group_not_required(self) -> None: + def func(self, verbose: bool = False, quiet: bool = False) -> None: ... + + parser = build_parser_from_function(func, mutually_exclusive_groups=(Group("verbose", "quiet"),)) + assert parser._mutually_exclusive_groups[0].required is False + + def test_required_mutex_group_argparse_enforces(self) -> None: + def func(self, verbose: bool = False, quiet: bool = False) -> None: ... + + parser = build_parser_from_function(func, mutually_exclusive_groups=(Group("verbose", "quiet", required=True),)) + with pytest.raises(SystemExit): + parser.parse_args([]) + + def test_required_on_plain_group_rejected(self) -> None: + """``required=True`` is only valid in mutex groups; argparse doesn't support it elsewhere.""" + + def func(self, host: str, port: int = 22) -> None: ... + + with pytest.raises(ValueError, match="only valid in mutually_exclusive_groups"): + build_parser_from_function(func, groups=(Group("host", "port", required=True),)) + + +# --------------------------------------------------------------------------- +# Parser-level kwargs: prog / usage / parents / argument_default +# --------------------------------------------------------------------------- + + +class TestParserLevelKwargs: + """Forward ``prog``, ``usage``, ``parents``, ``argument_default`` to the parser ctor.""" + + def test_prog_passthrough(self) -> None: + parser = build_parser_from_function(_make_func(str), prog="myprog") + assert parser.prog == "myprog" + + def test_usage_passthrough(self) -> None: + parser = build_parser_from_function(_make_func(str), usage="usage: do stuff") + assert parser.usage == "usage: do stuff" + + def test_parents_passthrough(self) -> None: + """argparse ``parents=`` copies argument actions from each parent into the new parser.""" + parent = argparse.ArgumentParser(add_help=False) + parent.add_argument("--shared", help="from parent") + + parser = build_parser_from_function(_make_func(str), parents=[parent]) + dests = {a.dest for a in parser._actions} + assert "shared" in dests + + def test_argument_default_passthrough(self) -> None: + sentinel = "DEFAULT_FROM_PARSER" + parser = build_parser_from_function(_make_func(str), argument_default=sentinel) + assert parser.argument_default == sentinel + + def test_argument_default_suppress_works_with_explicit_defaults(self) -> None: + """``argument_default=SUPPRESS`` is safe when every argument sets its own default. + + Every ``@with_annotated`` argument either is positional (always supplied) or + has an explicit default, so SUPPRESS at the parser level can't drop a kwarg + the function expects. + """ + + def func(self, name: str, count: int = 1) -> None: ... + + parser = build_parser_from_function(func, argument_default=argparse.SUPPRESS) + ns = parser.parse_args(["alice"]) + assert ns.name == "alice" + assert ns.count == 1 + + def test_decorator_passes_parser_kwargs(self) -> None: + from cmd2 import constants + + @with_annotated(prog="myprog", usage="usage line") + def do_run(self, name: str) -> None: ... + + builder = getattr(do_run, constants.CMD_ATTR_PARSER_SOURCE) + parser = builder() + assert parser.prog == "myprog" + assert parser.usage == "usage line" + + def test_prog_rejected_with_subcommand_to(self) -> None: + """cmd2's subcommand machinery rewrites ``prog`` from the parent hierarchy.""" + with pytest.raises(TypeError, match=r"prog .* not supported with subcommand_to"): + + @with_annotated(subcommand_to="team", prog="something") + def team_add(self, name: str) -> None: ... + + def test_usage_allowed_on_subcommand(self) -> None: + """``usage`` doesn't conflict with subcommand prog rewriting.""" + from cmd2 import constants + + @with_annotated(subcommand_to="team", usage="team add NAME") + def team_add(self, name: str) -> None: ... + + spec = getattr(team_add, constants.SUBCMD_ATTR_SPEC) + assert spec.parser_source().usage == "team add NAME" + + def test_parents_allowed_on_subcommand(self) -> None: + from cmd2 import constants + + parent = argparse.ArgumentParser(add_help=False) + parent.add_argument("--shared") + + @with_annotated(subcommand_to="team", parents=[parent]) + def team_add(self, name: str) -> None: ... + + spec = getattr(team_add, constants.SUBCMD_ATTR_SPEC) + dests = {a.dest for a in spec.parser_source()._actions} + assert "shared" in dests + + +# --------------------------------------------------------------------------- +# Less-common parser-level kwargs: prefix_chars / fromfile_prefix_chars / +# conflict_handler / add_help / allow_abbrev / exit_on_error +# --------------------------------------------------------------------------- + + +class TestParserLowLevelKwargs: + """Forward the remaining argparse parser ctor kwargs.""" + + def test_prefix_chars_passthrough(self) -> None: + """A non-default ``prefix_chars`` propagates so ``Option('+flag')`` would be legal.""" + parser = build_parser_from_function(_make_func(str), prefix_chars="+-") + assert parser.prefix_chars == "+-" + + def test_fromfile_prefix_chars_enables_argument_files(self) -> None: + """argparse loads tokens from a file when an arg starts with the prefix char.""" + import tempfile + + def func(self, name: str) -> None: ... + + parser = build_parser_from_function(func, fromfile_prefix_chars="@") + with tempfile.NamedTemporaryFile("w", suffix=".args", delete=False) as fh: + fh.write("alice\n") + path = fh.name + try: + ns = parser.parse_args([f"@{path}"]) + assert ns.name == "alice" + finally: + Path(path).unlink() + + def test_conflict_handler_resolve_lets_parents_be_overridden(self) -> None: + """``conflict_handler='resolve'`` allows a parent's ``--flag`` to be redefined.""" + parent = argparse.ArgumentParser(add_help=False) + parent.add_argument("--mode", default="parent") + + def func(self, mode: str = "child") -> None: ... + + parser = build_parser_from_function(func, parents=[parent], conflict_handler="resolve") + ns = parser.parse_args([]) + # The locally-declared --mode wins after the resolve. + assert ns.mode == "child" + + def test_add_help_false_drops_help_action(self) -> None: + parser = build_parser_from_function(_make_func(str), add_help=False) + assert all(not isinstance(a, argparse._HelpAction) for a in parser._actions) + + def test_allow_abbrev_false_rejects_prefix_match(self) -> None: + """With abbreviations off, ``--verb`` no longer matches ``--verbose``.""" + + def func(self, verbose: bool = False) -> None: ... + + parser = build_parser_from_function(func, allow_abbrev=False) + with pytest.raises(SystemExit): + parser.parse_args(["--verb"]) + + def test_exit_on_error_false_raises_argument_error(self) -> None: + """``exit_on_error=False`` surfaces parse failures as exceptions instead of sys.exit.""" + + def func(self, count: int) -> None: ... + + parser = build_parser_from_function(func, exit_on_error=False) + with pytest.raises(argparse.ArgumentError): + parser.parse_args(["not-an-int"]) + + def test_decorator_threads_all_low_level_kwargs(self) -> None: + """End-to-end: each kwarg lands on the parser when set on the decorator.""" + from cmd2 import constants + + @with_annotated( + prefix_chars="+-", + conflict_handler="resolve", + add_help=False, + allow_abbrev=False, + exit_on_error=False, + fromfile_prefix_chars="@", + ) + def do_run(self, name: str) -> None: ... + + builder = getattr(do_run, constants.CMD_ATTR_PARSER_SOURCE) + parser = builder() + assert parser.prefix_chars == "+-" + assert parser.fromfile_prefix_chars == "@" + assert parser.conflict_handler == "resolve" + assert all(not isinstance(a, argparse._HelpAction) for a in parser._actions) + assert parser.allow_abbrev is False + assert parser.exit_on_error is False + + +class TestExplicitChoicesValueSpace: + """Explicit ``choices=`` are reconciled with the inferred type converter and completer.""" + + def test_string_choices_converted_to_declared_type(self) -> None: + """choices=['1','2'] on an int parameter match after argparse runs the int converter.""" + p = build_parser_from_function(_make_func(Annotated[int, Option("--x", choices=["1", "2"])], default=1, name="x")) + action = next(a for a in p._actions if a.dest == "x") + assert action.choices == [1, 2] + assert p.parse_args(["--x", "1"]).x == 1 + with pytest.raises(SystemExit): + p.parse_args(["--x", "3"]) + + def test_already_typed_choices_left_untouched(self) -> None: + p = build_parser_from_function(_make_func(Annotated[int, Option("--x", choices=[1, 2])], default=1, name="x")) + action = next(a for a in p._actions if a.dest == "x") + assert action.choices == [1, 2] + + def test_choice_invalid_for_type_is_build_error(self) -> None: + _assert_build_error(Annotated[int, Option("--x", choices=["1", "nope"])], default=1, match="not a valid") + + def test_explicit_choices_kept_over_inferred_path_completer(self) -> None: + """An explicit choices= on a Path is retained (and the inferred path completer dropped).""" + p = build_parser_from_function(_make_func(Annotated[Path, Argument(choices=[Path("/a"), Path("/b")])], name="p")) + action = next(a for a in p._actions if a.dest == "p") + assert action.choices == [Path("/a"), Path("/b")] + assert getattr(action, "completer", None) is None + assert p.parse_args(["/a"]).p == Path("/a") + with pytest.raises(SystemExit): + p.parse_args(["/c"]) + + def test_user_completer_still_overrides_choices(self) -> None: + """A user-supplied completer continues to drive completion in place of static choices.""" + + def completer(self, *args: Any) -> list[str]: + return ["x"] + + p = build_parser_from_function( + _make_func(Annotated[str, Option("--x", choices=["a", "b"], completer=completer)], default="a", name="x") + ) + action = next(a for a in p._actions if a.dest == "x") + assert action.choices is None + + +class TestStrConstValidation: + """A const on a str parameter must itself be a str (it is stored verbatim, not converted).""" + + def test_non_str_const_on_str_rejected(self) -> None: + _assert_build_error(Annotated[str, Option("--x", const=123)], default="a", match="does not match") + + def test_str_const_on_str_accepted(self) -> None: + p = build_parser_from_function(_make_func(Annotated[str, Option("--x", const="fast")], default="slow", name="x")) + assert p.parse_args(["--x"]).x == "fast" + + def test_const_on_untyped_param_not_validated(self) -> None: + """Any/object/unannotated are genuinely untyped, so a const of any type is accepted.""" + p = build_parser_from_function(_make_func(Annotated[Any, Option("--x", const=123)], default=None, name="x")) + assert p.parse_args(["--x"]).x == 123 + + +class TestArgumentDefaultSuppressGuard: + """``argument_default=argparse.SUPPRESS`` is rejected when it would drop an omittable argument.""" + + def test_suppress_with_optional_positional_rejected(self) -> None: + def do_t(self, x: int | None): ... + + with pytest.raises(TypeError, match="SUPPRESS"): + build_parser_from_function(do_t, argument_default=argparse.SUPPRESS) + + def test_suppress_safe_when_all_args_required_or_defaulted(self) -> None: + def do_t(self, a: int, b: str = "x"): ... + + # ``a`` is always supplied (required positional); ``b`` carries its own default -> safe. + parser = build_parser_from_function(do_t, argument_default=argparse.SUPPRESS) + assert parser is not None + + def test_suppress_safe_with_var_positional(self) -> None: + def do_t(self, *vals: int): ... + + # *args is substituted with () by the invocation path, so SUPPRESS cannot strand it. + parser = build_parser_from_function(do_t, argument_default=argparse.SUPPRESS) + assert parser is not None + + +class TestHelpKwargReserved: + """Raw ``help=`` is rejected so it cannot silently shadow the mapped ``help_text=``.""" + + def test_raw_help_rejected(self) -> None: + with pytest.raises(TypeError, match="help_text"): + Option("--x", help="raw") + + def test_help_text_still_works(self) -> None: + action = _action_for(Annotated[str, Option("--x", help_text="mapped")], default="a") + assert action.help == "mapped" + + +class TestEnumAcceptsNameAndValue: + """Enum parameters accept member values AND names (documented behavior, locked here).""" + + def test_enum_accepts_both_name_and_value(self) -> None: + class Color(enum.Enum): + RED = "r" + GREEN = "g" + + p = build_parser_from_function(_make_func(Color, name="c")) + assert p.parse_args(["r"]).c is Color.RED + assert p.parse_args(["RED"]).c is Color.RED From a4db7cee01b37583104431ec064f572ed8b27dde Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sat, 30 May 2026 15:34:25 -0400 Subject: [PATCH 24/25] Add with_annotated to top-level cmd2 namespace Also: - Fix minor edge case silent failure bug - Add description of this new feature to CHANGELOG under an "Experimental Features" section for the 4.0.0 release --- CHANGELOG.md | 6 ++++++ cmd2/__init__.py | 2 ++ cmd2/annotated.py | 2 +- docs/features/annotated.md | 7 +++---- docs/features/argument_processing.md | 10 +++++----- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7ad8dce8..d7733da94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -171,6 +171,12 @@ prompt is displayed. is enabled. - `alias` and `macro` subcommands for `create` and `delete` now output their non-essential success case output using `pfeedback` +- Experimental features + - New `@with_annotated` decorator, a type-hint-driven alternative to `@with_argparse` that + builds the parser automatically from a command's signature (positional/option inference, + enum/literal/path/collection handling, subcommands, groups, mutex). See the + [annotated_example.py](https://github.com/python-cmd2/cmd2/blob/main/examples/annotated_example.py) + example for demonstration of usage. ## 3.5.1 (April 24, 2026) diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 2d13650ae..bc0f0c890 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -11,6 +11,7 @@ rich_utils, string_utils, ) +from .annotated import with_annotated from .argparse_completer import set_default_ap_completer_type from .argparse_utils import ( Cmd2ArgumentParser, @@ -88,6 +89,7 @@ "CompletionItem", "Completions", # Decorators + "with_annotated", "with_argument_list", "with_argparser", "with_category", diff --git a/cmd2/annotated.py b/cmd2/annotated.py index fcd86843e..87221d86b 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -1925,7 +1925,7 @@ def _invoke_command_func( """Call *func* from parsed kwargs, unpacking ``*args`` positionally when present.""" if var_positional_name is None: return func(self_arg, **func_kwargs) - positional = [func_kwargs.pop(name) for name in leading_names if name in func_kwargs] + positional = [func_kwargs.pop(name) for name in leading_names] var_values = func_kwargs.pop(var_positional_name, None) or () return func(self_arg, *positional, *var_values, **func_kwargs) diff --git a/docs/features/annotated.md b/docs/features/annotated.md index d06489e83..7f7758e9d 100644 --- a/docs/features/annotated.md +++ b/docs/features/annotated.md @@ -11,10 +11,9 @@ For production code that needs stable behavior, use [@with_argparser](argument_processing.md#with_argparser-decorator) instead. -The [@with_annotated][cmd2.annotated.with_annotated] decorator builds an argparse parser -automatically from the decorated function's type annotations. No manual `add_argument()` calls are -required, and the command body receives typed keyword arguments directly instead of an -`argparse.Namespace`. +The [@with_annotated][cmd2.with_annotated] decorator builds an argparse parser automatically from +the decorated function's type annotations. No manual `add_argument()` calls are required, and the +command body receives typed keyword arguments directly instead of an `argparse.Namespace`. The two decorators are interchangeable -- here is the same command written both ways: diff --git a/docs/features/argument_processing.md b/docs/features/argument_processing.md index e52d60c01..d920de550 100644 --- a/docs/features/argument_processing.md +++ b/docs/features/argument_processing.md @@ -19,7 +19,7 @@ following for you: These features are provided by two decorators: - [@with_argparser][cmd2.with_argparser] -- build parsers manually with `add_argument()` calls -- [@with_annotated][cmd2.annotated.with_annotated] -- build parsers automatically from type hints +- [@with_annotated][cmd2.with_annotated] -- build parsers automatically from type hints See the [argparse_completion](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_completion.py) @@ -61,10 +61,10 @@ stores internally. A consequence is that parsers don't need to be unique across The `@with_annotated` decorator is **experimental** and its API may change in future releases. -The [@with_annotated][cmd2.annotated.with_annotated] decorator builds an argparse parser -automatically from the decorated function's type annotations -- no manual `add_argument()` calls -required. See [Annotated Argument Processing](annotated.md) for the full reference, including type -mapping, metadata classes, subcommands, and stability caveats. +The [@with_annotated][cmd2.with_annotated] decorator builds an argparse parser automatically from +the decorated function's type annotations -- no manual `add_argument()` calls required. See +[Annotated Argument Processing](annotated.md) for the full reference, including type mapping, +metadata classes, subcommands, and stability caveats. ## Argument Parsing From a6de465db38d53f8015228db40ce87a2b558885b Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Sat, 30 May 2026 23:33:56 +0100 Subject: [PATCH 25/25] chore: doc clean up and some more edge cases --- cmd2/annotated.py | 368 +++++++++++-------------------------- docs/features/annotated.md | 14 +- tests/test_annotated.py | 80 +++++++- 3 files changed, 192 insertions(+), 270 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 87221d86b..c82150844 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -219,19 +219,11 @@ def do_paint( class Cmd2ParserKwargs(TypedDict, total=False): - """Forwarded ctor kwargs for :class:`~cmd2.Cmd2ArgumentParser`. - - Single source of truth for the parser-construction kwargs accepted by - :func:`with_annotated` and :func:`build_parser_from_function` via PEP 692 - ``Unpack[Cmd2ParserKwargs]``. Adding a new ctor kwarg to - :class:`~cmd2.Cmd2ArgumentParser` only needs a matching field here -- the - decorator picks it up automatically and IDEs/type-checkers surface it on - the call site. - - All fields are optional (``total=False``); omit a key to use argparse's - default. ``suggest_on_error`` and ``color`` only take effect on - Python >= 3.14, where :class:`~cmd2.Cmd2ArgumentParser` forwards them to - the stdlib parent. + """Forwarded ctor kwargs for :class:`~cmd2.Cmd2ArgumentParser` (PEP 692 ``Unpack``). + + Single source of truth mirroring the parser's ``__init__``: add a field here to expose a new + ctor kwarg on the decorator's call site. All optional (``total=False``); ``suggest_on_error`` + and ``color`` only take effect on Python >= 3.14. """ prog: str | None @@ -275,15 +267,10 @@ class _BaseArgMetadata: "nargs": "nargs", } - #: ``add_argument`` kwargs that ``@with_annotated`` derives from the function signature - #: itself (or exposes under a different name), so the metadata classes refuse to accept them - #: as ``extra_kwargs``: setting them there would silently disagree with (or be overridden by) - #: the inferred value. ``type`` comes from the annotation, ``dest`` from the parameter name, - #: and ``action``/``required`` are the named ``Option`` arguments and have no meaning on a - #: positional ``Argument``. ``help`` is exposed as the ``help_text`` parameter; accepting a - #: raw ``help`` too would silently shadow it (``to_kwargs`` lets ``extra_kwargs`` win). - #: ``default`` is accepted as a named parameter (see :meth:`__init__`) and reconciled with - #: the signature default; it must not appear in ``extra_kwargs`` as well. + #: ``add_argument`` kwargs the decorator derives from the signature (or exposes under another + #: name), so the metadata classes refuse them as ``extra_kwargs``: ``type`` (from the annotation), + #: ``dest`` (parameter name), ``action``/``required`` (named ``Option`` args), ``help`` (use + #: ``help_text``). Per-key remediation hints are in :meth:`__init__`. _RESERVED_EXTRA_KWARGS: ClassVar[frozenset[str]] = frozenset( { "type", @@ -311,24 +298,12 @@ def __init__( ) -> None: """Initialise shared metadata fields. - ``const`` is the value stored when a flag is present without an argument; on an - :class:`Option` it selects ``store_const`` (scalar type) or ``append_const`` - (``list[T]``). It is meaningless for a positional :class:`Argument` (argparse - ignores it there) and is rejected when the parser is built. Left as :data:`_UNSET` - when not given, so an explicit ``const=None`` is distinct from "no const". - - ``default`` is the value the parser stores when the argument is absent; it is - equivalent to writing the same default in the function signature - (``Annotated[T, Option('--x', default=v)]`` is the same as - ``Annotated[T, Option('--x')] = v``). Specifying both the signature default and - the metadata default is a conflict and rejected; :data:`argparse.SUPPRESS` is also - rejected because it removes the kwarg the function expects. - - ``extra_kwargs`` forwards any ``add_argument`` parameter not named above -- in - particular custom parameters registered via - :func:`~cmd2.argparse_utils.register_argparse_argument_parameter`. They pass - straight through to ``add_argument`` (which validates them: an unknown keyword - raises ``TypeError`` when the parser is built), giving parity with a hand-built parser. + ``const`` is the value stored on a present flag with no argument (``Option`` only: + ``store_const``/``append_const``); ``_UNSET`` distinguishes "no const" from ``const=None``. + ``default`` mirrors the signature default (``Option(default=v)`` == ``... = v``); supplying + both, or ``argparse.SUPPRESS``, is rejected. ``extra_kwargs`` forwards any other + ``add_argument`` parameter (incl. those from + :func:`~cmd2.argparse_utils.register_argparse_argument_parameter`) straight through. """ reserved = self._RESERVED_EXTRA_KWARGS & extra_kwargs.keys() if reserved: @@ -367,37 +342,14 @@ def to_kwargs(self) -> dict[str, Any]: class Argument(_BaseArgMetadata): - """Metadata for a positional argument in an ``Annotated`` type hint. - - Example:: - - def do_greet(self, name: Annotated[str, Argument(help_text="Person to greet")]): - ... - """ + """Metadata for a positional argument in an ``Annotated`` type hint.""" class Option(_BaseArgMetadata): """Metadata for an optional/flag argument in an ``Annotated`` type hint. - Positional ``*names`` are the flag strings (e.g. ``"--color"``, ``"-c"``). - When omitted, the decorator auto-generates ``--param-name`` (underscores - in the parameter name are converted to dashes). - - Pass ``const=`` to store a fixed value when the flag is present: on a scalar parameter this - selects ``store_const`` (present -> ``const``, absent -> the default), on a ``list[T]`` it - selects ``append_const`` (each flag appends ``const``). A scalar ``const=`` paired with an - explicit ``nargs`` (e.g. ``nargs='?'``) instead keeps the ``store`` action for argparse's - optional-value idiom (bare flag -> ``const``, ``flag VALUE`` -> the value). ``action=`` may - still be given explicitly; otherwise it is inferred from the type. - - Example:: - - def do_paint( - self, - color: Annotated[str, Option("--color", "-c", help_text="Color")] = "blue", - verbose: Annotated[int, Option("-v", const=2)] = 0, - ): - ... + Positional ``*names`` are the flag strings (e.g. ``"--color"``, ``"-c"``); when omitted + the decorator generates ``--param-name`` (underscores become dashes). """ def __init__( @@ -409,11 +361,10 @@ def __init__( ) -> None: """Initialise Option metadata. - ``action`` may be a string (one of the supported actions: ``store_true``, ``store_false``, - ``count``, ``append``, ``extend``, ``store_const``, ``append_const``) or a custom - :class:`argparse.Action` subclass. A custom class is passed straight through to - ``add_argument`` and the user's class owns the storage behaviour; the type-inferred - action, container factory, and the action-specific constraint checks are skipped. + ``action`` is a supported string action (``store_true``/``store_false``/``count``/ + ``append``/``extend``/``store_const``/``append_const``) or a custom + :class:`argparse.Action` subclass (passed through; it owns storage, so the inferred + action and the action-specific constraints are skipped). """ super().__init__(**kwargs) self.names = names @@ -431,19 +382,7 @@ def to_kwargs(self) -> dict[str, Any]: class Group: - """Argument-group definition for ``with_annotated(groups=...)``. - - Wrap parameter names with an optional ``title`` and ``description`` so the - group renders its own section in ``--help`` output. Every ``groups`` and - ``mutually_exclusive_groups`` entry must be a ``Group`` instance. - - Example:: - - @with_annotated( - groups=(Group("host", "port", title="connection", description="where to connect"),), - ) - def do_connect(self, host: str, port: int = 22): ... - """ + """Argument-group definition for ``with_annotated(groups=...)`` / ``mutually_exclusive_groups=...``.""" def __init__( self, @@ -455,12 +394,10 @@ def __init__( """Initialise an argument group definition. :param members: parameter names to place in the group (at least one) - :param title: optional group title shown as a section header in help - :param description: optional group description shown under the title - :param required: only meaningful for ``mutually_exclusive_groups``; when - ``True`` argparse requires exactly one member to be supplied. - Setting this on a group passed to ``groups=`` raises ``ValueError`` - (argparse's ``add_argument_group`` has no ``required`` flag). + :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") @@ -485,12 +422,7 @@ def _validate_members(self, *, all_param_names: set[str], group_type: str) -> No @dataclass class _TypeResult: - """How a declared type maps onto argparse settings. - - Produced by ``_TYPE_TABLE`` entries and consumed by :meth:`_ArgparseArgument._apply_type`. - ``converter``/``choices``/``action``/``completer`` flow to argparse; - ``is_collection``/``fixed_arity`` are scratch the nargs table reads. - """ + """How a declared type maps onto argparse settings.""" converter: Callable[[str], Any] | None = None choices: Iterable[Any] | None = None @@ -768,7 +700,7 @@ def _resolve_base_type(tp: Any, *, is_positional: bool = False) -> _TypeResult: if resolver is not None: return resolver(tp, args, is_positional=is_positional) - if tp in _PASSTHROUGH_TYPES or get_origin(tp) is not None: + if tp in _PASSTHROUGH_TYPES: return _TypeResult() raise TypeError( f"Unsupported parameter type {_type_name(tp)!r} for @with_annotated: there is no converter " @@ -899,18 +831,7 @@ def _first_match(rules: list[_Rule[_S, _R]], subject: _S) -> _R: class _ArgparseArgument: - """Builder whose output fields mirror ``parser.add_argument(...)``'s schema. - - Constructed by :func:`_resolve_parameters` from the signature-derived inputs and populated by - :meth:`_apply`, which fills each output slot from its decision table (role / nargs / default / - required) or imperative phase (targets / type / metadata / action). Inputs and scratch live - alongside the output slots, but only the named slots are emitted (see :meth:`_emit`). - - Building does *not* validate: validation is deferred to a final pass in :func:`_resolve_parameters`, - which links cross-argument facts (e.g. :attr:`has_following_positional`) onto each argument and then - runs the single :data:`_CONSTRAINTS` table. New behavior is added as a table row, not an ``if`` in - the phases. :meth:`add_to` emits the ``add_argument`` call. - """ + """Builder whose output fields mirror ``parser.add_argument(...)``'s schema.""" def __init__( self, @@ -1013,6 +934,16 @@ def _meta_nargs(self) -> _NargsValue | None: """An explicit ``Argument/Option(nargs=)``, else ``None``.""" return self.metadata.nargs if self.metadata is not None else None + @property + def _meta_nargs_yields_optional_single(self) -> bool: + """Whether an explicit ``nargs`` yields at most one value that argparse won't wrap in a list. + + ``'?'`` and the ranged ``(0, 1)`` both make argparse store a bare scalar (or ``None`` when + absent) instead of a list -- so they neither cast into a declared collection nor give a + non-Optional scalar a value when omitted. + """ + return self._meta_nargs == "?" or (isinstance(self._meta_nargs, tuple) and tuple(self._meta_nargs) == (0, 1)) + @property def _meta_choices(self) -> Iterable[Any] | None: """Explicit ``Argument/Option(choices=)``, else ``None``.""" @@ -1020,24 +951,14 @@ def _meta_choices(self) -> Iterable[Any] | None: @property def _has_user_completion(self) -> bool: - """Whether the user supplied a ``choices_provider`` / ``completer`` on the metadata. - - A user-supplied completion source drives completion in place of any static ``choices``. - Distinct from a completer the *type* inferred (e.g. ``Path``'s ``path_complete``), which - yields to an explicit ``choices=`` instead of overriding it. - """ + """Whether the user supplied a ``choices_provider`` / ``completer`` on the metadata.""" if self.metadata is None: return False return self.metadata.choices_provider is not None or self.metadata.completer is not None @property def _meta_action(self) -> str | type[argparse.Action] | None: - """An explicit ``Option(action=)`` value, else ``None`` (only ``Option`` carries one). - - May be a string (one of the supported actions) or a custom :class:`argparse.Action` - subclass; the constraint and policy rules below key on the string form, so a class - action skips them and is passed straight through to ``add_argument``. - """ + """An explicit ``Option(action=)`` value, else ``None`` (only ``Option`` carries one).""" return self.metadata.action if isinstance(self.metadata, Option) else None @property @@ -1110,23 +1031,11 @@ def _policy(self) -> _ActionPolicy | None: @property def _is_inferred_bool_flag(self) -> bool: - """Whether this is a bool option using the inferred ``BooleanOptionalAction`` (no explicit action). - - Like the explicit flag actions, it supplies its own value when absent (``False``, or ``None`` for - ``bool | None``), so it is neither ``required`` nor needs a user default. Reads the resolved - ``action`` slot, so it is only meaningful after :meth:`_apply_type` / :data:`_ACTION_RULES` run. - """ + """Whether this is a bool option using the inferred ``BooleanOptionalAction`` (no explicit action).""" return self.action is argparse.BooleanOptionalAction def _apply(self) -> None: - """Build this argument by deriving each output slot (no validation here). - - :meth:`_apply_type` seeds the type-inferred baselines and :meth:`_apply_metadata_extras` merges - the user's display kwargs into ``extras``; then every output slot is filled from its value table - (role / action / choices / nargs / default / required). The action *policy* is applied last, as - an override, because ``action=`` is cross-cutting and only makes sense once the rest is known. - Validity is checked later by :func:`_resolve_parameters` (via :meth:`_check_constraints`). - """ + """Build this argument by deriving each output slot.""" self.is_positional = _first_match(_ROLE_RULES, self) self._apply_targets() self._apply_type() @@ -1205,11 +1114,7 @@ def _apply_type(self) -> None: self.fixed_arity = result.fixed_arity def _apply_metadata_extras(self) -> None: - """Pass the user's display/completion metadata straight through to ``extras``. - - The override facts (choices/action/required/nargs) are read on demand from ``metadata`` via - properties; only these passthrough kwargs need merging into ``extras``. - """ + """Pass the user's display/completion metadata straight through to ``extras``.""" if self.metadata is None: return kwargs = self.metadata.to_kwargs() @@ -1218,12 +1123,9 @@ def _apply_metadata_extras(self) -> None: def _apply_action(self) -> None: """Apply an explicit ``Option(action=...)`` as the final override pass. - Runs after type/nargs/default/required are resolved and only sets slots; the action's validity - (type match, unknown action, ...) is enforced by the constraints. - - A custom :class:`argparse.Action` subclass has no :data:`_ACTION_TABLE` policy: it owns its - own storage so the collection casting wrapper is dropped, but the type-inferred converter, - default, and required-ness are kept (the user can override them via :data:`extra_kwargs`). + Sets slots only; validity (type match, unknown action) is enforced by the constraints. A + custom :class:`argparse.Action` has no policy -- it owns storage, so the casting wrapper is + dropped while the inferred converter/default/required are kept. """ if self._meta_action_is_class: # The user's class owns storage; drop the casting wrapper's container_factory kwarg. @@ -1233,10 +1135,8 @@ def _apply_action(self) -> None: if policy is None: return if policy.drop_converter: - # The action stores a fixed value and takes no command-line argument, so the parsed - # string is never converted, validated against choices, or tab-completed -- drop the - # converter, choices, and any value-completion metadata (e.g. the completer inferred - # for a Path type), which argparse rejects on a zero-argument action. + # The action takes no command-line value, so drop the converter, choices, and any + # value-completion metadata (argparse rejects them on a zero-argument action). self.type = None self.choices = None self.extras.pop("completer", None) @@ -1345,13 +1245,9 @@ def add_to(self, target: _ArgumentTarget) -> None: #: (collection-casting / ``BooleanOptionalAction`` / none). The action *policy* is applied later. _ACTION_RULES: list[_Rule[_ArgparseArgument, str | type[argparse.Action] | None]] = [ (lambda a: a._meta_action is not None, lambda a: a._meta_action), # explicit Option(action=) - # A const with no explicit action selects the const action by type shape: - # list[T] -> append_const (accumulate), any scalar -> store_const (single value). - # Exception: a scalar that *also* sets an explicit nargs (e.g. nargs='?') wants argparse's native - # optional-value-with-const semantics (absent -> default, bare flag -> const, flag VALUE -> converted - # VALUE), so it keeps the type-inferred ``store`` action instead of the value-less store_const -- the - # explicit nargs and the type converter would otherwise be silently dropped. list[T] still infers - # append_const regardless of nargs (append_const takes no value, so nargs is meaningless there). + # const with no explicit action infers the const action by shape: list[T] -> append_const, + # scalar -> store_const. Exception: a scalar that also sets nargs (e.g. '?') keeps the inferred + # ``store`` for argparse's optional-value idiom (bare flag -> const, flag VALUE -> value). ( lambda a: a._has_const and (a.is_collection or a._meta_nargs is None), lambda a: "append_const" if a.is_collection else "store_const", @@ -1428,11 +1324,8 @@ def _const_element_type(a: _ArgparseArgument) -> Any: def _const_mismatches_type(a: _ArgparseArgument) -> bool: """Whether a supplied ``const`` is incompatible with the declared (element) type. - Best-effort, mirroring the decorator's "parsed value matches the annotation" guarantee: - ``Literal``/``Enum`` are checked for membership and the concrete scalars by ``isinstance``; - open types (``str``/``Any``/``object``/the bool flag) and unresolved types are not validated. - A class :class:`argparse.Action` owns its storage semantics, so any ``const`` paired with one - is the user's responsibility and is not type-checked here. + Best-effort: ``Literal``/``Enum`` checked for membership, concrete scalars by ``isinstance``; + open types (``str``/``Any``/``object``/bool flag), unresolved types, and class actions are not checked. """ if not a._has_const or a._meta_action_is_class: return False @@ -1534,10 +1427,8 @@ def _const_mismatches_type(a: _ArgparseArgument) -> bool: ), ), ( - # const only makes sense with the const actions; pairing it with another explicit action is contradictory. - # Restricted to *known* actions so an unsupported action (e.g. 'store') falls through to the - # "not supported" row below -- that is the more fundamental problem to report first. - # A class action is exempt -- the user's action owns const semantics. + # const only goes with the const actions. Limited to *known* actions so an unsupported one + # (e.g. 'store') falls through to the "not supported" row. Class actions own const, so exempt. lambda a: ( isinstance(a._meta_action, str) and a._has_const @@ -1580,10 +1471,8 @@ def _const_mismatches_type(a: _ArgparseArgument) -> bool: ), ), ( - # action='store' WITH const is ambiguous, not merely redundant: plain 'store' ignores const - # unless paired with nargs='?', so the intent cannot be inferred -- a value-less const flag - # (store_const) or an optional value (nargs='?'+const)? Reported before the const-type-mismatch - # row below so the ambiguity wins even when the const's type also happens to mismatch. + # action='store' + const is ambiguous: 'store' ignores const unless paired with nargs='?'. + # Placed before the const-type-mismatch row so the ambiguity wins even on a mistyped const. lambda a: a._meta_action == "store" and a._has_const, lambda a: TypeError( f"Option(action='store', const=...) on '{a.name}' is ambiguous: 'store' ignores const unless " @@ -1618,11 +1507,9 @@ def _const_mismatches_type(a: _ArgparseArgument) -> bool: ), ), ( - # A user-supplied completer / choices_provider on a value-less action (store_true / store_false / - # count / store_const / append_const) has nothing to complete: the action consumes no command-line - # value. Raw cmd2 raises here, so fail loud rather than silently dropping the user's request. A - # type-*inferred* completer (e.g. Path's) is still dropped silently by _apply_action -- only an - # explicit one is rejected. A class action owns its storage (no _policy), so it is exempt. + # A user completer/choices_provider on a value-less action (store_true/false, count, + # store_const, append_const) has nothing to complete. Fail loud (raw cmd2 raises too) rather + # than drop it silently. Inferred completers (e.g. Path's) are still dropped; class actions exempt. lambda a: a._policy is not None and a._policy.drop_converter and a._has_user_completion, lambda a: TypeError( f"completer=/choices_provider= on '{a.name}' cannot be used with action={a._effective_action!r}, " @@ -1672,6 +1559,17 @@ def _const_mismatches_type(a: _ArgparseArgument) -> bool: f"Use list[T], tuple[T, ...], or set[T] (optionally with | None) to match." ), ), + ( + # An explicit '?' / (0, 1) on a collection yields a single value (or None), which the + # collection-casting action cannot wrap into the declared list/set/tuple. + lambda a: a.is_collection and a._meta_nargs_yields_optional_single, + lambda a: TypeError( + f"parameter '{a.name}' in {a.func_qualname} sets nargs={a._meta_nargs!r} on the collection " + f"type '{_type_name(a.inner_type)}', but that yields at most a single value, which argparse " + f"does not wrap in a list -- so it cannot be cast to the declared collection. Use nargs='*' or " + f"'+' (optionally with a default or '| None'), or drop the explicit nargs." + ), + ), ( lambda a: a.is_positional and a.omittable and isinstance(a.nargs, int), lambda a: TypeError( @@ -1765,7 +1663,7 @@ def _const_mismatches_type(a: _ArgparseArgument) -> bool: # supplied, so the others arrive as None (violating its non-Optional type), and argparse forbids # it. This is an argument-typing rule (required-ness comes from the annotation), so it lives here # rather than with the group graph construction. - lambda a: a.required and bool(a.mutex_group_indices), + lambda a: bool(a.mutex_group_indices) and (a.required or (a.is_positional and not a.omittable)), lambda a: ValueError( f"parameter {a.name!r} in mutually exclusive group {a.mutex_group_indices[0]} is required (no default " f"and not Optional), but mutually exclusive group members must be optional because " @@ -1820,19 +1718,13 @@ def _resolve_parameters( ) -> list[_ArgparseArgument]: """Resolve a function signature into a list of argparse-argument builders. - ``base_command`` marks each argument's context so the base-command rows in :data:`_CONSTRAINTS` - fire (a base command's parameters become subcommand-level options, so positionals are rejected), - and drives the function-level ``cmd2_handler`` check below (a plain ``if``, not a table row, - because its subject is the whole function rather than a single argument). - ``groups``/``mutually_exclusive_groups`` are linked onto each argument as membership facts so the - cross-config rows in :data:`_CONSTRAINTS` (double-assignment, required-member) fire from the one - validity pass. + ``base_command`` marks each argument's context for the base-command :data:`_CONSTRAINTS` rows and + drives the function-level ``cmd2_handler`` check below. ``groups``/``mutually_exclusive_groups`` + are linked onto each argument as membership facts for the cross-config constraint rows. """ sig = inspect.signature(func) - # Function-level check, before any argument is built: base_command dispatches to subcommands - # through its cmd2_handler parameter, so without one there is nothing to dispatch to. Checked - # here rather than in the per-argument _CONSTRAINTS loop so it also fires when the function - # declares zero parameters, and wins over any per-argument rule on the same function. + # Function-level check (not a per-argument _CONSTRAINTS row): a base command dispatches through + # cmd2_handler, so it must exist. Here so it also fires when the function has zero parameters. if base_command and "cmd2_handler" not in sig.parameters: raise TypeError(f"with_annotated(base_command=True) requires a 'cmd2_handler' parameter in {func.__qualname__}") try: @@ -1926,7 +1818,7 @@ def _invoke_command_func( if var_positional_name is None: return func(self_arg, **func_kwargs) positional = [func_kwargs.pop(name) for name in leading_names] - var_values = func_kwargs.pop(var_positional_name, None) or () + var_values = func_kwargs.pop(var_positional_name) return func(self_arg, *positional, *var_values, **func_kwargs) @@ -2049,31 +1941,15 @@ def build_parser_from_function( ) -> Cmd2ArgumentParser: """Inspect a function's signature and build a ``Cmd2ArgumentParser``. - Parameters without defaults become positional arguments. - Parameters with defaults become ``--option`` flags. - ``Annotated[T, Argument(...)]`` or ``Annotated[T, Option(...)]`` - overrides the default behavior. - - Any kwarg accepted by :class:`~cmd2.Cmd2ArgumentParser`'s constructor - (``description``, ``epilog``, ``prog``, ``usage``, ``parents``, - ``argument_default``, ``prefix_chars``, ``fromfile_prefix_chars``, - ``conflict_handler``, ``add_help``, ``allow_abbrev``, ``exit_on_error``, - ``formatter_class``, ``ap_completer_type``, plus Python >= 3.14's - ``suggest_on_error`` and ``color``) is forwarded via ``**parser_kwargs``; - see :class:`Cmd2ParserKwargs` for the canonical list and IDE - autocomplete. - - When ``description`` is omitted from ``parser_kwargs``, the first paragraph - of ``func.__doc__`` (up to the first blank line) is used. + The lower-level entry point behind :func:`with_annotated`. ``parser_kwargs`` is forwarded to + the parser ctor (see :class:`Cmd2ParserKwargs`); when ``description`` is omitted, the first + paragraph of ``func.__doc__`` is used. :param func: the command function to inspect :param skip_params: parameter names to exclude from the parser - :param groups: :class:`Group` instances assigning parameter names to argument - groups (for help display) + :param groups: :class:`Group` instances assigning parameters to argument groups :param mutually_exclusive_groups: :class:`Group` instances of mutually exclusive parameters - :param parser_class: custom parser class (defaults to the configured default). - The chosen class must accept whatever subset of - :class:`Cmd2ParserKwargs` you pass. + :param parser_class: custom parser class (defaults to the configured default) :param parser_kwargs: forwarded :class:`Cmd2ParserKwargs` :return: a fully configured ``Cmd2ArgumentParser`` """ @@ -2209,16 +2085,12 @@ def _build_subcommand_handler( base_command: bool = False, options: _ParserBuildOptions, ) -> tuple[Callable[..., Any], str, Callable[[], Cmd2ArgumentParser]]: - """Build a subcommand handler wrapper and its parser from type annotations. - - Validates the naming convention, builds a parser from annotations, and - returns a wrapper that unpacks ``argparse.Namespace`` into typed kwargs - before calling the original function. + """Build a subcommand's parser and a handler that unpacks the Namespace into typed kwargs. :param func: the subcommand handler function :param subcommand_to: parent command name (space-delimited for nesting) :param base_command: if True, the parser also gets ``add_subparsers()`` - :param options: shared parser/subcommand configuration (see :class:`_ParserBuildOptions`) + :param options: shared parser/subcommand configuration :return: ``(handler, subcommand_name, parser_builder)`` """ subcmd_name = _derive_subcommand_name(func, subcommand_to) @@ -2292,54 +2164,30 @@ def with_annotated( """Decorate a ``do_*`` method to build its argparse parser from type annotations. :param func: the command function (when used without parentheses) - :param ns_provider: optional callable returning a prepopulated argparse.Namespace. - Not supported with ``subcommand_to``. - :param preserve_quotes: if True, preserve quotes in arguments. - Not supported with ``subcommand_to``. - :param with_unknown_args: if True, capture unknown args (passed as extra kwarg ``_unknown``). - Not supported with ``subcommand_to``. - :param base_command: if True, this command has subcommands (adds ``add_subparsers()``). - Requires a ``cmd2_handler`` parameter and no positional arguments. - :param subcommand_to: parent command name (e.g. ``'team'`` or ``'team member'``). - Function must be named ``{parent_underscored}_{subcommand}``. - :param help: help text for the subcommand (only valid with ``subcommand_to``) - :param aliases: alternative names for the subcommand (only valid with ``subcommand_to``) - :param deprecated: mark the subcommand as deprecated in ``--help`` (only valid with ``subcommand_to``) - :param groups: :class:`Group` instances assigning parameter names to argument - groups (pass ``title``/``description`` for a titled section) + :param ns_provider: callable returning a prepopulated Namespace (not with ``subcommand_to``) + :param preserve_quotes: preserve quotes in arguments (not with ``subcommand_to``) + :param with_unknown_args: capture unknown args as the ``_unknown`` kwarg (not with ``subcommand_to``) + :param base_command: add ``add_subparsers()``; requires a ``cmd2_handler`` param and no positionals + :param subcommand_to: parent command name; function must be named ``{parent_underscored}_{subcommand}`` + :param help: subcommand help text (only with ``subcommand_to``) + :param aliases: alternative subcommand names (only with ``subcommand_to``) + :param deprecated: mark the subcommand deprecated in ``--help`` (only with ``subcommand_to``) + :param groups: :class:`Group` instances assigning parameters to titled argument groups :param mutually_exclusive_groups: :class:`Group` instances of mutually exclusive parameters :param parser_class: custom parser class (defaults to the configured default) - :param subcommand_required: whether a subcommand must be supplied (only with ``base_command``) - :param subcommand_metavar: metavar shown for the subcommands group (only with ``base_command``) - :param subcommand_title: title for the subcommands ``--help`` section (only with ``base_command``) - :param subcommand_description: description for the subcommands ``--help`` section (only with ``base_command``) - :param parser_kwargs: any kwarg accepted by :class:`~cmd2.Cmd2ArgumentParser`'s - constructor (see :class:`Cmd2ParserKwargs` for the full list and - per-field types). IDEs/type-checkers surface these on the call - site via PEP 692 ``Unpack``. Notable behaviors layered on top of - the raw passthrough: - - - ``description`` -- when omitted, the first paragraph of the - function's docstring (up to the first blank line) is used; - pass an explicit value to override that. - - ``prog`` -- rejected when ``subcommand_to`` is set, because - cmd2's subcommand machinery rewrites ``prog`` from the parent - command hierarchy and any value here would be silently - overwritten. - - Example:: - - class MyApp(cmd2.Cmd): - @with_annotated - def do_greet(self, name: str, count: int = 1): ... - - @with_annotated(base_command=True) - def do_team(self, *, cmd2_handler): ... - - @with_annotated(subcommand_to='team', help='create a team') - def team_create(self, name: str): ... - + :param subcommand_required: whether a subcommand must be supplied (``base_command`` only) + :param subcommand_metavar: metavar for the subcommands group (``base_command`` only) + :param subcommand_title: title for the subcommands ``--help`` section (``base_command`` only) + :param subcommand_description: description for that section (``base_command`` only) + :param parser_kwargs: any :class:`~cmd2.Cmd2ArgumentParser` ctor kwarg (see :class:`Cmd2ParserKwargs`). + ``description`` defaults to the docstring's first paragraph when omitted; + ``prog`` is rejected with ``subcommand_to`` (cmd2 rewrites it from the parent). """ + if aliases is None: + raise TypeError( + "aliases must be a sequence of subcommand-name strings (e.g. ('co', 'ci')), not None; " + "omit it or pass an empty tuple for no aliases." + ) if (help is not None or aliases or deprecated) and subcommand_to is None: raise TypeError("'help', 'aliases', and 'deprecated' are only valid with subcommand_to") if subcommand_to is not None: diff --git a/docs/features/annotated.md b/docs/features/annotated.md index 7f7758e9d..04c7e8472 100644 --- a/docs/features/annotated.md +++ b/docs/features/annotated.md @@ -404,12 +404,18 @@ def manage_project_add(self, name: str): [cmd2.annotated.build_parser_from_function][cmd2.annotated.build_parser_from_function] builds the parser directly from a function without registering a command. It accepts the same `groups`, `mutually_exclusive_groups`, `parser_class`, and forwarded -[`Unpack[Cmd2ParserKwargs]`][cmd2.annotated.Cmd2ParserKwargs] as `@with_annotated`. +[`Unpack[Cmd2ParserKwargs]`][cmd2.annotated.Cmd2ParserKwargs] as `@with_annotated`. Like the +decorator, it skips the first parameter as the method receiver (`self`/`cls`). ```py -@with_annotated(preserve_quotes=True) -def do_raw(self, text: str): - self.poutput(f"raw: {text}") +from cmd2.annotated import build_parser_from_function + +def greet(self, name: str, count: int = 1): + """Greet someone.""" + +parser = build_parser_from_function(greet) +namespace = parser.parse_args(["Alice", "--count", "3"]) +# namespace.name == "Alice", namespace.count == 3 ``` ## Automatic completion from types diff --git a/tests/test_annotated.py b/tests/test_annotated.py index c2d787de7..449d0e1ef 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -34,6 +34,7 @@ _ArgparseArgument, _build_argument_group_targets, _CollectionCastingAction, + _invoke_command_func, _make_enum_type, _make_literal_type, _normalize_annotation, @@ -973,6 +974,18 @@ def func(self, local: Annotated[str, Option("--local")], remote: str | None = No with pytest.raises(ValueError, match=r"mutually exclusive group members must be optional"): build_parser_from_function(func, mutually_exclusive_groups=(Group("local", "remote"),)) + def test_required_positional_member_in_mutex_group_raises(self) -> None: + """A required *positional* mutex member is rejected with the same clear, type-safety message. + + Positionals never carry argparse's ``required=`` flag, so the check keys on the parameter + being non-omittable, not on ``required`` (which is always False for a positional). + """ + + def func(self, a: int, b: Annotated[int, Option("--b")] = 0) -> None: ... + + with pytest.raises(ValueError, match=r"mutually exclusive group members must be optional"): + build_parser_from_function(func, mutually_exclusive_groups=(Group("a", "b"),)) + def test_optional_members_in_mutex_group_build(self) -> None: """Mutex members that are Optional or have defaults build fine (the regression guard).""" @@ -1362,10 +1375,23 @@ def test_nested_collection_raises(self, annotation) -> None: pytest.param(dict[str, int], id="dict"), ], ) - def test_unsupported_collection_no_nargs(self, annotation) -> None: - _flags, kwargs = _resolve_annotation(annotation)._emit() - assert "nargs" not in kwargs - assert "action" not in kwargs + def test_unsupported_subscripted_generic_raises(self, annotation) -> None: + """An unsupported subscripted generic must raise, not silently arrive as a plain str.""" + with pytest.raises(TypeError, match=r"Unsupported parameter type"): + _resolve_annotation(annotation) + + @pytest.mark.parametrize( + "annotation", + [ + pytest.param(Annotated[list[int] | None, Argument(nargs="?")], id="list_q"), + pytest.param(Annotated[list[int] | None, Argument(nargs=(0, 1))], id="list_0_1"), + pytest.param(Annotated[set[str] | None, Argument(nargs="?")], id="set_q"), + ], + ) + def test_optional_single_nargs_on_collection_raises(self, annotation) -> None: + """nargs='?'/(0,1) on a collection yields a single value the casting action cannot wrap.""" + with pytest.raises(TypeError, match=r"single value"): + _resolve_annotation(annotation) @pytest.mark.parametrize( "annotation", @@ -2057,6 +2083,36 @@ def do_cat(self, *files: str, upper: bool = False) -> None: app.do_cat("a b --upper") assert app.stdout.getvalue().splitlines()[-1] == "A|B" + def test_invoke_missing_leading_positional_raises(self) -> None: + """A leading positional missing from func_kwargs fails loud instead of shifting *args. + + ``*positional`` is unpacked by index, so silently skipping a missing leading + name would slide ``*var_values`` into the wrong parameter. Fail with KeyError. + """ + + def fn(self, first: str, *rest: str) -> None: ... + + with pytest.raises(KeyError, match="first"): + _invoke_command_func( + fn, + None, + {"rest": ("x", "y")}, + leading_names=["first"], + var_positional_name="rest", + ) + + def test_invoke_missing_var_positional_raises(self) -> None: + """A *args name missing from func_kwargs fails loud instead of defaulting to empty. + + argparse always populates the ``*args`` attribute (an empty list when nothing is + given), so a missing key signals a bug and must not be silently swallowed. + """ + + def fn(self, *rest: str) -> None: ... + + with pytest.raises(KeyError, match="rest"): + _invoke_command_func(fn, None, {}, leading_names=[], var_positional_name="rest") + def test_bare_call_decorator(self) -> None: """@with_annotated() with empty parens works same as @with_annotated.""" @@ -2180,6 +2236,11 @@ def test_subcommand_help(self, subcmd_app) -> None: class TestSubcommandValidation: + def test_subcommand_aliases_none_raises(self) -> None: + """aliases=None is off-spec (it must be a Sequence[str]); reject it with a clear message.""" + with pytest.raises(TypeError, match=r"aliases must be a sequence"): + with_annotated(subcommand_to="team", aliases=None) + def test_base_command_positional_str_raises(self) -> None: """Positional str param conflicts with subcommand name.""" with pytest.raises(TypeError, match="positional"): @@ -2938,12 +2999,19 @@ def test_ranged_nargs_on_list_builds(self) -> None: assert parser.parse_args(["a", "b", "c"]).value == ["a", "b", "c"] def test_ranged_nargs_zero_one_on_scalar_builds(self) -> None: - # cmd2 collapses the (0, 1) range to OPTIONAL ('?'), which yields a single - # optional value -- not a list -- so it is allowed on a scalar, like nargs='?'. + # cmd2 collapses the (0, 1) range to OPTIONAL ('?'), which yields a single optional value + # (not a list), so it is allowed on a scalar, like nargs='?'. Absent -> None. parser = build_parser_from_function(_make_func(Annotated[str, Argument(nargs=(0, 1))])) assert parser.parse_args(["x"]).value == "x" assert parser.parse_args([]).value is None + def test_optional_scalar_positional_nargs_question_builds(self) -> None: + # An explicit nargs='?' on a scalar positional is the standard argparse optional-positional; + # it is allowed (absent -> None). Developers wanting type-safe absence use 'T | None'. + parser = build_parser_from_function(_make_func(Annotated[int, Argument(nargs="?")])) + assert parser.parse_args(["5"]).value == 5 + assert parser.parse_args([]).value is None + # --------------------------------------------------------------------------- # Subcommands group configuration (required / metavar / title / description)