From 7e41c10f179e1419df3c0a5972d33784a2c997bc Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Wed, 6 May 2026 22:11:13 +0100 Subject: [PATCH 01/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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`