diff --git a/CHANGELOG.md b/CHANGELOG.md index a7ad8dce8..d7733da94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -171,6 +171,12 @@ prompt is displayed. is enabled. - `alias` and `macro` subcommands for `create` and `delete` now output their non-essential success case output using `pfeedback` +- Experimental features + - New `@with_annotated` decorator, a type-hint-driven alternative to `@with_argparse` that + builds the parser automatically from a command's signature (positional/option inference, + enum/literal/path/collection handling, subcommands, groups, mutex). See the + [annotated_example.py](https://github.com/python-cmd2/cmd2/blob/main/examples/annotated_example.py) + example for demonstration of usage. ## 3.5.1 (April 24, 2026) diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 2d13650ae..bc0f0c890 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -11,6 +11,7 @@ rich_utils, string_utils, ) +from .annotated import with_annotated from .argparse_completer import set_default_ap_completer_type from .argparse_utils import ( Cmd2ArgumentParser, @@ -88,6 +89,7 @@ "CompletionItem", "Completions", # Decorators + "with_annotated", "with_argument_list", "with_argparser", "with_category", diff --git a/cmd2/annotated.py b/cmd2/annotated.py new file mode 100644 index 000000000..c82150844 --- /dev/null +++ b/cmd2/annotated.py @@ -0,0 +1,2319 @@ +"""Build argparse parsers from type-annotated function signatures. + +.. warning:: Experimental + + This module is experimental and its behavior may change in future releases. + +The :func:`with_annotated` decorator inspects a command function's type hints and +default values to build a ``Cmd2ArgumentParser``. :class:`Argument` and +:class:`Option` metadata classes give finer per-parameter control via +``typing.Annotated``. + +Parameters without defaults become positional arguments; parameters with defaults +become ``--option`` flags; keyword-only parameters (after ``*``) are always options. +A ``bool`` option is a flag, not a value: when absent it means ``False`` (or ``None`` +for ``bool | None``), so it defaults to that and is never ``required``. A ``*args`` +parameter becomes a variadic positional accepting zero or more values (``nargs='*'``), +collected into a tuple. Underscores in a parameter name become dashes in the generated +flag (``dry_run`` -> ``--dry-run``); pass an explicit ``Option("--my_flag")`` to opt out. +Positional-only parameters (before ``/``) and ``**kwargs`` raise ``TypeError``. The parameter +names ``dest`` and ``subcommand`` are reserved; ``cmd2_statement`` receives the parsed +``Statement`` and (with ``base_command=True``) ``cmd2_handler`` receives the subcommand handler:: + + class MyApp(cmd2.Cmd): + @cmd2.with_annotated + 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=`` +- ``bool`` option -- ``--flag / --no-flag`` via ``BooleanOptionalAction``; defaults to + ``False`` (or ``None`` for ``bool | None``) when omitted, so it is never ``required`` +- positional ``bool`` -- parsed from ``true/false``, ``yes/no``, ``on/off``, ``1/0`` +- ``pathlib.Path`` -- sets ``type=Path`` +- ``enum.Enum`` subclass -- ``type=converter``, ``choices`` from member values +- ``decimal.Decimal`` -- sets ``type=Decimal`` +- ``Literal[...]`` -- ``type=converter`` and ``choices`` from the literal values +- ``list[T]`` / ``set[T]`` / ``tuple[T, ...]`` -- ``nargs='+'`` (or ``'*'`` with a default or ``| None``) +- ``tuple[T, T]`` (fixed arity, same type) -- ``nargs=N`` with ``type=T`` +- ``*args: T`` -- variadic positional (``nargs='*'``); ``T`` is each value's type, not the + collected tuple. ``Annotated[T, Argument(...)]`` metadata is honored +- ``T | None`` (no default) -- positional with ``nargs='?'`` (0-or-1 tokens) +- ``T | None = None`` -- ``--flag`` option with ``default=None`` + +A value option with no default is made ``required`` (omitting it would pass ``None``, +violating a non-Optional hint); annotate it ``T | None`` or give it a default to make it +omittable. + +Explicit ``Option(action=...)`` is type-checked so the parsed result matches the +declared type: + +- ``store_true`` / ``store_false`` -- require a ``bool`` parameter (``type=`` is dropped; + argparse supplies the ``False``/``True`` default) +- ``count`` -- requires an ``int`` parameter; defaults to ``0`` (``None`` for ``int | None``) +- ``append`` / ``extend`` -- require a ``list[T]`` parameter and default to ``[]`` + (``append`` takes one value per flag; ``extend`` takes ``nargs`` values per flag) +- ``store_const`` / ``append_const`` -- store the ``Option(const=...)`` value (``type=`` is dropped). + The action is inferred from the type when ``action=`` is omitted: a scalar ``Option(const=X)`` becomes + ``store_const`` (present -> ``const``, absent -> the default, which must exist or be ``T | None``); a + ``list[T]`` ``Option(const=X)`` becomes ``append_const`` (each flag appends ``const``; defaults to ``[]``). + A scalar ``Option(const=X)`` given an explicit ``nargs`` (e.g. ``nargs='?'``) instead keeps the ``store`` + action for argparse's optional-value idiom (absent -> default, bare flag -> ``const``, ``flag VALUE`` -> + converted ``VALUE``); the ``const`` is stored verbatim and must match the declared type. + ``const`` is validated against the declared type and is rejected on a positional ``Argument`` (argparse + ignores it there) +- a custom :class:`argparse.Action` subclass -- passed straight through to ``add_argument``. + The user's class owns storage, so the collection-casting wrapper is dropped and the action-specific + type/const/collection-shape constraints are skipped. The type-inferred converter, default, and + ``required`` are still applied; the action receives them like any hand-built ``add_argument`` call. + ``action='help'`` and ``action='version'`` are not supported. + +The zero-argument actions above (``store_true`` / ``store_false`` / ``count`` / ``store_const`` / +``append_const``) take no value from the command line, so the value-oriented metadata inferred from +the type is dropped before ``add_argument`` is called: the ``type=`` converter, the static +``choices``, and any inferred tab-completer (e.g. the path completer for ``Path``). There is nothing +to complete or convert on a value-less action. A completer that was only *inferred* from the type is +dropped silently, but a ``completer`` / ``choices_provider`` you supply *explicitly* on such an action +is a contradiction and raises ``TypeError`` (matching argparse, which rejects it outright). Actions +that *do* consume values (``append`` / ``extend`` on a ``list[T]``, or a plain value option) keep the +inferred converter and completer unchanged. + +The metadata classes refuse a handful of ``add_argument`` kwargs that the decorator derives from the +signature itself, so passing them through ``Argument(...)`` / ``Option(...)`` raises ``TypeError``: +``type`` (from the annotation), ``dest`` (from the parameter name), ``help`` (use the ``help_text`` +parameter, which maps to it -- a raw ``help`` would silently shadow it), and -- on ``Argument`` only +-- ``action`` / ``required`` (which have no meaning on a positional). Every other ``add_argument`` +parameter, including those registered via +:func:`~cmd2.argparse_utils.register_argparse_argument_parameter`, passes through unchanged. + +A ``default`` may be supplied either as the function-signature default (``param: T = v``) or as +``Argument(default=v)`` / ``Option(default=v)`` -- the two forms are equivalent. Specifying both at +once raises ``TypeError`` (the value would have two sources of truth), and ``argparse.SUPPRESS`` is +rejected as a default from either source because it would remove the keyword argument the function +expects. + +Parser-level customization is forwarded to :class:`~cmd2.Cmd2ArgumentParser`'s constructor via PEP +692 ``**parser_kwargs: Unpack[Cmd2ParserKwargs]``. Anything the parser ctor accepts -- ``description``, +``epilog``, ``prog``, ``usage``, ``parents``, ``argument_default``, ``prefix_chars``, +``fromfile_prefix_chars``, ``conflict_handler``, ``add_help``, ``allow_abbrev``, ``exit_on_error``, +``formatter_class``, ``ap_completer_type``, and on Python >= 3.14 ``suggest_on_error`` / ``color`` -- +flows straight through; the :class:`Cmd2ParserKwargs` ``TypedDict`` is the single source of truth +and gives type-checkers/IDEs autocomplete on the decorator's call site. ``parser_class`` stays as +its own explicit kwarg because it selects the class itself, not a value passed to it. Two +behaviors layer on top of the raw passthrough: if ``description`` is omitted, the first paragraph +of ``func.__doc__`` (up to the first blank line) is used so docstrings double as help text without +leaking ``:param:`` directives; and ``prog`` is rejected with ``subcommand_to`` because cmd2 +rewrites it from the parent command's hierarchy. Mutually exclusive groups accept +``Group(required=True)`` to require exactly one member; the same flag on a plain ``groups=`` entry +raises ``ValueError`` (argparse's ``add_argument_group`` has no ``required``). + +Unsupported patterns (raise ``TypeError``): + +- a non-Optional type with a ``None`` default (e.g. ``name: str = None``); annotate it + ``T | None`` or use a non-None default. ``Any``/``object``/unannotated are exempt +- a scalar type with no converter (e.g. ``datetime.datetime``, ``uuid.UUID``, ``bytes``, + or any custom class), which would silently arrive as a plain string. Supported scalars + are ``str``, ``int``, ``float``, ``bool``, ``decimal.Decimal``, ``pathlib.Path``, + ``enum.Enum`` subclasses, and ``Literal[...]`` (``str``/``Any``/``object`` pass through raw) +- ``str | int`` -- a union of multiple non-None types is ambiguous +- ``tuple[int, str, float]`` -- mixed element types (argparse applies one ``type=`` per argument) +- ``*args: tuple[T, ...]`` (or any collection element) -- the annotation is each value's type, + so a collection element means a tuple-of-collections; annotate the element, e.g. ``*args: str`` +- ``*args: Annotated[T, Option(...)]`` -- ``*args`` is always positional; use ``Argument()`` +- ``*args: Annotated[T, Argument(nargs=N)]`` -- ``*args`` arity is fixed to ``nargs='*'`` +- a keyword-only parameter annotated with ``Argument()`` -- it marks a positional; use ``Option()`` +- a required option (no default, not ``T | None``) in a ``mutually_exclusive_groups`` group -- + only one member is supplied, so the others arrive as ``None``; give it a default or ``T | None`` +- ``Annotated[T, Argument(nargs=N)]`` producing a list (``'*'``, ``'+'``, integer ``>= 1``) + on a non-collection ``T``; use ``list[T]`` or ``tuple[T, ...]`` to match the runtime shape +- ``Annotated[tuple[T, T], Argument(nargs=N)]`` where ``N`` differs from the tuple's arity +- ``Option(action=...)`` whose result type mismatches the declared type, an unsupported action, + or a non-list action on a collection (use ``append``/``extend``/``append_const`` with ``list[T]``) +- a variable-arity positional (``T | None``, ``list[T]``, ``tuple[T, ...]``) followed by another + positional -- it must come last (``def f(self, a: str, *rest: str)`` is fine) + +When combining ``Annotated`` with ``Optional``, the union should go *inside*: +``Annotated[T | None, meta]``. ``Annotated[T, meta] | None`` is ambiguous and raises -- unless the +inner type already carries the ``None`` (``Annotated[T | None, meta] | None``), in which case the +redundant outer ``| None`` is accepted as equivalent to ``Annotated[T | None, meta]``. + +``Path`` and ``Enum`` annotations also get automatic tab completion. A user-supplied +``choices_provider`` or ``completer`` drives completion in place of the inferred static +``choices``, while the inferred ``type`` converter is kept so values still coerce to the +declared type (an ``Enum`` to its member, ``Literal[1, 2]`` to ``int``) and out-of-type +values are rejected at parse time. An ``Enum`` accepts both member values and member names on the +command line (completion and ``--help`` show the values). + +An explicit ``choices=`` is reconciled with the inferred type rather than fighting it: its values are +run through the inferred ``type`` converter so they match argparse's post-conversion comparison +(``Annotated[int, Option('--n', choices=['1', '2'])]`` becomes ``choices=[1, 2]``, so ``--n 1`` +matches; a value the converter rejects is a build-time ``TypeError``), and an explicit ``choices=`` +takes precedence over a *type-inferred* completer (the ``Path`` completer is dropped so the choices +drive both validation and completion). A ``choices_provider`` / ``completer`` you supply yourself +still wins over ``choices=``. +""" + +import argparse +import decimal +import enum +import functools +import inspect +import types +from collections.abc import Callable, Container, Iterable, Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + ClassVar, + Literal, + TypedDict, + TypeVar, + Union, + Unpack, + get_args, + get_origin, + get_type_hints, + overload, +) + +from rich.table import Column + +from . import constants +from .argparse_utils import DEFAULT_ARGUMENT_PARSER, Cmd2ArgumentParser, SubcommandSpec +from .completion import CompletionItem +from .decorators import _parse_positionals +from .exceptions import Cmd2ArgparseError +from .rich_utils import Cmd2HelpFormatter, HelpContent +from .types import CmdOrSetT, UnboundChoicesProvider, UnboundCompleter + +if TYPE_CHECKING: + from .argparse_completer import ArgparseCompleter + +#: ``nargs`` values accepted by cmd2's patched ``add_argument`` (incl. ranged tuples). +_NargsValue = int | str | tuple[int] | tuple[int, int] | tuple[int, float] + + +class Cmd2ParserKwargs(TypedDict, total=False): + """Forwarded ctor kwargs for :class:`~cmd2.Cmd2ArgumentParser` (PEP 692 ``Unpack``). + + Single source of truth mirroring the parser's ``__init__``: add a field here to expose a new + ctor kwarg on the decorator's call site. All optional (``total=False``); ``suggest_on_error`` + and ``color`` only take effect on Python >= 3.14. + """ + + prog: str | None + usage: str | None + description: HelpContent | None + epilog: HelpContent | None + parents: Sequence[argparse.ArgumentParser] + formatter_class: type[Cmd2HelpFormatter] + prefix_chars: str + fromfile_prefix_chars: str | None + argument_default: Any + conflict_handler: str + add_help: bool + allow_abbrev: bool + exit_on_error: bool + suggest_on_error: bool + color: bool + ap_completer_type: "type[ArgparseCompleter] | None" + + +# --------------------------------------------------------------------------- +# Metadata classes +# --------------------------------------------------------------------------- + +#: Sentinel marking "no value assigned" (a builder slot, or an unset ``const``). +#: Defined here so the metadata classes can use it as a default before the builder section. +_UNSET: Any = object() + + +class _BaseArgMetadata: + """Shared fields for ``Argument`` and ``Option`` metadata.""" + + _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", + } + + #: ``add_argument`` kwargs the decorator derives from the signature (or exposes under another + #: name), so the metadata classes refuse them as ``extra_kwargs``: ``type`` (from the annotation), + #: ``dest`` (parameter name), ``action``/``required`` (named ``Option`` args), ``help`` (use + #: ``help_text``). Per-key remediation hints are in :meth:`__init__`. + _RESERVED_EXTRA_KWARGS: ClassVar[frozenset[str]] = frozenset( + { + "type", + "dest", + "action", + "required", + "help", + } + ) + + def __init__( + self, + *, + help_text: str | None = None, + metavar: str | tuple[str, ...] | None = None, + nargs: _NargsValue | None = None, + choices: Iterable[Any] | None = None, + choices_provider: UnboundChoicesProvider[CmdOrSetT] | None = None, + completer: UnboundCompleter[CmdOrSetT] | None = None, + table_columns: Sequence[str | Column] | None = None, + suppress_tab_hint: bool | None = None, + const: Any = _UNSET, + default: Any = _UNSET, + **extra_kwargs: Any, + ) -> None: + """Initialise shared metadata fields. + + ``const`` is the value stored on a present flag with no argument (``Option`` only: + ``store_const``/``append_const``); ``_UNSET`` distinguishes "no const" from ``const=None``. + ``default`` mirrors the signature default (``Option(default=v)`` == ``... = v``); supplying + both, or ``argparse.SUPPRESS``, is rejected. ``extra_kwargs`` forwards any other + ``add_argument`` parameter (incl. those from + :func:`~cmd2.argparse_utils.register_argparse_argument_parameter`) straight through. + """ + reserved = self._RESERVED_EXTRA_KWARGS & extra_kwargs.keys() + if reserved: + name = sorted(reserved)[0] + # Per-key remediation hint for the reserved kwarg. + hint = { + "type": "The converter is derived from the parameter annotation; change the annotation instead.", + "dest": "The dest is the parameter name; rename the parameter instead.", + "action": "Use Option(action=...) (only Option supports an action; Argument is always positional).", + "required": ( + "Use Option(required=True); a positional Argument is always required unless it has " + "a default or is annotated as `T | None`." + ), + "help": "Use the help_text= parameter instead; it maps to argparse's help= and would otherwise be shadowed.", + }[name] + raise TypeError(f"{type(self).__name__}({name}=...) is not accepted by @with_annotated. {hint}") + self.help_text = help_text + self.metavar = metavar + self.nargs = nargs + self.choices = choices + self.choices_provider = choices_provider + self.completer = completer + self.table_columns = table_columns + self.suppress_tab_hint = suppress_tab_hint + self.const = const + self.default = default + self.extra_kwargs = extra_kwargs + + def to_kwargs(self) -> dict[str, Any]: + """Return non-None mapped fields, an explicit ``const``, and any passthrough ``extra_kwargs``.""" + kwargs = {kwarg: val for attr, kwarg in self._KWARGS_MAP.items() if (val := getattr(self, attr)) is not None} + if self.const is not _UNSET: + kwargs["const"] = self.const + kwargs.update(self.extra_kwargs) + return kwargs + + +class Argument(_BaseArgMetadata): + """Metadata for a positional argument in an ``Annotated`` type hint.""" + + +class Option(_BaseArgMetadata): + """Metadata for an optional/flag argument in an ``Annotated`` type hint. + + Positional ``*names`` are the flag strings (e.g. ``"--color"``, ``"-c"``); when omitted + the decorator generates ``--param-name`` (underscores become dashes). + """ + + def __init__( + self, + *names: str, + action: str | type[argparse.Action] | None = None, + required: bool = False, + **kwargs: Any, + ) -> None: + """Initialise Option metadata. + + ``action`` is a supported string action (``store_true``/``store_false``/``count``/ + ``append``/``extend``/``store_const``/``append_const``) or a custom + :class:`argparse.Action` subclass (passed through; it owns storage, so the inferred + action and the action-specific constraints are skipped). + """ + super().__init__(**kwargs) + self.names = names + 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 is not None: + kwargs["action"] = self.action + if self.required: + kwargs["required"] = True + return kwargs + + +class Group: + """Argument-group definition for ``with_annotated(groups=...)`` / ``mutually_exclusive_groups=...``.""" + + def __init__( + self, + *members: str, + title: str | None = None, + description: str | None = None, + required: bool = False, + ) -> None: + """Initialise an argument group definition. + + :param members: parameter names to place in the group (at least one) + :param title: group title shown as a help section header + :param description: group description shown under the title + :param required: ``mutually_exclusive_groups`` only -- require exactly one member. + On a ``groups=`` entry it raises ``ValueError``. + """ + if not members: + raise ValueError("Group requires at least one member parameter name") + self.members = members + self.title = title + self.description = description + self.required = required + + def _validate_members(self, *, all_param_names: set[str], group_type: str) -> None: + """Validate that every referenced member parameter exists.""" + for name in self.members: + if name not in all_param_names: + raise ValueError(f"{group_type} references nonexistent parameter {name!r}") + + +#: Metadata extracted from ``Annotated[T, meta]``, or ``None`` for plain types. +ArgMetadata = Argument | Option | None + +_NormalizedAnnotation = tuple[Any, ArgMetadata, bool] +_ArgumentTarget = argparse.ArgumentParser | argparse._MutuallyExclusiveGroup | argparse._ArgumentGroup + + +@dataclass +class _TypeResult: + """How a declared type maps onto argparse settings.""" + + converter: Callable[[str], Any] | None = None + choices: Iterable[Any] | None = None + action: str | type[argparse.Action] | None = None + completer: Any = None + is_collection: bool = False + container_factory: Callable[[list[Any]], Any] | None = None + fixed_arity: int | None = None + + +# --------------------------------------------------------------------------- +# Type resolvers +# --------------------------------------------------------------------------- +# +# Each resolver has the signature ``(tp, args, *, is_positional) -> _TypeResult`` and is +# registered in ``_TYPE_TABLE``. ``_resolve_base_type`` looks one up by type; ``_apply_type`` +# copies the resulting ``_TypeResult`` onto the builder's slots and context scratch. +# --------------------------------------------------------------------------- + +_BOOL_TRUE_VALUES = ["1", "true", "t", "yes", "y", "on"] +_BOOL_FALSE_VALUES = ["0", "false", "f", "no", "n", "off"] +_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: + 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})") + + _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[..., _TypeResult]: + """Create a resolver for types that just need ``type=converter``.""" + + def _resolve(_tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> _TypeResult: + return _TypeResult(converter=converter) + + return _resolve + + +def _resolve_path(_tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> _TypeResult: + """Resolve Path and add completer.""" + from .cmd2 import Cmd + + return _TypeResult(converter=Path, completer=Cmd.path_complete) + + +def _resolve_bool(_tp: Any, _args: tuple[Any, ...], *, is_positional: bool = False, **_ctx: Any) -> _TypeResult: + """Resolve bool: a positional gets a converter, an option a flag action. + + A user ``Option(action=...)`` overrides this later; here we set only the default option action. + """ + if not is_positional: + return _TypeResult(action=argparse.BooleanOptionalAction) + return _TypeResult(converter=_parse_bool, choices=list(_BOOL_CHOICES)) + + +def _resolve_element(tp: Any) -> _TypeResult: + """Resolve a collection element type and reject nested collections.""" + inner = _resolve_base_type(tp, is_positional=True) + if inner.is_collection: + raise TypeError("Nested collections are not supported") + return inner + + +def _make_collection_resolver(collection_type: type) -> Callable[..., _TypeResult]: + """Create a resolver for single-arg collections (list[T], set[T]).""" + + def _resolve(_tp: Any, args: tuple[Any, ...], **_ctx: Any) -> _TypeResult: + if len(args) == 0: + # Bare list/set without type args -- treat as list[str]/set[str]. + return _TypeResult(is_collection=True, container_factory=collection_type) + if len(args) != 1: + raise TypeError( + f"{collection_type.__name__}[...] with {len(args)} type arguments is not supported; " + f"use {collection_type.__name__}[T] with a single element type." + ) + element = _resolve_element(args[0]) + return _TypeResult( + converter=element.converter, + choices=element.choices, + completer=element.completer, + is_collection=True, + container_factory=collection_type, + ) + + return _resolve + + +def _resolve_tuple(_tp: Any, args: tuple[Any, ...], **_ctx: Any) -> _TypeResult: + """Resolve tuple[T, ...] (variable) and tuple[T, T] (fixed arity).""" + if not args: + # Bare tuple without type args -- treat as tuple[str, ...]. + return _TypeResult(is_collection=True, container_factory=tuple) + + if len(args) == 2 and args[1] is Ellipsis: + element = _resolve_element(args[0]) + return _TypeResult( + converter=element.converter, + choices=element.choices, + completer=element.completer, + is_collection=True, + container_factory=tuple, + ) + + if Ellipsis not in args: + first = args[0] + if not all(a == first for a in args[1:]): + raise TypeError( + 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." + ) + element = _resolve_element(first) + return _TypeResult( + converter=element.converter, + choices=element.choices, + completer=element.completer, + is_collection=True, + container_factory=tuple, + fixed_arity=len(args), + ) + + raise TypeError( + "tuple with Ellipsis in an unexpected position is not supported; " + "use tuple[T, ...] for variable-length or tuple[T, T] for fixed-arity." + ) + + +def _resolve_literal(_tp: Any, args: tuple[Any, ...], **_ctx: Any) -> _TypeResult: + """Resolve Literal["a", "b", ...] into converter + choices.""" + literal_values = list(args) + return _TypeResult(converter=_make_literal_type(literal_values), choices=literal_values) + + +def _resolve_enum(tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> _TypeResult: + """Resolve Enum subclasses into converter + choices.""" + return _TypeResult( + converter=_make_enum_type(tp), + choices=[CompletionItem(m, text=str(m.value), display_meta=m.name) for m in tp], + ) + + +# -- Registry ----------------------------------------------------------------- + +_TYPE_TABLE: dict[Any, Callable[..., _TypeResult]] = { + # Subclass-matchable entries first -- iteration order matters for the + # issubclass fallback. enum.Enum must precede int (IntEnum <: int). + enum.Enum: _resolve_enum, + Path: _resolve_path, + # Exact-match entries (order among these doesn't affect subclass lookup). + bool: _resolve_bool, + 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, +} + + +# -- Helpers ------------------------------------------------------------------ + + +def _type_name(tp: Any) -> str: + """Best-effort type name for diagnostic messages.""" + return tp.__name__ if hasattr(tp, "__name__") else str(tp) + + +#: Scalar annotations that argparse stores as the raw string (no converter needed). +_PASSTHROUGH_TYPES = frozenset({str, object, Any, inspect.Parameter.empty}) + + +def _resolve_base_type(tp: Any, *, is_positional: bool = False) -> _TypeResult: + """Resolve a declared type into a :class:`_TypeResult` via the registry. + + Lookup order: ``get_origin(tp)`` -> ``tp`` -> ``issubclass`` fallback -> passthrough. + Raises ``TypeError`` for a scalar with no converter. + """ + args = get_args(tp) + resolver = _TYPE_TABLE.get(get_origin(tp)) or _TYPE_TABLE.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_TABLE.items(): + if isinstance(parent, type) and issubclass(tp, parent): + resolver = candidate + break + + if resolver is not None: + return resolver(tp, args, is_positional=is_positional) + if tp in _PASSTHROUGH_TYPES: + return _TypeResult() + raise TypeError( + f"Unsupported parameter type {_type_name(tp)!r} for @with_annotated: there is no converter " + f"for it, so command-line values would silently arrive as plain strings. Supported scalar types " + f"are str, int, float, bool, decimal.Decimal, pathlib.Path, enum.Enum subclasses, and Literal[...]; " + f"use one of these (optionally in list/set/tuple) or a subclass of one." + ) + + +def _unwrap_optional(tp: Any) -> tuple[Any, bool]: + """If *tp* is ``T | None``, return ``(T, True)``. Otherwise ``(tp, False)``. + + Raises ``TypeError`` for ambiguous unions like ``str | int`` or ``str | int | None``. + """ + 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(_type_name(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: Any) -> _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 -- builder anchored to argparse's add_argument schema +# --------------------------------------------------------------------------- + + +class _NargsMode(enum.Enum): + """How an explicit action sets ``nargs`` in the final override pass.""" + + ARITY = "arity" # keep the nargs resolved by the arity table + CLEAR = "clear" # take one value per flag, so emit no nargs + + +@dataclass(frozen=True) +class _ActionPolicy: + """Declarative data for an explicit ``Option(action=...)``. + + Applied verbatim by :meth:`_ArgparseArgument._apply_action`; the type + compatibility check (``requires``) is enforced by the constraint table. + """ + + requires: Callable[["_ArgparseArgument"], bool] | None = None # declared type the result needs + requires_label: str = "" # human name of the required type (for the error message) + drop_converter: bool = False # action does not accept type= + nargs_mode: _NargsMode = _NargsMode.ARITY # whether to keep or clear the arity-table nargs + default_factory: Callable[[], Any] | None = None # default when omitted (also -> None for T | None) + + +#: Actions that accumulate values into a list via argparse's native action (replace the casting action). +_LIST_ACTIONS = frozenset({"append", "extend", "append_const"}) +#: Actions that store a fixed ``const`` value: ``store_const`` (scalar) and ``append_const`` (list[T]). +_CONST_ACTIONS = frozenset({"store_const", "append_const"}) + +#: Metadata kwargs the builder reasons about itself (the override facts read on demand via the +#: ``_meta_*`` properties); every other ``to_kwargs()`` entry passes straight through to +#: ``extras`` for argparse (help / metavar / choices_provider / completer / ...). +_METADATA_SPECIAL_KEYS = frozenset({"choices", "action", "required", "nargs", "const"}) + +#: A rule table maps a subject ``_S`` (a single :class:`_ArgparseArgument`) to a result ``_R``. +_S = TypeVar("_S") +_R = TypeVar("_R") + +#: One rule-table row: ``(predicate, producer)``. A table is scanned top-to-bottom and the first +#: row whose ``predicate(subject)`` holds yields ``producer(subject)`` (see :func:`_first_match`). +#: Derivation tables produce an output slot's value; constraint tables produce an error or ``None``. +#: (Keyed dispatch -- ``_TYPE_TABLE`` / ``_ACTION_TABLE`` -- is separate: a payload looked up by key.) +_Rule = tuple[Callable[[_S], bool], Callable[[_S], _R]] + + +def _always(_subject: object) -> bool: + """Predicate for a catch-all row -- always matches (every table ends with one).""" + return True + + +def _const(value: _R) -> Callable[[object], _R]: + """Return a producer that ignores its subject and always yields *value*.""" + return lambda _subject: value + + +def _first_match(rules: list[_Rule[_S, _R]], subject: _S) -> _R: + """Return ``producer(subject)`` for the first rule whose predicate matches *subject*. + + Every table ends with an ``_always`` catch-all, so a match is guaranteed and only the + matching producer runs (a producer may assume its predicate held). + """ + return next(produce(subject) for predicate, produce in rules if predicate(subject)) + + +class _ArgparseArgument: + """Builder whose output fields mirror ``parser.add_argument(...)``'s schema.""" + + def __init__( + self, + *, + name: str, + func_qualname: str, + has_default: bool, + param_default: Any, + is_kw_only: bool, + is_variadic: bool, + inner_type: Any, + metadata: ArgMetadata, + is_optional: bool, + kind: inspect._ParameterKind, + is_base_command: bool, + ) -> None: + # signature-derived inputs (never emitted): + self.name = name + self.func_qualname = func_qualname + self.has_default = has_default + self.param_default = param_default # the function's own default, not the argparse `default` slot + self.is_kw_only = is_kw_only + self.is_variadic = is_variadic + self.inner_type = inner_type # peeled type (after Annotated + Optional) + self.metadata = metadata + self.is_optional = is_optional + self.kind = kind # unsupported kinds (positional-only, **kwargs) are rejected by _CONSTRAINTS + self.is_base_command = is_base_command + # scratch filled by the type table (_apply_type): + self.is_collection = False + self.fixed_arity: int | None = None + # output slots (1:1 with add_argument): + self.is_positional = False + self.flags: list[str] = [] + self.action: str | type[argparse.Action] | None = None + self.nargs: _NargsValue | None = None + self.type: Callable[[str], Any] | None = None + self.choices: Iterable[Any] | None = None + self.default: Any = _UNSET # _UNSET until a default rule/action sets one + self.required: bool = False + self.container_factory: Callable[[list[Any]], Any] | None = None + self.extras: dict[str, Any] = {} + # first type-resolution error, captured so _CONSTRAINTS (not build order) picks the message: + self.build_error: Exception | None = None + # cross-argument facts, linked by _resolve_parameters once the whole list is built: + self.has_following_positional = False + # 1-based indices of the groups=/mutually_exclusive_groups= this parameter belongs to: + self.argument_group_indices: list[int] = [] + self.mutex_group_indices: list[int] = [] + # Derive every output slot now; validation stays deferred to _check_constraints. + self._apply() + + @property + def omittable(self) -> bool: + """Whether the argument may be absent (drives ``nargs`` and required). + + A metadata default (``Option(default=...)``) counts the same as a signature default here. + """ + return self._effective_has_default or self.is_kw_only or self.is_optional or self.is_variadic + + @property + def _is_list(self) -> bool: + """Whether the declared type is ``list``/``list[T]`` -- the shape the list actions need. + + Distinct from :attr:`is_collection` (also true for ``set``/``tuple``): ``append``/``extend``/ + ``append_const`` accumulate specifically into a ``list``. + """ + return get_origin(self.inner_type) is list or self.inner_type is list + + # -- ``*args`` element facts, derived from the ``tuple[element, ...]`` wrapper that + # _resolve_parameters builds for a variadic parameter (only meaningful when ``is_variadic``) -- + + @property + def _var_positional_element(self) -> Any: + """The ``*args`` element type ``T`` (``inner_type`` is the variadic ``tuple[T, ...]``).""" + return get_args(self.inner_type)[0] + + @property + def _var_positional_element_display(self) -> str: + """Display name of the ``*args`` element type (for the collection-element constraint message).""" + element = self._var_positional_element + return str(element) if get_origin(element) is not None else _type_name(element) + + @property + def _var_positional_element_is_collection(self) -> bool: + """Whether the ``*args`` element is itself a collection (``list``/``set``/``tuple``). + + Mirrors the collection entries in :data:`_TYPE_TABLE`; a collection element means ``*args`` + would collect a tuple of collections, which the constraint table rejects. + """ + element = self._var_positional_element + origin = get_origin(element) + return (origin if origin is not None else element) in (list, set, tuple) + + # -- the user's metadata overrides, derived read-only from ``metadata`` (consulted by the + # choices/action/nargs/required tables, the action phase, and the constraints) -- + + @property + def _meta_nargs(self) -> _NargsValue | None: + """An explicit ``Argument/Option(nargs=)``, else ``None``.""" + return self.metadata.nargs if self.metadata is not None else None + + @property + def _meta_nargs_yields_optional_single(self) -> bool: + """Whether an explicit ``nargs`` yields at most one value that argparse won't wrap in a list. + + ``'?'`` and the ranged ``(0, 1)`` both make argparse store a bare scalar (or ``None`` when + absent) instead of a list -- so they neither cast into a declared collection nor give a + non-Optional scalar a value when omitted. + """ + return self._meta_nargs == "?" or (isinstance(self._meta_nargs, tuple) and tuple(self._meta_nargs) == (0, 1)) + + @property + def _meta_choices(self) -> Iterable[Any] | None: + """Explicit ``Argument/Option(choices=)``, else ``None``.""" + return self.metadata.choices if self.metadata is not None else None + + @property + def _has_user_completion(self) -> bool: + """Whether the user supplied a ``choices_provider`` / ``completer`` on the metadata.""" + if self.metadata is None: + return False + return self.metadata.choices_provider is not None or self.metadata.completer is not None + + @property + def _meta_action(self) -> str | type[argparse.Action] | None: + """An explicit ``Option(action=)`` value, else ``None`` (only ``Option`` carries one).""" + return self.metadata.action if isinstance(self.metadata, Option) else None + + @property + def _meta_action_is_class(self) -> bool: + """Whether ``Option(action=)`` is a custom :class:`argparse.Action` subclass.""" + return isinstance(self._meta_action, type) + + @property + def _meta_required(self) -> bool: + """Whether the user set ``Option(required=True)``.""" + return self.metadata.required if isinstance(self.metadata, Option) else False + + @property + def _const_value(self) -> Any: + """The explicit ``Argument/Option(const=)`` value, or :data:`_UNSET` when none was given.""" + return self.metadata.const if self.metadata is not None else _UNSET + + @property + def _has_const(self) -> bool: + """Whether a ``const`` value was supplied (drives const-action inference and validation).""" + return self._const_value is not _UNSET + + @property + def _meta_default(self) -> Any: + """The explicit ``Argument/Option(default=)`` value, or :data:`_UNSET` when none was given.""" + return self.metadata.default if self.metadata is not None else _UNSET + + @property + def _has_meta_default(self) -> bool: + """Whether a metadata default was supplied (treated like a signature default).""" + return self._meta_default is not _UNSET + + @property + def _effective_has_default(self) -> bool: + """Whether any default applies (signature or metadata). + + A metadata default is the equivalent of a signature default: + ``Annotated[T, Option('--x', default=v)]`` behaves the same as + ``Annotated[T, Option('--x')] = v``. Specifying both is a conflict (see ``_CONSTRAINTS``). + """ + return self.has_default or self._has_meta_default + + @property + def _effective_param_default(self) -> Any: + """The default to emit -- signature default if present, else the metadata default, else ``None``.""" + if self.has_default: + return self.param_default + if self._has_meta_default: + return self._meta_default + return None + + @property + def _effective_action(self) -> str | None: + """The resolved action as a string -- explicit ``Option(action=)`` or const-inferred. + + ``None`` for the type-inferred class actions (``BooleanOptionalAction`` / + ``_CollectionCastingAction``), which carry no :data:`_ACTION_TABLE` policy. Reads the + resolved ``action`` slot, so it is only meaningful after :data:`_ACTION_RULES` run. + """ + return self.action if isinstance(self.action, str) else None + + @property + def _policy(self) -> _ActionPolicy | None: + """The action policy for the effective action (``None`` for no/an unknown/a class action). + + Keyed on :attr:`_effective_action` so a const-inferred ``store_const``/``append_const`` + gets the same policy treatment as an explicit one. + """ + return _ACTION_TABLE.get(self._effective_action) if self._effective_action else None + + @property + def _is_inferred_bool_flag(self) -> bool: + """Whether this is a bool option using the inferred ``BooleanOptionalAction`` (no explicit action).""" + return self.action is argparse.BooleanOptionalAction + + def _apply(self) -> None: + """Build this argument by deriving each output slot.""" + self.is_positional = _first_match(_ROLE_RULES, self) + self._apply_targets() + self._apply_type() + if self.build_error is not None: + # Type unresolved: the remaining phases assume a resolved type, so stop here and let + # _CONSTRAINTS raise the captured error. + return + self._apply_metadata_extras() + self.action = _first_match(_ACTION_RULES, self) + self.choices = _first_match(_CHOICES_RULES, self) + if self._meta_choices is not None and self.choices is not None: + # The choices the user wrote in source are compared by argparse *after* the type + # converter runs, so run them through that converter to land in the same value-space + # (Annotated[int, Option(choices=['1','2'])] -> [1, 2], so `--x 1` matches), and drop a + # type-inferred completer (e.g. Path's) so completion is driven by these choices. + if self.type is not None: + self.choices = self._convert_choices(self.choices, self.type) + if not self._has_user_completion: + self.extras.pop("completer", None) + self.extras.pop("choices_provider", None) + self.nargs = _first_match(_NARGS_RULES, self) + self.default = _first_match(_DEFAULT_RULES, self) + self.required = _first_match(_REQUIRED_RULES, self) + self._apply_action() + + def _convert_choices(self, choices: Iterable[Any], converter: Callable[[str], Any]) -> list[Any]: + """Run string ``choices`` through the inferred ``type`` *converter* so they match post-conversion. + + Non-string choices are assumed already in the target type and left untouched (the simple + converters are idempotent on them). A choice the converter rejects is a build-time error. + """ + converted: list[Any] = [] + for choice in choices: + if not isinstance(choice, str): + converted.append(choice) + continue + try: + converted.append(converter(choice)) + except (ValueError, TypeError, ArithmeticError, argparse.ArgumentTypeError) as exc: + raise TypeError(f"choice {choice!r} on '{self.name}' is not a valid '{_type_name(self.inner_type)}'.") from exc + return converted + + # -- construction (fill output slots from the tables; no business rules here) -- + + def _apply_targets(self) -> None: + """Set ``flags`` for options (positionals keep the empty default).""" + if self.is_positional: + return + self.flags = ( + list(self.metadata.names) + if isinstance(self.metadata, Option) and self.metadata.names + else [f"--{self.name.replace('_', '-')}"] + ) + + def _apply_type(self) -> None: + """Copy the type table's result onto the output slots + type scratch. + + Type resolution is the only build step that can fail (unsupported type, nested collection, ...). + Rather than raise here -- which would let build order decide the message -- the error is captured + so :data:`_CONSTRAINTS` can rank it against more specific rules and raise the winner. + """ + try: + result = _resolve_base_type(self.inner_type, is_positional=self.is_positional) + except TypeError as exc: + self.build_error = exc + return + self.type = result.converter + self.choices = result.choices + # A collection coerces its parsed list into the declared container type; option bool + # gets ``--flag/--no-flag``. Either may be overridden by an explicit ``Option(action=)``. + self.action = _CollectionCastingAction if result.is_collection else result.action + self.container_factory = result.container_factory + if result.completer is not None: + self.extras["completer"] = result.completer + self.is_collection = result.is_collection + self.fixed_arity = result.fixed_arity + + def _apply_metadata_extras(self) -> None: + """Pass the user's display/completion metadata straight through to ``extras``.""" + if self.metadata is None: + return + kwargs = self.metadata.to_kwargs() + self.extras.update({key: value for key, value in kwargs.items() if key not in _METADATA_SPECIAL_KEYS}) + + def _apply_action(self) -> None: + """Apply an explicit ``Option(action=...)`` as the final override pass. + + Sets slots only; validity (type match, unknown action) is enforced by the constraints. A + custom :class:`argparse.Action` has no policy -- it owns storage, so the casting wrapper is + dropped while the inferred converter/default/required are kept. + """ + if self._meta_action_is_class: + # The user's class owns storage; drop the casting wrapper's container_factory kwarg. + self.container_factory = None + return + policy = self._policy + if policy is None: + return + if policy.drop_converter: + # The action takes no command-line value, so drop the converter, choices, and any + # value-completion metadata (argparse rejects them on a zero-argument action). + self.type = None + self.choices = None + self.extras.pop("completer", None) + self.extras.pop("choices_provider", None) + if self._effective_action in _LIST_ACTIONS: + # append/extend/append_const use argparse's native list action, not the casting action. + self.container_factory = None + if policy.nargs_mode is _NargsMode.CLEAR: + self.nargs = None # append collects one value per flag, so it takes no nargs + if self.default is _UNSET and policy.default_factory is not None: + # The action carries its own default (count -> 0, append/extend -> []), + # except T | None, where None is the natural absence value. + self.default = None if self.is_optional else policy.default_factory() + if not self._meta_required: + # A supported action supplies a value when absent, so it is never required by default. + self.required = False + + def _check_constraints(self) -> None: + """Raise for the first violated constraint (declarative validation).""" + error = _first_match(_CONSTRAINTS, self) + if error is not None: + raise error + + # -- emission ------------------------------------------------------------ + + def _emit(self) -> tuple[tuple[Any, ...], dict[str, Any]]: + """Return ``(args, kwargs)`` for ``target.add_argument(*args, **kwargs)``.""" + kwargs: dict[str, Any] = dict(self.extras) + if self.type is not None: + kwargs["type"] = self.type + if self.choices is not None: + # Materialize so argparse can re-iterate it (a one-shot generator would be exhausted). + kwargs["choices"] = list(self.choices) + if self.action is not None: + kwargs["action"] = self.action + if self._has_const: + kwargs["const"] = self._const_value + if self.nargs is not None: + kwargs["nargs"] = self.nargs + if self.container_factory is not None: + kwargs["container_factory"] = self.container_factory + if self.default is not _UNSET: + kwargs["default"] = self.default + if self.required: + kwargs["required"] = True + if self.is_positional: + return (self.name,), kwargs + kwargs["dest"] = self.name + return tuple(self.flags), kwargs + + def add_to(self, target: _ArgumentTarget) -> None: + """Add this argument to *target* (a parser, group, or mutex group).""" + args, kwargs = self._emit() + target.add_argument(*args, **kwargs) + + +#: Explicit ``Option(action=...)`` policies. Defined after :class:`_ArgparseArgument` so the +#: ``requires`` predicates can read its ``inner_type`` slot (like the other rule tables below). +_ACTION_TABLE: dict[str, _ActionPolicy] = { + "store_true": _ActionPolicy( + requires=lambda a: a.inner_type is bool, + requires_label="bool", + drop_converter=True, + default_factory=lambda: False, + ), + "store_false": _ActionPolicy( + requires=lambda a: a.inner_type is bool, + requires_label="bool", + drop_converter=True, + default_factory=lambda: True, + ), + "count": _ActionPolicy( + requires=lambda a: a.inner_type is int, requires_label="int", drop_converter=True, default_factory=lambda: 0 + ), + "append": _ActionPolicy( + requires=lambda a: a._is_list, + requires_label="list[T]", + nargs_mode=_NargsMode.CLEAR, + default_factory=list, + ), + "extend": _ActionPolicy( + requires=lambda a: a._is_list, + requires_label="list[T]", + default_factory=list, + ), + # const actions: shape (scalar vs list[T]) and the const value itself are validated by dedicated + # _CONSTRAINTS rows, so requires=None here. Both store a fixed value, so the converter/choices are + # dropped (drop_converter) and they take no command-line value (nargs CLEAR). + "store_const": _ActionPolicy(drop_converter=True, nargs_mode=_NargsMode.CLEAR), + "append_const": _ActionPolicy(drop_converter=True, nargs_mode=_NargsMode.CLEAR, default_factory=list), +} + + +#: Role table: the first matching predicate decides positional (``True``) vs option (``False``). +_ROLE_RULES: list[_Rule[_ArgparseArgument, bool]] = [ + (lambda a: a.is_variadic, _const(True)), # *args is always positional + (lambda a: isinstance(a.metadata, Argument), _const(True)), # Argument() forces positional + (lambda a: isinstance(a.metadata, Option), _const(False)), # Option() forces option + (lambda a: a.is_kw_only, _const(False)), # keyword-only -> option + (lambda a: a.has_default, _const(False)), # a signature default -> option (metadata-only + # default never reaches this row: Argument/Option metadata already pinned the role above) + (_always, _const(True)), # otherwise positional +] + +#: Action table: an explicit ``Option(action=)`` overrides the type-inferred action +#: (collection-casting / ``BooleanOptionalAction`` / none). The action *policy* is applied later. +_ACTION_RULES: list[_Rule[_ArgparseArgument, str | type[argparse.Action] | None]] = [ + (lambda a: a._meta_action is not None, lambda a: a._meta_action), # explicit Option(action=) + # const with no explicit action infers the const action by shape: list[T] -> append_const, + # scalar -> store_const. Exception: a scalar that also sets nargs (e.g. '?') keeps the inferred + # ``store`` for argparse's optional-value idiom (bare flag -> const, flag VALUE -> value). + ( + lambda a: a._has_const and (a.is_collection or a._meta_nargs is None), + lambda a: "append_const" if a.is_collection else "store_const", + ), + (_always, lambda a: a.action), # the type-inferred action baseline +] + +#: Choices table, in precedence order: a *user* choices_provider/completer drives completion (drop +#: static choices); otherwise an explicit metadata ``choices=`` wins (even over a type-inferred +#: completer such as ``Path``'s -- see ``_apply``, which then drops that completer); otherwise a +#: type-inferred completer drives completion (drop choices); otherwise the type-inferred choices. +_CHOICES_RULES: list[_Rule[_ArgparseArgument, Iterable[Any] | None]] = [ + (lambda a: a._has_user_completion, _const(None)), # user completer/provider overrides choices + (lambda a: a._meta_choices is not None, lambda a: a._meta_choices), # explicit Argument/Option choices + (lambda a: bool(a.extras.get("choices_provider") or a.extras.get("completer")), _const(None)), # inferred completer + (_always, lambda a: a.choices), # the type-inferred choices baseline +] + +#: ``nargs`` table -- the sole source of arity. An explicit ``Argument(nargs=)`` wins; otherwise the +#: value shape decides (fixed tuple -> its arity, collection -> ``'+'``/``'*'``, optional scalar +#: positional -> ``'?'``). Action effects (append clears nargs) are applied later by the action phase. +_NARGS_RULES: list[_Rule[_ArgparseArgument, _NargsValue | None]] = [ + (lambda a: a._meta_nargs is not None, lambda a: a._meta_nargs), # an explicit Argument(nargs=) wins + (lambda a: a.fixed_arity is not None, lambda a: a.fixed_arity), # tuple[T, T] pins nargs to its arity + (lambda a: a.is_collection and a.omittable, _const("*")), # list/set/tuple[T, ...] that may be empty + (lambda a: a.is_collection, _const("+")), # collection requiring >= 1 value + (lambda a: a.is_positional and a.omittable, _const("?")), # an optional scalar positional + (_always, _const(None)), # required scalar / any option scalar +] + +#: Default-value table. Either source (signature or metadata) feeds the parser default; +#: explicit-action defaults (count -> 0, append/extend -> []) are added later by the action phase. +_DEFAULT_RULES: list[_Rule[_ArgparseArgument, Any]] = [ + (lambda a: a._effective_has_default, lambda a: a._effective_param_default), + # A bool option is a flag: when absent it means ``False`` (not a missing value), so -- like + # store_true -- it carries its own default. ``bool | None`` keeps the catch-all ``_UNSET`` + # (argparse's ``None``), the natural absence value for the Optional case. + (lambda a: a._is_inferred_bool_flag and not a.is_optional, _const(False)), + (_always, _const(_UNSET)), # nothing to set (the action may still add one) +] + +#: Required table. Positionals never carry ``required=``; the action phase may relax it. +_REQUIRED_RULES: list[_Rule[_ArgparseArgument, bool]] = [ + (lambda a: a.is_positional, _const(False)), + (lambda a: a._meta_required, _const(True)), # explicit Option(required=True) + (lambda a: a._effective_has_default or a.is_optional, _const(False)), # omittable + # A bool option is a flag that supplies its own value (False/None) when absent, so it is never + # required without an explicit Option(required=True) -- same reasoning as the flag actions below. + (lambda a: a._is_inferred_bool_flag, _const(False)), + (_always, _const(True)), # an option with no default and no ``| None`` must be supplied +] + + +#: ``nargs`` values that let a positional consume a variable number of tokens. +_VARIABLE_NARGS = frozenset({"?", "*", "+", argparse.REMAINDER}) + + +# Parameter names that conflict with argparse internals and cannot be used as annotated +# parameter names (checked by _CONSTRAINTS). +_RESERVED_PARAM_NAMES = frozenset({"dest", "subcommand"}) + + +def _const_element_type(a: _ArgparseArgument) -> Any: + """Return the type a const must match: the element ``T`` for ``list[T]``, else the scalar itself.""" + if a.is_collection: + args = get_args(a.inner_type) + element = args[0] if args else str + else: + element = a.inner_type + element, _ = _unwrap_optional(element) + return element + + +def _const_mismatches_type(a: _ArgparseArgument) -> bool: + """Whether a supplied ``const`` is incompatible with the declared (element) type. + + Best-effort: ``Literal``/``Enum`` checked for membership, concrete scalars by ``isinstance``; + open types (``str``/``Any``/``object``/bool flag), unresolved types, and class actions are not checked. + """ + if not a._has_const or a._meta_action_is_class: + return False + try: + result = _resolve_base_type(_const_element_type(a)) + except TypeError: + return False # an unresolved element type is reported by the build_error row instead + const = a._const_value + if result.choices is not None: + accepted = [c.value if isinstance(c, CompletionItem) else c for c in result.choices] + return const not in accepted + converter = result.converter + if converter is int: # bool is an int subclass but not a valid int const + return type(const) is not int + if converter is float: + return not (isinstance(const, (int, float)) and not isinstance(const, bool)) + if converter is decimal.Decimal: + return not isinstance(const, decimal.Decimal) + if converter is Path: + return not isinstance(const, Path) + if _const_element_type(a) is str: # a str parameter stores the const verbatim, so it must be a str + return not isinstance(const, str) + return False # Any / object / unannotated / bool flag: genuinely untyped, nothing to validate + + +# The single validity table, evaluated by :func:`_resolve_parameters` once every argument is built +# and its cross-argument facts are linked. +_CONSTRAINTS: list[_Rule[_ArgparseArgument, Exception | None]] = [ + ( + # Signature shape: positional-only parameters cannot be passed by keyword, which is how + # the decorator forwards parsed values. + lambda a: a.kind == inspect.Parameter.POSITIONAL_ONLY, + lambda a: TypeError( + f"Parameter {a.name!r} in {a.func_qualname} is positional-only, " + "which is not supported by @with_annotated because parameters are passed as keyword arguments." + ), + ), + ( + # Signature shape: **kwargs has no fixed names to map command-line arguments onto. + lambda a: a.kind == inspect.Parameter.VAR_KEYWORD, + lambda a: TypeError( + f"Parameter '**{a.name}' in {a.func_qualname} is variadic keyword (**kwargs), " + "which is not supported by @with_annotated because there is no native way to map " + "command-line arguments onto arbitrary keyword names." + ), + ), + ( + # A name argparse reserves on the namespace; raised as ValueError (a bad name value). + lambda a: a.name in _RESERVED_PARAM_NAMES, + lambda a: ValueError( + f"Parameter name {a.name!r} in {a.func_qualname} is reserved by argparse " + f"and cannot be used as an annotated parameter name." + ), + ), + ( + # *args (is_variadic) is always a plain positional, so Option() metadata is contradictory. + lambda a: a.is_variadic and isinstance(a.metadata, Option), + lambda a: TypeError( + f"Parameter '*{a.name}' in {a.func_qualname} uses Option() metadata, but *args is " + f"always a positional argument. Use Argument() metadata instead." + ), + ), + ( + # *args is fixed at nargs='*'; Argument(nargs=...) cannot override it. + lambda a: a.is_variadic and a._meta_nargs is not None, + lambda a: TypeError( + f"Parameter '*{a.name}' in {a.func_qualname} sets nargs={a._meta_nargs!r} via Argument(), " + f"but *args always accepts zero or more values (nargs='*') and its arity cannot be overridden." + ), + ), + ( + # For *args the annotation is the element type; a collection element would mean a tuple of + # collections + lambda a: a.is_variadic and a._var_positional_element_is_collection, + lambda a: TypeError( + f"Parameter '*{a.name}' in {a.func_qualname} is annotated with the collection type " + f"'{a._var_positional_element_display}'. For *args the annotation is the type of each " + f"value, not the collected tuple, so '*{a.name}: {a._var_positional_element_display}' " + f"would mean a tuple of '{a._var_positional_element_display}'. Annotate the element type " + f"instead (e.g. '*{a.name}: str'); values are always collected into a tuple." + ), + ), + ( + lambda a: a.is_kw_only and isinstance(a.metadata, Argument), + lambda a: TypeError( + f"Parameter '{a.name}' in {a.func_qualname} is keyword-only but uses Argument() metadata, " + f"which marks it as a positional argument. Keyword-only parameters always become options; " + f"use Option() metadata (or omit the metadata) instead." + ), + ), + ( + # const is meaningless on a positional: argparse rejects store_const/append_const there and + # ignores const with nargs='?'. A positional only ever gets its command-line value or its default. + lambda a: a._has_const and a.is_positional, + lambda a: TypeError( + f"Parameter '{a.name}' in {a.func_qualname} sets const=, but const is not supported on a " + f"positional argument (argparse ignores it). Use a default value or '{_type_name(a.inner_type)} | None' " + f"for the absent value, or use Option() to make it a flag." + ), + ), + ( + # const only goes with the const actions. Limited to *known* actions so an unsupported one + # (e.g. 'store') falls through to the "not supported" row. Class actions own const, so exempt. + lambda a: ( + isinstance(a._meta_action, str) + and a._has_const + and a._meta_action in _ACTION_TABLE + and a._meta_action not in _CONST_ACTIONS + ), + lambda a: TypeError( + f"Option(const=...) on '{a.name}' cannot be combined with action={a._meta_action!r}. " + f"const is only valid with store_const or append_const (or omit action= to infer it from the type)." + ), + ), + ( + # An explicit const action with no const value: argparse would store None on presence. + lambda a: a._meta_action in _CONST_ACTIONS and not a._has_const, + lambda a: TypeError(f"Option(action={a._meta_action!r}) on '{a.name}' needs a const value. Pass Option(const=...)."), + ), + ( + # append_const accumulates into list[T]; reject it (explicit or inferred) on a non-list type. + lambda a: a._effective_action == "append_const" and not a._is_list, + lambda a: TypeError( + f"const on '{a.name}' accumulates into list[T] (append_const), but '{_type_name(a.inner_type)}' " + f"is not a list. Use list[T] to accumulate, or a scalar type to store a single value (store_const)." + ), + ), + ( + # store_const stores a single value, so a list/collection annotation is the wrong shape. + lambda a: a._effective_action == "store_const" and a.is_collection, + lambda a: TypeError( + f"const on '{a.name}' stores a single value (store_const), but '{_type_name(a.inner_type)}' is a " + f"collection. Use a scalar type, or list[T] to accumulate the const on each flag (append_const)." + ), + ), + ( + # A store_const flag falls back to its default when absent; without a default (and not Optional) + # that absent value is None, violating the declared type. + lambda a: a._effective_action == "store_const" and not (a._effective_has_default or a.is_optional), + lambda a: TypeError( + f"store_const flag '{a.name}' has no value when absent: give it a default or annotate it as " + f"'{_type_name(a.inner_type)} | None'." + ), + ), + ( + # action='store' + const is ambiguous: 'store' ignores const unless paired with nargs='?'. + # Placed before the const-type-mismatch row so the ambiguity wins even on a mistyped const. + lambda a: a._meta_action == "store" and a._has_const, + lambda a: TypeError( + f"Option(action='store', const=...) on '{a.name}' is ambiguous: 'store' ignores const unless " + f"combined with nargs='?', so the intent cannot be inferred. Use action='store_const' for a " + f"value-less const flag, or Option(nargs='?', const=...) (no action=) for an optional value." + ), + ), + ( + # A supplied const must match the declared (element) type, keeping the parsed-value guarantee. + _const_mismatches_type, + lambda a: TypeError( + f"const={a._const_value!r} on '{a.name}' does not match the declared type '{_type_name(_const_element_type(a))}'." + ), + ), + ( + # An unknown string action (class actions pass through verbatim). Checked before the + # collection-shape row below so an unsupported action reports "not supported", not the + # misleading "cannot be combined with a collection type". + lambda a: isinstance(a._meta_action, str) and a._meta_action not in _ACTION_TABLE, + lambda a: TypeError( + f"Option(action={a._meta_action!r}) is not supported by @with_annotated. Supported actions are " + f"store_true, store_false, count, append, extend, store_const, and append_const." + ), + ), + ( + # A (known) string action on a collection type must be one of the list actions; a class action + # owns storage and is exempt. + lambda a: isinstance(a._meta_action, str) and a.is_collection and a._meta_action not in _LIST_ACTIONS, + lambda a: TypeError( + f"Option(action={a._meta_action!r}) cannot be combined with a collection type, which installs its " + f"own action. Use action='append'/'extend'/'append_const' with list[T], or drop action= to collect via nargs." + ), + ), + ( + # A user completer/choices_provider on a value-less action (store_true/false, count, + # store_const, append_const) has nothing to complete. Fail loud (raw cmd2 raises too) rather + # than drop it silently. Inferred completers (e.g. Path's) are still dropped; class actions exempt. + lambda a: a._policy is not None and a._policy.drop_converter and a._has_user_completion, + lambda a: TypeError( + f"completer=/choices_provider= on '{a.name}' cannot be used with action={a._effective_action!r}, " + f"which takes no value from the command line, so there is nothing to tab-complete. Remove the " + f"completer/choices_provider, or use a value-consuming action." + ), + ), + ( + lambda a: a._policy is not None and a._policy.requires is not None and not a._policy.requires(a), + lambda a: TypeError( + f"Option(action={a._meta_action!r}) yields {a._policy.requires_label if a._policy else ''}; " + f"annotate the parameter as {a._policy.requires_label if a._policy else ''}." + ), + ), + ( + lambda a: a._meta_action == "append" and a._meta_nargs is not None, + _const( + TypeError( + "Option(action='append') collects one value per flag and cannot set nargs; " + "use action='extend' to take multiple values per flag." + ) + ), + ), + ( + lambda a: a._meta_nargs is not None and a.fixed_arity is not None and a._meta_nargs != a.fixed_arity, + lambda a: TypeError( + f"nargs={a._meta_nargs!r} conflicts with the fixed arity of " + f"'{_type_name(a.inner_type)}' (expected nargs={a.fixed_arity})." + ), + ), + ( + # A user nargs that collects a list on a non-collection annotation. A nargs yields a list + # when it is '*'/'+'/REMAINDER, an int >= 1 (argparse returns [value] even for nargs=1), or a + # cmd2 ranged tuple other than (0, 1) + lambda a: ( + a._meta_nargs is not None + and not a.is_collection + and ( + a._meta_nargs in ("*", "+", argparse.REMAINDER) + or (isinstance(a._meta_nargs, int) and a._meta_nargs >= 1) + or (isinstance(a._meta_nargs, tuple) and tuple(a._meta_nargs) != (0, 1)) + ) + ), + lambda a: TypeError( + f"nargs={a._meta_nargs!r} produces a list of values, but the annotation " + f"'{_type_name(a.inner_type)}' is not a collection type. " + f"Use list[T], tuple[T, ...], or set[T] (optionally with | None) to match." + ), + ), + ( + # An explicit '?' / (0, 1) on a collection yields a single value (or None), which the + # collection-casting action cannot wrap into the declared list/set/tuple. + lambda a: a.is_collection and a._meta_nargs_yields_optional_single, + lambda a: TypeError( + f"parameter '{a.name}' in {a.func_qualname} sets nargs={a._meta_nargs!r} on the collection " + f"type '{_type_name(a.inner_type)}', but that yields at most a single value, which argparse " + f"does not wrap in a list -- so it cannot be cast to the declared collection. Use nargs='*' or " + f"'+' (optionally with a default or '| None'), or drop the explicit nargs." + ), + ), + ( + lambda a: a.is_positional and a.omittable and isinstance(a.nargs, int), + lambda a: TypeError( + f"A fixed-arity positional (nargs={a.nargs}) cannot be optional; argparse always " + f"requires it. Drop the default or '| None', make it an option (give it a default without " + f"Argument()), or use a variable-arity type such as tuple[T, ...]." + ), + ), + ( + # Conflict: both the signature and the metadata supplied a default. These are two + # sources of truth for the same value; refuse rather than silently pick a winner. + lambda a: a.has_default and a._has_meta_default, + lambda a: TypeError( + f"parameter '{a.name}' in {a.func_qualname} has a default in both the function signature " + f"({a.param_default!r}) and the metadata ({a._meta_default!r}); specify it in only one place." + ), + ), + ( + # argparse.SUPPRESS removes the attribute from the parsed namespace when absent, so the + # function would be called without the kwarg it expects. Reject from either source. + lambda a: a._effective_has_default and a._effective_param_default == argparse.SUPPRESS, + lambda a: TypeError( + f"parameter '{a.name}' in {a.func_qualname} uses argparse.SUPPRESS as a default, which is " + f"not supported by @with_annotated: SUPPRESS removes '{a.name}' from the parsed namespace " + f"when absent, but the function expects it as a keyword argument. Use a real default or " + f"annotate the type as '{_type_name(a.inner_type)} | None'." + ), + ), + ( + lambda a: ( + a._effective_has_default + and a._effective_param_default is None + and not a.is_optional + and a.inner_type not in (object, Any, inspect.Parameter.empty) + ), + lambda a: TypeError( + f"parameter '{a.name}' in {a.func_qualname} declared as '{_type_name(a.inner_type)}' has a " + f"default of None, but '{_type_name(a.inner_type)}' is not Optional, so omitting it would pass None " + f"and violate the type hint. Annotate it as '{_type_name(a.inner_type)} | None' to allow None, or " + f"give it a non-None default." + ), + ), + ( + # Cross-argument: a variable-arity positional must be last, else argparse cannot split + # tokens unambiguously (``def f(self, a: str, *rest: str)`` is fine -- the variadic is last). + # ``has_following_positional`` is linked by _resolve_parameters before this table runs. + lambda a: ( + a.is_positional and a.has_following_positional and (a.nargs in _VARIABLE_NARGS or isinstance(a.nargs, tuple)) + ), + lambda a: TypeError( + f"Parameter '{a.name}' in {a.func_qualname} has variable arity (nargs={a.nargs!r}) " + f"but is followed by another positional argument, so argparse cannot assign command-line " + f"tokens unambiguously. Make it the last positional, give the following positional(s) a " + f"default, or make them options." + ), + ), + ( + # base_command only: its parameters become subcommand-level options, so a positional + # conflicts with subcommand dispatch. ``is_base_command`` is set from the decorator's role; + # non-base arguments skip these rows on the first conjunct. + lambda a: a.is_base_command and a.is_positional and not isinstance(a.metadata, Argument), + lambda a: TypeError( + f"Parameter '{a.name}' in {a.func_qualname} is positional, " + f"which conflicts with subcommand parsing. " + f"Use a keyword-only parameter (after *) or give it a default value." + ), + ), + ( + lambda a: a.is_base_command and isinstance(a.metadata, Argument), + lambda a: TypeError( + f"Parameter '{a.name}' in {a.func_qualname} uses Argument() metadata, " + f"which creates a positional argument that conflicts with subcommand parsing." + ), + ), + ( + # Cross-config: a parameter assigned to two argument groups is ambiguous. The membership + # indices are linked by _resolve_parameters from the decorator's groups= before this runs. + lambda a: len(a.argument_group_indices) > 1, + lambda a: ValueError( + f"parameter {a.name!r} cannot be assigned to both argument " + f"group {a.argument_group_indices[0]} and argument group {a.argument_group_indices[1]}" + ), + ), + ( + # Cross-config: a parameter cannot belong to two mutually exclusive groups. + lambda a: len(a.mutex_group_indices) > 1, + lambda a: ValueError(f"parameter {a.name!r} cannot be assigned to multiple mutually exclusive groups"), + ), + ( + # Cross-config: a required member is incompatible with a mutex group -- only one member is + # supplied, so the others arrive as None (violating its non-Optional type), and argparse forbids + # it. This is an argument-typing rule (required-ness comes from the annotation), so it lives here + # rather than with the group graph construction. + lambda a: bool(a.mutex_group_indices) and (a.required or (a.is_positional and not a.omittable)), + lambda a: ValueError( + f"parameter {a.name!r} in mutually exclusive group {a.mutex_group_indices[0]} is required (no default " + f"and not Optional), but mutually exclusive group members must be optional because " + f"only one is supplied on the command line and the others arrive as None. " + f"Give it a default or annotate it as 'T | None'." + ), + ), + ( + # Type resolution failed during build (captured by _apply_type so build order does not pick + # the message). Raised last, so a more specific rule above wins; otherwise the raw type error. + lambda a: a.build_error is not None, + lambda a: a.build_error, + ), + (_always, _const(None)), # no violation +] + + +# --------------------------------------------------------------------------- +# Signature → Parser conversion +# --------------------------------------------------------------------------- + + +# Parameters handled specially by the decorator and not added to the parser. The first positional +# parameter (self/cls) is always skipped by position; these cover additional decorator-managed names. +_SKIP_PARAMS = frozenset({"cmd2_handler", "cmd2_statement"}) + + +def _link_group_membership( + by_name: dict[str, _ArgparseArgument], + specs: tuple[Group, ...] | None, + select: Callable[[_ArgparseArgument], list[int]], +) -> None: + """Append each spec's 1-based index to the *select*-ed membership list of each member argument. + + :func:`_resolve_parameters` validates member references via :meth:`Group._validate_members` + before calling this, so every member name resolves to a built argument. + """ + if not specs: + return + for index, spec in enumerate(specs, start=1): + for name in spec.members: + select(by_name[name]).append(index) + + +def _resolve_parameters( + func: Callable[..., Any], + *, + skip_params: frozenset[str] = _SKIP_PARAMS, + base_command: bool = False, + groups: tuple[Group, ...] | None = None, + mutually_exclusive_groups: tuple[Group, ...] | None = None, +) -> list[_ArgparseArgument]: + """Resolve a function signature into a list of argparse-argument builders. + + ``base_command`` marks each argument's context for the base-command :data:`_CONSTRAINTS` rows and + drives the function-level ``cmd2_handler`` check below. ``groups``/``mutually_exclusive_groups`` + are linked onto each argument as membership facts for the cross-config constraint rows. + """ + sig = inspect.signature(func) + # Function-level check (not a per-argument _CONSTRAINTS row): a base command dispatches through + # cmd2_handler, so it must exist. Here so it also fires when the function has zero parameters. + if base_command and "cmd2_handler" not in sig.parameters: + raise TypeError(f"with_annotated(base_command=True) requires a 'cmd2_handler' parameter in {func.__qualname__}") + try: + 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[_ArgparseArgument] = [] + + # 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 + + # *args has no default and is never keyword-only; its hint is the element type (default str). + is_variadic = param.kind == inspect.Parameter.VAR_POSITIONAL + has_default = param.default is not inspect.Parameter.empty + # Peel Annotated then Optional. For *args the annotation is the element type T, modeled as a + # variadic tuple[T, ...]; its own optionality is dropped (*args is always a possibly-empty tuple). + inner_type, metadata, is_optional = _normalize_annotation(hints.get(name, str if is_variadic else param.annotation)) + if is_variadic: + inner_type = types.GenericAlias(tuple, (inner_type, ...)) + is_optional = False + arg = _ArgparseArgument( + name=name, + func_qualname=func.__qualname__, + has_default=has_default, + param_default=param.default if has_default else None, + is_kw_only=param.kind == inspect.Parameter.KEYWORD_ONLY, + is_variadic=is_variadic, + inner_type=inner_type, + metadata=metadata, + is_optional=is_optional, + kind=param.kind, + is_base_command=base_command, + ) + resolved.append(arg) + + # Validate the whole list at once (per-argument + cross-argument rules) now that every + # argument is built and its cross-argument facts can be linked. + positionals = [arg for arg in resolved if arg.is_positional] + for arg in positionals[:-1]: # every positional except the last has a following positional + arg.has_following_positional = True + by_name = {arg.name: arg for arg in resolved} + # Reject group references to nonexistent parameters before the constraint table runs. + all_param_names = set(by_name) + for spec in groups or (): + spec._validate_members(all_param_names=all_param_names, group_type="groups") + for spec in mutually_exclusive_groups or (): + spec._validate_members(all_param_names=all_param_names, group_type="mutually_exclusive_groups") + _link_group_membership(by_name, groups, lambda a: a.argument_group_indices) + _link_group_membership(by_name, mutually_exclusive_groups, lambda a: a.mutex_group_indices) + for arg in resolved: + arg._check_constraints() + return resolved + + +def _var_positional_call_plan(func: Callable[..., Any]) -> tuple[list[str], str | None]: + """Return ``(leading_positional_names, var_positional_name)`` for unpacking ``*args``. + + ``leading_positional_names`` are the positional-or-keyword parameters that + precede ``*args`` (they must be passed positionally, in order, so ``*args`` + can follow). ``var_positional_name`` is the ``*args`` parameter name, or + ``None`` when the function has no ``*args``. + """ + params = list(inspect.signature(func).parameters.values())[1:] # skip self/cls + leading: list[str] = [] + for param in params: + if param.kind is inspect.Parameter.VAR_POSITIONAL: + return leading, param.name + if param.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD: + leading.append(param.name) + return leading, None + + +def _invoke_command_func( + func: Callable[..., Any], + self_arg: Any, + func_kwargs: dict[str, Any], + *, + leading_names: list[str], + var_positional_name: str | None, +) -> Any: + """Call *func* from parsed kwargs, unpacking ``*args`` positionally when present.""" + if var_positional_name is None: + return func(self_arg, **func_kwargs) + positional = [func_kwargs.pop(name) for name in leading_names] + var_values = func_kwargs.pop(var_positional_name) + return func(self_arg, *positional, *var_values, **func_kwargs) + + +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 _build_argument_group_targets( + parser: argparse.ArgumentParser, + *, + groups: tuple[Group, ...] | None, +) -> tuple[dict[str, _ArgumentTarget], dict[str, argparse._ArgumentGroup]]: + """Build argument groups and return add_argument targets for their members. + + Member references and double-assignment are validated upstream by :func:`_resolve_parameters` + (via :meth:`Group._validate_members`) and :data:`_CONSTRAINTS` (the ``argument_group_indices`` + fact), so construction can assign each member unconditionally. + """ + target_for: dict[str, _ArgumentTarget] = {} + argument_group_for: dict[str, argparse._ArgumentGroup] = {} + + if not groups: + return target_for, argument_group_for + + for spec in groups: + if spec.required: + raise ValueError( + "Group(required=True) is only valid in mutually_exclusive_groups; " + "argparse's add_argument_group has no 'required' flag" + ) + group = parser.add_argument_group(title=spec.title, description=spec.description) + for name in spec.members: + argument_group_for[name] = group + 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[Group, ...] | None, +) -> None: + """Build mutually exclusive groups and update add_argument targets for their members. + + Member references, double-assignment, and required-member rejections are validated upstream by + :func:`_resolve_parameters` and :data:`_CONSTRAINTS` (the ``mutex_group_indices`` fact); the + remaining check -- a mutex group spanning different argument groups -- stays here because its + subject is the group, not an argument. + """ + if not mutually_exclusive_groups: + return + + for index, spec in enumerate(mutually_exclusive_groups, start=1): + member_names = spec.members + + 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(required=spec.required) + for name in member_names: + target_for[name] = mutex_group + + +def _docstring_first_paragraph(doc: str | None) -> str | None: + """Return the first paragraph of *doc* (everything before the first blank line), or ``None``. + + Used to auto-fill ``description`` from ``func.__doc__`` when the caller didn't pass one. + Subsequent paragraphs are intentionally ignored: rst field directives (``:param:``, ``:return:``) + routinely live below the summary and would render as nonsense in ``--help``. + """ + if not doc: + return None + cleaned = inspect.cleandoc(doc).strip() + if not cleaned: + return None + # Stop at the first blank line OR the first rst field directive (``:param:``, ``:return:``, ...); + # a directive that immediately follows the summary with no blank line would otherwise leak into --help. + summary_lines: list[str] = [] + for line in cleaned.splitlines(): + if not line.strip() or line.lstrip().startswith(":"): + break + summary_lines.append(line) + return "\n".join(summary_lines).strip() or None + + +def build_parser_from_function( + func: Callable[..., Any], + *, + skip_params: frozenset[str] = _SKIP_PARAMS, + groups: tuple[Group, ...] | None = None, + mutually_exclusive_groups: tuple[Group, ...] | None = None, + parser_class: type[Cmd2ArgumentParser] | None = None, + **parser_kwargs: Unpack[Cmd2ParserKwargs], +) -> Cmd2ArgumentParser: + """Inspect a function's signature and build a ``Cmd2ArgumentParser``. + + The lower-level entry point behind :func:`with_annotated`. ``parser_kwargs`` is forwarded to + the parser ctor (see :class:`Cmd2ParserKwargs`); when ``description`` is omitted, the first + paragraph of ``func.__doc__`` is used. + + :param func: the command function to inspect + :param skip_params: parameter names to exclude from the parser + :param groups: :class:`Group` instances assigning parameters to argument groups + :param mutually_exclusive_groups: :class:`Group` instances of mutually exclusive parameters + :param parser_class: custom parser class (defaults to the configured default) + :param parser_kwargs: forwarded :class:`Cmd2ParserKwargs` + :return: a fully configured ``Cmd2ArgumentParser`` + """ + parser_cls = parser_class or DEFAULT_ARGUMENT_PARSER + if "description" not in parser_kwargs: + auto_description = _docstring_first_paragraph(func.__doc__) + if auto_description is not None: + parser_kwargs["description"] = auto_description + parser = parser_cls(**parser_kwargs) + + # _resolve_parameters validates each argument and the cross-argument/cross-config rules (e.g. a + # variable-arity positional must be last; double-assignment and required-mutex-member) once the + # whole list is built and the group memberships are linked. + resolved = _resolve_parameters( + func, + skip_params=skip_params, + groups=groups, + mutually_exclusive_groups=mutually_exclusive_groups, + ) + + # ``argument_default=argparse.SUPPRESS`` removes an absent argument from the parsed namespace. + # That is safe only for arguments that are always supplied (required) or carry their own default; + # an *omittable* argument with no default (e.g. a ``T | None`` positional -> nargs='?') would be + # dropped when absent, leaving the function without a keyword argument it expects. ``*args`` is + # exempt: the invocation path substitutes an empty tuple for it. Reject the combination here, + # mirroring the per-argument ``default=argparse.SUPPRESS`` rejection. + if parser_kwargs.get("argument_default") is argparse.SUPPRESS: + dropped = [ + arg.name + for arg in resolved + if arg.default is _UNSET and arg.omittable and not arg.required and not arg.is_variadic + ] + if dropped: + raise TypeError( + f"argument_default=argparse.SUPPRESS is not supported by @with_annotated for {func.__qualname__}: " + f"it would drop {dropped!r} from the parsed namespace when absent, but the function expects " + f"{'them' if len(dropped) > 1 else 'it'} as a keyword argument. Give each an explicit default or " + f"make it required, or drop argument_default=argparse.SUPPRESS." + ) + + # Build the group lookup (member references already validated by _resolve_parameters). + target_for, argument_group_for = _build_argument_group_targets(parser, groups=groups) + _apply_mutex_group_targets( + parser, + target_for=target_for, + argument_group_for=argument_group_for, + mutually_exclusive_groups=mutually_exclusive_groups, + ) + + # Add each argument to its target (its group/mutex group if assigned, else the parser). + for arg in resolved: + arg.add_to(target_for.get(arg.name, parser)) + + return parser + + +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) :] + + +@dataclass(frozen=True) +class _ParserBuildOptions: + """The parser/subcommand configuration shared by every ``with_annotated`` build path. + + These options are a data clump threaded identically through the regular-command and subcommand + builders; bundling them lets :func:`_make_parser_builder` host the single deferred build flow. + """ + + groups: tuple[Group, ...] | None = None + mutually_exclusive_groups: tuple[Group, ...] | None = None + parser_class: type[Cmd2ArgumentParser] | None = None + #: Forwarded :class:`Cmd2ParserKwargs` (description, epilog, prog, ...). + #: Stored as a plain ``dict`` so missing keys yield argparse's defaults + #: rather than this layer second-guessing them. + parser_kwargs: dict[str, Any] = field(default_factory=dict) + subcommand_required: bool = True + subcommand_metavar: str = "SUBCOMMAND" + subcommand_title: str | None = None + subcommand_description: str | None = None + + +def _make_parser_builder( + func: Callable[..., Any], + *, + skip_params: frozenset[str], + base_command: bool, + options: _ParserBuildOptions, +) -> Callable[[], Cmd2ArgumentParser]: + """Return the deferred builder for *func*'s parser (adds the subcommands group when ``base_command``). + + Shared by the regular-command and subcommand decorators so the build flow lives in one place. + """ + + def parser_builder() -> Cmd2ArgumentParser: + parser = build_parser_from_function( + func, + skip_params=skip_params, + groups=options.groups, + mutually_exclusive_groups=options.mutually_exclusive_groups, + parser_class=options.parser_class, + **options.parser_kwargs, + ) + if base_command: + # dict[str, Any] is load-bearing: the typeshed stub types title/metavar as non-None, + # but argparse accepts None at runtime, so splatting avoids a false overload error. + kwargs: dict[str, Any] = { + "dest": "subcommand", + "metavar": options.subcommand_metavar, + "required": options.subcommand_required, + "title": options.subcommand_title, + "description": options.subcommand_description, + } + parser.add_subparsers(**kwargs) + return parser + + return parser_builder + + +def _build_subcommand_handler( + func: Callable[..., Any], + subcommand_to: str, + *, + base_command: bool = False, + options: _ParserBuildOptions, +) -> tuple[Callable[..., Any], str, Callable[[], Cmd2ArgumentParser]]: + """Build a subcommand's parser and a handler that unpacks the Namespace into typed kwargs. + + :param func: the subcommand handler function + :param subcommand_to: parent command name (space-delimited for nesting) + :param base_command: if True, the parser also gets ``add_subparsers()`` + :param options: shared parser/subcommand configuration + :return: ``(handler, subcommand_name, parser_builder)`` + """ + subcmd_name = _derive_subcommand_name(func, subcommand_to) + + if base_command: + # Validate eagerly (decoration time); the base-command rows in _CONSTRAINTS fire here. + _resolve_parameters(func, base_command=True) + + _accepted = set(list(inspect.signature(func).parameters.keys())[1:]) + _leading_names, _var_positional_name = _var_positional_call_plan(func) + + @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 _invoke_command_func( + func, self_arg, filtered, leading_names=_leading_names, var_positional_name=_var_positional_name + ) + + parser_builder = _make_parser_builder(func, skip_params=_SKIP_PARAMS, base_command=base_command, options=options) + 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] = ..., + deprecated: bool = ..., + groups: tuple[Group, ...] | None = ..., + mutually_exclusive_groups: tuple[Group, ...] | None = ..., + parser_class: type[Cmd2ArgumentParser] | None = ..., + subcommand_required: bool = ..., + subcommand_metavar: str = ..., + subcommand_title: str | None = ..., + subcommand_description: str | None = ..., + **parser_kwargs: Unpack[Cmd2ParserKwargs], +) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ... + + +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] = (), + deprecated: bool = False, + groups: tuple[Group, ...] | None = None, + mutually_exclusive_groups: tuple[Group, ...] | None = None, + parser_class: type[Cmd2ArgumentParser] | None = None, + subcommand_required: bool = True, + subcommand_metavar: str = "SUBCOMMAND", + subcommand_title: str | None = None, + subcommand_description: str | None = None, + **parser_kwargs: Unpack[Cmd2ParserKwargs], +) -> Callable[..., Any] | Callable[[Callable[..., Any]], Callable[..., Any]]: + """Decorate a ``do_*`` method to build its argparse parser from type annotations. + + :param func: the command function (when used without parentheses) + :param ns_provider: callable returning a prepopulated Namespace (not with ``subcommand_to``) + :param preserve_quotes: preserve quotes in arguments (not with ``subcommand_to``) + :param with_unknown_args: capture unknown args as the ``_unknown`` kwarg (not with ``subcommand_to``) + :param base_command: add ``add_subparsers()``; requires a ``cmd2_handler`` param and no positionals + :param subcommand_to: parent command name; function must be named ``{parent_underscored}_{subcommand}`` + :param help: subcommand help text (only with ``subcommand_to``) + :param aliases: alternative subcommand names (only with ``subcommand_to``) + :param deprecated: mark the subcommand deprecated in ``--help`` (only with ``subcommand_to``) + :param groups: :class:`Group` instances assigning parameters to titled argument groups + :param mutually_exclusive_groups: :class:`Group` instances of mutually exclusive parameters + :param parser_class: custom parser class (defaults to the configured default) + :param subcommand_required: whether a subcommand must be supplied (``base_command`` only) + :param subcommand_metavar: metavar for the subcommands group (``base_command`` only) + :param subcommand_title: title for the subcommands ``--help`` section (``base_command`` only) + :param subcommand_description: description for that section (``base_command`` only) + :param parser_kwargs: any :class:`~cmd2.Cmd2ArgumentParser` ctor kwarg (see :class:`Cmd2ParserKwargs`). + ``description`` defaults to the docstring's first paragraph when omitted; + ``prog`` is rejected with ``subcommand_to`` (cmd2 rewrites it from the parent). + """ + if aliases is None: + raise TypeError( + "aliases must be a sequence of subcommand-name strings (e.g. ('co', 'ci')), not None; " + "omit it or pass an empty tuple for no aliases." + ) + if (help is not None or aliases or deprecated) and subcommand_to is None: + raise TypeError("'help', 'aliases', and 'deprecated' are only valid with subcommand_to") + if subcommand_to is not None: + 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 "prog" in parser_kwargs: + # cmd2's subcommand machinery (``update_prog``) rewrites prog from the parent + # command hierarchy, so any value supplied here would be silently overwritten. + unsupported.append("prog") + if unsupported: + names = ", ".join(unsupported) + raise TypeError( + f"{names} {'is' if len(unsupported) == 1 else 'are'} not supported with subcommand_to. " + "Configure these behaviors on the base command instead." + ) + + options = _ParserBuildOptions( + groups=groups, + mutually_exclusive_groups=mutually_exclusive_groups, + parser_class=parser_class, + parser_kwargs=dict(parser_kwargs), + subcommand_required=subcommand_required, + subcommand_metavar=subcommand_metavar, + subcommand_title=subcommand_title, + subcommand_description=subcommand_description, + ) + + def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: + if with_unknown_args: + unknown_param = inspect.signature(fn).parameters.get("_unknown") + 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 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, + subcommand_to, + base_command=base_command, + options=options, + ) + spec = SubcommandSpec( + name=subcmd_name, + command=subcommand_to, + help=help, + aliases=tuple(aliases), + deprecated=deprecated, + parser_source=subcmd_parser_builder, + ) + setattr(handler, constants.SUBCMD_ATTR_SPEC, spec) + 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 eagerly (decoration time); the base-command rows in _CONSTRAINTS fire here. + _resolve_parameters(fn, skip_params=skip_params, base_command=True) + + # Cache signature introspection at decoration time, not per-invocation + accepted = set(list(inspect.signature(fn).parameters.keys())[1:]) + leading_names, var_positional_name = _var_positional_call_plan(fn) + + parser_builder = _make_parser_builder(fn, skip_params=skip_params, base_command=base_command, options=options) + + @functools.wraps(fn) + def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: + 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 = _invoke_command_func( + fn, owner, func_kwargs, leading_names=leading_names, var_positional_name=var_positional_name + ) + return result + + setattr(cmd_wrapper, constants.CMD_ATTR_PARSER_SOURCE, parser_builder) + 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/annotated.md b/docs/features/annotated.md new file mode 100644 index 000000000..04c7e8472 --- /dev/null +++ b/docs/features/annotated.md @@ -0,0 +1,445 @@ +# 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.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.annotated 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 | +| `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]` 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 +- `Annotated[T, Argument(nargs=N)]` where `N` is `'*'`, `'+'`, or an integer `>= 1` and `T` is not a + collection type. `nargs` values that produce a list of values need a collection annotation such as + `list[T]` or `tuple[T, ...]`. +- an optional fixed-arity positional, such as `Annotated[tuple[int, int], Argument()] = (1, 2)`, + `Annotated[tuple[int, int] | None, Argument()]`, or any positional `Argument(nargs=N)` with a + default or `| None`. argparse cannot make a fixed-arity positional optional (there is no `nargs` + for "absent or exactly `N` tokens"), so use a variable-arity type like `tuple[T, ...]`, drop the + default, or make it an option (give it a default without `Argument()`). +- `Annotated[tuple[T, T], Argument(nargs=N)]` where `N` differs from the number of elements declared + by the tuple. The tuple type already pins `nargs`; user metadata cannot change it. + +The parameter names `dest` and `subcommand` are reserved and may not be used as annotated parameter +names. `cmd2_statement` receives the parsed [cmd2.Statement][] object, and `cmd2_handler` (only on a +command decorated with `@with_annotated(base_command=True)`) receives the subcommand handler. + +## Annotated metadata + +For finer control, use `typing.Annotated` with [Argument][cmd2.annotated.Argument] or +[Option][cmd2.annotated.Option] metadata: + +```py +from typing import Annotated +from cmd2.annotated 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 a zero-argument argparse action that takes no value from the +command line (`count`, `store_true`, `store_false`, `store_const`, `append_const`), +`@with_annotated` removes the value-oriented metadata inferred from the type before calling +`add_argument()`: the `type` converter, the static `choices`, and any inferred tab-completer (such +as the path completer for `Path`) or `choices_provider`. This matches argparse behavior (which +rejects a completer on a value-less action) and avoids parser-construction errors such as combining +`action='count'` with `type=int`. Actions that do consume values (`append` / `extend` on a +`list[T]`, or a plain value option) keep the inferred converter and completer. `action='help'` and +`action='version'` are not supported. + +Pairing `const` with an explicit `nargs` on a scalar `Option` selects argparse's optional-value +idiom instead of `store_const`. `Annotated[str | None, Option("--log", nargs='?', const="CONSOLE")]` +keeps the `store` action and the inferred `type` converter, so the flag is three-way: absent yields +the default, a bare `--log` yields the `const`, and `--log VALUE` yields the converted `VALUE`. The +`const` is stored verbatim (it is not run through the converter), so it must already match the +declared type. Without an explicit `nargs`, `const` alone still infers the value-less `store_const` +(present yields the `const`, and supplying a value is an error). + +`Option(action=...)` also accepts a custom `argparse.Action` subclass. The class is passed straight +through to `add_argument()` and owns storage of the parsed value, so the type-inferred collection +casting and the action-specific type/const/shape constraints are skipped; the inferred `type=` +converter, default, and `required` are still applied so the class receives them like any hand-built +`add_argument()` call. + +```py +class UpperAction(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + setattr(namespace, self.dest, values.upper()) + +@with_annotated +def do_shout(self, name: Annotated[str, Option("--name", action=UpperAction)] = ""): + self.poutput(name) +``` + +`action='help'` and `action='version'` are not supported by `@with_annotated`; use `@with_argparser` +if you need them. + +`Argument()` and `Option()` refuse a handful of `add_argument()` kwargs that the decorator derives +from the function signature itself, so misusing them surfaces as a clear `TypeError` instead of a +silent override. The refused kwargs are: + +- `type` -- comes from the parameter annotation +- `dest` -- comes from the parameter name +- `action` and `required` on `Argument` -- only `Option` accepts them; positional arguments have no + action and are required unless they carry a default or `| None` + +Every other `add_argument()` parameter passes through, including any custom parameter registered via +[register_argparse_argument_parameter][cmd2.argparse_utils.register_argparse_argument_parameter]. + +A `default` may be supplied either through the function signature or as a metadata kwarg. The two +forms are equivalent: + +```py +# Signature default +def do_x(self, name: Annotated[str, Option("--name")] = "HI"): ... + +# Metadata default (same behaviour) +def do_x(self, name: Annotated[str, Option("--name", default="HI")]): ... +``` + +Specifying both at the same time is a conflict and raises `TypeError`. `argparse.SUPPRESS` is +rejected as a default from either source, because suppressing the namespace attribute would call the +function without the keyword argument it expects. + +Parser-construction kwargs such as `add_help`, `prefix_chars`, `fromfile_prefix_chars`, +`argument_default`, `conflict_handler`, and `allow_abbrev` are not exposed by `@with_annotated`. Set +them on a custom `parser_class` subclass and pass it via `parser_class=`. + +When a user-supplied `choices_provider` or `completer` is given for an inferred `Enum` or `Literal`, +the inferred static `choices` list is dropped so completion is driven by the provider or completer. +The inferred `type` converter is preserved, so parsed values still coerce to the declared type +(`Literal[1, 2]` yields an `int`, an `Enum` yields its member) and values outside the type are +rejected at parse time. + +An explicit `choices=` is reconciled with the inferred type rather than fighting it: + +- The values are run through the inferred `type` converter so they match argparse's post-conversion + comparison. `Annotated[int, Option("--n", choices=["1", "2"])]` is normalized to `choices=[1, 2]`, + so `--n 1` is accepted. A choice the converter rejects (`choices=["1", "nope"]` on an `int`) is a + build-time `TypeError`. Values already of the declared type are left as-is. +- An explicit `choices=` takes precedence over a _type-inferred_ completer (such as the `Path` + completer): the choices are kept (so they validate and drive completion) and the inferred + completer is dropped. A `choices_provider`/`completer` you pass yourself still wins over + `choices=`. + +An `Enum` parameter accepts both member **values** and member **names** on the command line +(`Color.RED` with value `"red"` is selected by either `red` or `RED`); tab-completion and `--help` +list the values. + +## Decorator options + +`@with_annotated` currently supports: + +- `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`. +- `subcommand_required` -- whether a subcommand must be supplied (only with `base_command=True`, + default `True`) +- `subcommand_metavar` -- metavar shown for the subcommands group (only with `base_command=True`, + default `"SUBCOMMAND"`) +- `subcommand_title` -- title for the subcommands `--help` section (only with `base_command=True`) +- `subcommand_description` -- description for the subcommands `--help` section (only with + `base_command=True`) +- `help` -- help text for an annotated subcommand (only valid with `subcommand_to`) +- `aliases` -- aliases for an annotated subcommand (only valid with `subcommand_to`) +- `deprecated` -- mark the subcommand as deprecated in `--help` (only valid with `subcommand_to`) +- `groups` -- `Group` instances assigning parameter names to argument groups +- `mutually_exclusive_groups` -- `Group` instances of mutually exclusive parameters +- `parser_class` -- a custom parser class (defaults to the configured default) +- `**parser_kwargs` -- every other parser-construction kwarg accepted by `Cmd2ArgumentParser` is + forwarded through PEP 692 + [`Unpack`][typing.Unpack][`[Cmd2ParserKwargs]`][cmd2.annotated.Cmd2ParserKwargs]: `description`, + `epilog`, `prog`, `usage`, `parents`, `argument_default`, `prefix_chars`, `fromfile_prefix_chars`, + `conflict_handler`, `add_help`, `allow_abbrev`, `exit_on_error`, `formatter_class`, + `ap_completer_type`, and on Python ≥ 3.14 `suggest_on_error` / `color`. Two behaviors layer on + top of the raw passthrough: + - `description` -- when omitted, the first paragraph of the function's docstring (up to the + first blank line) is used; pass an explicit value to override. + - `prog` -- rejected when `subcommand_to` is set; cmd2's subcommand machinery rewrites `prog` + from the parent command hierarchy and any value here would be silently overwritten. + +```py +@with_annotated(with_unknown_args=True) +def do_rawish(self, name: str, _unknown: list[str] | None = None): + self.poutput((name, _unknown)) +``` + +## Parser customization + +Every `Cmd2ArgumentParser` constructor kwarg flows straight through `@with_annotated` and +`build_parser_from_function` via PEP 692 +[`Unpack[Cmd2ParserKwargs]`][cmd2.annotated.Cmd2ParserKwargs]. The +[`Cmd2ParserKwargs`][cmd2.annotated.Cmd2ParserKwargs] `TypedDict` is the single source of truth for +the forwarded kwargs and gives type-checkers/IDEs autocomplete on the decorator's call site: adding +a new ctor kwarg to `Cmd2ArgumentParser` only needs a matching field on `Cmd2ParserKwargs`, and the +annotated decorator picks it up automatically. + +`parser_class` stays as its own explicit kwarg because it selects the class itself rather than a +value passed to it. Argument groups are declared with [Group][cmd2.annotated.Group]; pass `title` +and `description` for a titled help section (omit them for an untitled group): + +```py +from cmd2.annotated import Group, with_annotated + +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}") +``` + +If you omit `description`, the first paragraph of the function's docstring (everything up to the +first blank line) is used as the parser description; subsequent paragraphs are dropped so rst field +directives like `:param name:` don't leak into `--help`. Pass `description=""` to suppress the +automatic fill, or `description="..."` to override it. + +```py +@with_annotated +def do_greet(self, name: str): + """Greet someone by name. + + :param name: who to greet + """ + self.poutput(f"hello {name}") +# parser.description == "Greet someone by name." +``` + +`mutually_exclusive_groups` also takes `Group` instances (their `title`/`description` are ignored, +since argparse mutually-exclusive groups have no header). Pass `Group(..., required=True)` to make +the mutex group itself required -- argparse will then enforce that exactly one of its members must +be supplied. `required=True` is rejected on a plain (non-mutex) `Group` because `add_argument_group` +has no `required` flag. + +```py +@with_annotated( + mutually_exclusive_groups=(Group("verbose", "quiet", required=True),), +) +def do_run(self, verbose: bool = False, quiet: bool = False): ... +``` + +`parents=` mirrors argparse's standard parents mechanism for sharing argument definitions across +parsers. `argument_default=argparse.SUPPRESS` is accepted only when no argument could be stranded by +it: it removes an absent argument from the parsed namespace, which is safe for an argument that is +always supplied (a required option, a mandatory positional) or that carries its own default, but not +for an _omittable_ argument with no default (for example a `T | None` positional, which becomes +`nargs='?'`). If any such argument is present, `@with_annotated` raises `TypeError` rather than let +the function be called missing a keyword argument it expects (mirroring the per-argument +`default=argparse.SUPPRESS` rejection). `*args` is exempt, since the invocation path substitutes an +empty tuple for it. + +The remaining argparse kwargs cover less-common needs but are wired through unchanged: + +- `prefix_chars="+-"` accepts options that start with `+` (e.g. `+verbose`); pair with an explicit + `Option("+verbose")` to declare such flags. +- `fromfile_prefix_chars="@"` lets a user write `mycmd @args.txt` and have the file's contents + spliced in as arguments. +- `conflict_handler="resolve"` lets a parent parser's option be redefined locally without an error + -- useful with `parents=` when you want to override an inherited flag. +- `add_help=False` drops the auto-added `-h`/`--help` action (cmd2's standard parser keeps it on by + default). +- `allow_abbrev=False` requires users to type the full long-option name (no `--verb` for + `--verbose`). +- `exit_on_error=False` makes parse failures raise `argparse.ArgumentError` instead of calling + `sys.exit`; useful when embedding the parser inside another flow. + +## Annotated subcommands + +`@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 + +[cmd2.annotated.build_parser_from_function][cmd2.annotated.build_parser_from_function] builds the +parser directly from a function without registering a command. It accepts the same `groups`, +`mutually_exclusive_groups`, `parser_class`, and forwarded +[`Unpack[Cmd2ParserKwargs]`][cmd2.annotated.Cmd2ParserKwargs] as `@with_annotated`. Like the +decorator, it skips the first parameter as the method receiver (`self`/`cls`). + +```py +from cmd2.annotated import build_parser_from_function + +def greet(self, name: str, count: int = 1): + """Greet someone.""" + +parser = build_parser_from_function(greet) +namespace = parser.parse_args(["Alice", "--count", "3"]) +# namespace.name == "Alice", namespace.count == 3 +``` + +## Automatic completion from types + +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 a0a577380..d920de550 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.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.annotated.with_annotated][] - [cmd2.decorators.with_argument_list][] All of these decorators accept an optional **preserve_quotes** argument which defaults to `False`. @@ -52,6 +55,17 @@ 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 + +!!! warning "Experimental" + + The `@with_annotated` decorator is **experimental** and its API may change in future releases. + +The [@with_annotated][cmd2.with_annotated] decorator builds an argparse parser automatically from +the decorated function's type annotations -- no manual `add_argument()` calls required. See +[Annotated Argument Processing](annotated.md) for the full reference, including type mapping, +metadata classes, subcommands, and stability caveats. + ## Argument Parsing 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..7322f20f5 --- /dev/null +++ b/examples/annotated_example.py @@ -0,0 +1,544 @@ +#!/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, as does argparse's +optional-value idiom (``nargs='?'`` with ``const``). + +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 collections.abc import Callable +from decimal import Decimal +from enum import StrEnum +from pathlib import Path +from typing import ( + Annotated, + Any, + Literal, +) + +import cmd2 +from cmd2 import ( + Choices, + Cmd, + CompletionItem, +) +from cmd2.annotated import ( + Argument, + Group, + Option, + with_annotated, +) + + +class Color(StrEnum): + red = "red" + green = "green" + blue = "blue" + yellow = "yellow" + + +class LogLevel(StrEnum): + debug = "debug" + info = "info" + warning = "warning" + 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" + + +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 + typed parameters ----------------------------------- + # With @with_argparser you'd set type=int and action='store_true', then read + # args.a / args.verbose off a Namespace. Here the types are inferred from the + # annotations and each parameter arrives as an ordinary typed local variable. + + @with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_add(self, a: int, b: int = 0, verbose: bool = False) -> None: + """Add two integers. Types are inferred; parameters are typed locals. + + ``a``/``b`` infer ``type=int`` and ``verbose: bool`` infers a flag -- and + each is a normal typed argument, not a ``Namespace`` attribute to unpack. + + Examples: + add 2 --b 3 + 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. + + @with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_paint( + self, + item: str, + 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. + + 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. + + @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. + + @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)) + + # -- Count action (-vvv) ------------------------------------------------- + # action='count' turns a flag into a repeatable counter: each occurrence + # adds one, so ``-vvv`` arrives as 3. Set explicitly via Option(action=). + + @with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_log( + self, + message: str, + verbosity: Annotated[int, Option("-v", "--verbose", action="count", help_text="raise verbosity; repeatable")] = 0, + ) -> None: + """Log a message. Repeat ``-v`` to raise verbosity (``-vvv`` -> 3). + + Try: + log hello + log hello -vvv + """ + self.poutput(f"[v={verbosity}] {message}") + + # -- List arguments ------------------------------------------------------ + # With @with_argparser you'd set type=float and nargs='+'. + # Here list[float] does both at once. + + @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)}") + + # -- Variadic positional (*args) ----------------------------------------- + # ``*args: T`` becomes a variadic positional (nargs='*') collected into a + # tuple -- zero or more values. A keyword-only option after ``*args`` stays + # an ordinary ``--flag``. + + @with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_cat(self, *files: str, number: bool = False) -> None: + """Concatenate file names. ``*args`` accepts zero or more values. + + Try: + cat a.txt b.txt c.txt + cat a.txt b.txt --number + cat + """ + if not files: + self.poutput("(no files)") + for index, name in enumerate(files, start=1): + self.poutput(f"{index}: {name}" if number else name) + + # -- Optional positional (T | None) -------------------------------------- + # A scalar annotated ``T | None`` becomes an optional positional (nargs='?'): + # zero or one value, defaulting to None when omitted. A very common CLI shape. + + @with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_status(self, service: str | None) -> None: + """Show status for one service, or for all when the positional is omitted. + + Try: + status + status web + """ + self.poutput(f"status: {service or 'all services'}") + + # -- Ranged nargs (cmd2 extension) --------------------------------------- + # cmd2's patched argparse accepts a (min, max) nargs tuple. ``nargs=(2, 4)`` + # takes 2 to 4 values; fewer or more is rejected. Plain argparse cannot do this. + + @with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_plot(self, points: Annotated[list[int], Argument(nargs=(2, 4))]) -> None: + """Plot 2 to 4 integer points. cmd2 allows a ``(min, max)`` nargs range. + + Try: + plot 1 2 + plot 1 2 3 4 + plot 1 # rejected: needs at least 2 + """ + self.poutput(f"plotting {len(points)} points: {points}") + + # -- Literal + Decimal --------------------------------------------------- + # Literal values become validated choices. Decimal values preserve precision. + + @with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_deploy( + self, + 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. + + Try: + deploy api --mode + deploy api --mode fast --budget 2.75 + """ + self.poutput(f"Deploying {service} in {mode} mode with budget {budget} and timeout {timeout}") + + # -- Optional value with const (nargs='?') + completion ------------------ + # A scalar Option with nargs='?' + const is argparse's optional-value idiom: + # flag absent -> default, bare flag -> const, ``flag VALUE`` -> converted VALUE. + # A completion provider tab-completes that optional value -- because the + # option still consumes a value, a completer/choices_provider is kept (it is + # only rejected on value-less actions like store_true). The provider suggests + # common sizes without restricting input: ``--size 999`` is still accepted. + + def common_sizes(self) -> Choices: + """choices_provider suggesting common cache sizes (suggestions only, not a constraint).""" + return Choices.from_values(["32", "64", "128", "256", "512"]) + + @with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_cache( + self, + name: str, + size: Annotated[ + int, + Option("--size", nargs="?", const=64, choices_provider=common_sizes, help_text="cache size in MB"), + ] = 0, + ) -> None: + """Configure caching. ``--size`` takes an optional value and tab-completes it. + + ``--size`` absent -> 0; bare ``--size`` -> 64 (the const); ``--size 256`` + -> 256 (the supplied value, converted to int). + + Try: + cache build + cache build --size + cache build --size # suggests 32 64 128 256 512 + cache build --size 256 + """ + self.poutput(f"{name}: cache size = {size} MB") + + # -- 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"]) + + @with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_score( + self, + sport: Annotated[ + str, + Argument( + choices_provider=sport_choices, + help_text="Sport to score", + ), + ], + play: Annotated[ + str, + 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)") + + # -- Advanced: explicit completer ---------------------------------------- + # A completer wires a completion function onto an argument directly. Unlike + # the Path type (which auto-completes), here a plain ``str`` gets filesystem + # completion only because ``completer=Cmd.path_complete`` asks for it. + + @with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_load( + self, + config: Annotated[str, Argument(completer=Cmd.path_complete, help_text="config file to load")], + ) -> None: + """Load a config. ``completer=`` attaches a completer to a ``str`` arg. + + Try: + load ./ + """ + self.poutput(f"Loading config from {config}") + + # -- Advanced: table_columns --------------------------------------------- + # A choices_provider can return CompletionItems carrying extra data, and + # table_columns names the columns shown alongside each completion. + + def package_choices(self) -> Choices: + """choices_provider returning CompletionItems with a description column.""" + return Choices( + items=[ + CompletionItem("numpy", table_data=["numerical computing"]), + CompletionItem("rich", table_data=["terminal formatting"]), + CompletionItem("cmd2", table_data=["interactive CLIs"]), + ] + ) + + @with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_install( + self, + package: Annotated[ + str, + Argument( + choices_provider=package_choices, + table_columns=["Description"], + help_text="package to install", + ), + ], + ) -> None: + """Install a package. ``table_columns`` adds context columns to completions. + + Try: + install + """ + self.poutput(f"Installing {package}") + + # -- Namespace provider -------------------------------------------------- + # This mirrors one of @with_argparser's advanced features. + + def default_namespace(self) -> Namespace: + return Namespace(region=self._default_region) + + @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 -------------------------------------------------------- + + @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. + + @with_annotated(base_command=True) + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_manage(self, verbose: bool = False, *, cmd2_handler: Callable[[], Any] | None = None) -> None: + """Base command for annotated subcommands. + + Try: + help manage + manage project add demo + """ + if verbose: + self.poutput("verbose mode") + if cmd2_handler: + cmd2_handler() + + @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() + + @with_annotated(subcommand_to="manage project", help="add a project") + def manage_project_add(self, name: str) -> None: + self.poutput(f"project added: {name}") + + @with_annotated(subcommand_to="manage project", help="list projects") + def manage_project_list(self) -> None: + self.poutput("project list: demo") + + # -- Parser customization ------------------------------------------------ + # 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.", + 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) + + # -- 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. + + @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) + @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/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 diff --git a/tests/test_annotated.py b/tests/test_annotated.py new file mode 100644 index 000000000..449d0e1ef --- /dev/null +++ b/tests/test_annotated.py @@ -0,0 +1,3472 @@ +"""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 datetime +import decimal +import enum +import inspect +import types +import uuid +from pathlib import Path +from typing import ( + Annotated, + Any, + Literal, + Optional, +) + +import pytest + +import cmd2 +from cmd2 import ( + CompletionItem, +) +from cmd2.annotated import ( + Argument, + Group, + Option, + _apply_mutex_group_targets, + _ArgparseArgument, + _build_argument_group_targets, + _CollectionCastingAction, + _invoke_command_func, + _make_enum_type, + _make_literal_type, + _normalize_annotation, + _parse_bool, + build_parser_from_function, + with_annotated, +) +from cmd2.argparse_utils import register_argparse_argument_parameter + +from .conftest import run_cmd + + +def _resolve_annotation(annotation: Any, *, has_default: bool = False, default: Any = None) -> _ArgparseArgument: + """Build and validate a single argument from a bare annotation (test helper). + + The library builds a whole parameter list in ``_resolve_parameters``; these unit tests exercise one + annotation in isolation, mirroring that step: peel the annotation, populate the builder, run the + validity table. A lone argument has no following positional or group membership, so the + cross-argument/cross-config rows in ``_CONSTRAINTS`` are naturally inert. + """ + inner_type, metadata, is_optional = _normalize_annotation(annotation) + arg = _ArgparseArgument( + name="arg", + func_qualname="", + has_default=has_default, + param_default=default, + is_kw_only=False, + is_variadic=False, + inner_type=inner_type, + metadata=metadata, + is_optional=is_optional, + kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, + is_base_command=False, + ) + arg._check_constraints() + return arg + + +# --------------------------------------------------------------------------- +# Test enums +# --------------------------------------------------------------------------- + + +class _Color(enum.StrEnum): + 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"), +] + + +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. +# --------------------------------------------------------------------------- + + +def _func_empty(self) -> None: ... +def _func_var_keyword(self, name: str, **kwargs: str) -> 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 _func_positional_only_xy(self, x: str, /, y: int) -> None: ... +def _func_positional_only_mixed(self, x: str, /, y: int, *, z: int = 0) -> None: ... + + +def _provider(cmd: cmd2.Cmd): + return [] + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + + +def _get_param_action(func: object) -> argparse.Action: + """Build parser from a single-param function and return its action.""" + 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}") + + +# Templates used by ``_make_func``: one per parameter kind. ``code.replace`` swaps +# the literal name ``value`` for whatever ``name=`` the caller asks for, so the +# resulting signature carries the right dest without resorting to ``exec``. +def _stub_pos(self, value): ... # type: ignore[no-untyped-def] +def _stub_kw(self, *, value): ... # type: ignore[no-untyped-def] +def _stub_var(self, *value): ... # type: ignore[no-untyped-def] + + +_STUB_TEMPLATES = {"pos": _stub_pos, "kw": _stub_kw, "var": _stub_var} +_MISSING: Any = object() + + +def _make_func( + annotation: Any, + *, + name: str = "value", + default: Any = _MISSING, + kind: str = "pos", +) -> Any: + """Construct a one-parameter ``self``-method carrying just an annotation. + + ``kind`` is ``"pos"`` (positional-or-keyword), ``"kw"`` (keyword-only), or + ``"var"`` (``*args``). Used by tests as a stand-in for the throwaway + ``def do_x(self, value: T): ...`` stubs that only exist to be fed to + ``build_parser_from_function``. + """ + template = _STUB_TEMPLATES[kind] + code = template.__code__ + new_varnames = tuple(n if n != "value" else name for n in code.co_varnames) + new_code = code.replace(co_varnames=new_varnames) + f = types.FunctionType(new_code, template.__globals__, name=f"_stub_{name}") + # ``_MISSING`` for ``annotation`` produces an unannotated parameter (still ``-> None``). + f.__annotations__ = {"return": type(None)} if annotation is _MISSING else {name: annotation, "return": type(None)} + if default is not _MISSING: + if kind == "pos": + f.__defaults__ = (default,) + elif kind == "kw": + f.__kwdefaults__ = {name: default} + else: + raise ValueError(f"default not supported for kind={kind!r}") + return f + + +def _action_for(annotation: Any, **kwargs: Any) -> argparse.Action: + """Build a one-param function with the given annotation and return its action.""" + return _get_param_action(_make_func(annotation, **kwargs)) + + +def _assert_build_error(annotation: Any, *, match: str | None = None, **kwargs: Any) -> None: + """Assert ``build_parser_from_function`` rejects a one-param function with this annotation.""" + with pytest.raises(TypeError, match=match): + build_parser_from_function(_make_func(annotation, **kwargs)) + + +def _complete_cmd(app: cmd2.Cmd, line: str, text: str) -> list[str]: + begidx = len(line) - len(text) + endidx = len(line) + completions = app.complete(text, line, begidx, endidx) + return list(completions.to_strings()) + + +# Register a custom add_argument parameter so we can verify that Argument()/Option() +# forward arbitrary registered parameters (parity with hand-built parsers). +register_argparse_argument_parameter("annotated_custom_attr") + + +# --------------------------------------------------------------------------- +# Core: build_parser_from_function produces correct action attributes +# --------------------------------------------------------------------------- + + +class TestBuildParser: + """Verify action attributes produced by build_parser_from_function.""" + + @pytest.mark.parametrize( + ("func", "expected"), + [ + # --- Positionals --- + pytest.param(_make_func(str, name="name"), {"option_strings": [], "type": None}, id="str_positional"), + pytest.param(_make_func(Path, name="file"), {"option_strings": [], "type": Path}, id="path_positional"), + pytest.param( + _make_func(decimal.Decimal, name="amount"), + {"option_strings": [], "type": decimal.Decimal}, + id="decimal_positional", + ), + pytest.param(_make_func(bool, name="flag"), {"option_strings": [], "type": _parse_bool}, id="bool_positional"), + pytest.param( + _make_func(_Color, name="color"), {"option_strings": [], "choices": _COLOR_CHOICE_ITEMS}, id="enum_positional" + ), + pytest.param( + _make_func(Literal["fast", "slow"], name="mode"), + {"option_strings": [], "choices": ["fast", "slow"]}, + id="literal_positional", + ), + pytest.param( + _make_func(Literal[1, 2, 3], name="level"), + {"option_strings": [], "choices": [1, 2, 3]}, + id="literal_int_positional", + ), + pytest.param( + _make_func(_IntColor, name="color"), + {"option_strings": [], "choices": _INT_COLOR_CHOICE_ITEMS}, + id="int_enum_positional", + ), + pytest.param( + _make_func(_PlainColor, name="color"), + {"option_strings": [], "choices": _PLAIN_COLOR_CHOICE_ITEMS}, + id="plain_enum_positional", + ), + pytest.param(_make_func(list[int], name="nums"), {"option_strings": [], "nargs": "+", "type": int}, id="list_int"), + pytest.param(_make_func(set[int], name="nums"), {"option_strings": [], "nargs": "+", "type": int}, id="set_int"), + pytest.param( + _make_func(tuple[int, int, int], name="triple"), + {"option_strings": [], "nargs": 3, "type": int}, + id="tuple_fixed_triple", + ), + pytest.param(_make_func(list[str], name="files"), {"option_strings": [], "nargs": "+"}, id="list_positional"), + pytest.param(_make_func(set[str], name="tags"), {"option_strings": [], "nargs": "+"}, id="set_positional"), + pytest.param( + _make_func(tuple[int, ...], name="values"), + {"option_strings": [], "nargs": "+", "type": int}, + id="tuple_ellipsis", + ), + pytest.param( + _make_func(tuple[int, int], name="pair"), {"option_strings": [], "nargs": 2, "type": int}, id="tuple_fixed" + ), + pytest.param(_make_func(list, name="items"), {"option_strings": [], "nargs": "+"}, id="bare_list"), + pytest.param(_make_func(tuple, name="items"), {"option_strings": [], "nargs": "+"}, id="bare_tuple"), + pytest.param( + _make_func(Annotated[int | None, Argument()], name="val"), + {"option_strings": [], "nargs": "?", "type": int}, + id="optional_positional", + ), + pytest.param( + _make_func(Annotated[str, Argument()], name="arg", default="foo"), + {"option_strings": [], "nargs": "?", "default": "foo"}, + id="positional_with_default", + ), + pytest.param( + _make_func(int | None, name="val"), {"option_strings": [], "nargs": "?", "type": int}, id="optional_plain" + ), + pytest.param( + _make_func(list[int] | None, name="vals"), + {"option_strings": [], "nargs": "*", "type": int}, + id="optional_list", + ), + pytest.param( + _make_func(tuple[int, ...] | None, name="vals"), + {"option_strings": [], "nargs": "*", "type": int}, + id="optional_tuple_ellipsis", + ), + # --- Options --- + pytest.param( + _make_func(int, name="count", default=1), + {"option_strings": ["--count"], "type": int, "default": 1}, + id="int_option", + ), + pytest.param( + _make_func(float, name="rate", default=1.0), + {"option_strings": ["--rate"], "type": float, "default": 1.0}, + id="float_option", + ), + pytest.param( + _make_func(bool, name="verbose", default=False), + {"option_strings": ["--verbose", "--no-verbose"], "default": False}, + id="bool_optional_action", + ), + pytest.param( + _make_func(bool, name="debug", default=True), + {"option_strings": ["--debug", "--no-debug"], "default": True}, + id="bool_optional_action_true", + ), + pytest.param( + _make_func(Path, name="file", default=Path(".")), + {"option_strings": ["--file"], "type": Path}, + id="path_option", + ), + pytest.param( + _make_func(_Color, name="color", default=_Color.blue), + {"option_strings": ["--color"], "choices": _COLOR_CHOICE_ITEMS, "default": _Color.blue}, + id="enum_option", + ), + pytest.param( + _make_func(Literal["fast", "slow"], name="mode", default="fast"), + {"option_strings": ["--mode"], "choices": ["fast", "slow"]}, + id="literal_option", + ), + pytest.param( + _make_func(str | None, name="name", default=None), + {"option_strings": ["--name"], "default": None}, + id="optional_str", + ), + pytest.param( + _make_func(list[str] | None, name="items", default=None), + {"option_strings": ["--items"], "nargs": "*"}, + id="list_with_default", + ), + # --- Annotated metadata --- + pytest.param( + _make_func(Annotated[str, Argument(help_text="Your name")], name="name"), + {"option_strings": [], "help": "Your name"}, + id="annotated_help", + ), + pytest.param( + _make_func(Annotated[str, Option("--color", "-c", help_text="Pick")], name="color", default="blue"), + {"option_strings": ["--color", "-c"], "help": "Pick"}, + id="annotated_custom_flags", + ), + pytest.param( + _make_func(Annotated[str, Argument(metavar="NAME")], name="name"), + {"option_strings": [], "metavar": "NAME"}, + id="annotated_metavar", + ), + pytest.param( + # argparse accepts a tuple metavar to label each value of a multi-value argument. + _make_func(Annotated[tuple[int, int], Argument(metavar=("LO", "HI"))], name="span"), + {"option_strings": [], "metavar": ("LO", "HI")}, + id="annotated_tuple_metavar", + ), + pytest.param( + _make_func(Annotated[tuple[str, ...], Argument(nargs=2)], name="names"), + {"option_strings": [], "nargs": 2}, + id="annotated_nargs", + ), + pytest.param( + _make_func(Annotated[str, Option("--name", required=True)], name="name"), + {"option_strings": ["--name"], "required": True}, + id="annotated_required", + ), + pytest.param( + _make_func(Annotated[str, Option(required=True)], name="name"), + {"option_strings": ["--name"], "required": True}, + id="annotated_required_auto_flag", + ), + # A value option with no default and no ``| None`` must be required (else omitting it + # would pass None, violating the non-Optional type hint). + pytest.param( + _make_func(Annotated[str, Option("-c")], name="color"), + {"option_strings": ["-c"], "required": True}, + id="option_no_default_required", + ), + # A bool option is a flag, not a value: absence means ``False``, so it defaults to False + # and is NOT required (a required bool flag would be unsatisfiable for a short-only flag). + pytest.param( + _make_func(Annotated[bool, Option("-f")], name="flag"), + {"option_strings": ["-f"], "required": False, "default": False}, + id="bool_option_no_default_defaults_false", + ), + # ``| None`` opts out of required: None is a valid value when omitted. + pytest.param( + _make_func(Annotated[str | None, Option("-c")], name="color"), + {"option_strings": ["-c"], "required": False, "default": None}, + id="option_optional_no_default_not_required", + ), + pytest.param( + _make_func(str | None, name="name", kind="kw"), + {"option_strings": ["--name"], "required": False, "default": None}, + id="kw_only_optional_not_required", + ), + pytest.param( + _make_func(Annotated[str, Argument(choices=["a", "b"])], name="food"), + {"option_strings": [], "choices": ["a", "b"]}, + id="annotated_choices", + ), + pytest.param( + _make_func(str, name="args", kind="var"), {"option_strings": [], "type": None, "nargs": "*"}, id="star_args" + ), + pytest.param( + _make_func(int, name="args", kind="var"), {"option_strings": [], "type": int, "nargs": "*"}, id="star_args_int" + ), + # --- Keyword-only --- + pytest.param( + _make_func(str, name="name", kind="kw"), + {"option_strings": ["--name"], "required": True}, + id="kw_only_required", + ), + pytest.param( + _make_func(str, name="name", default="world", kind="kw"), + {"option_strings": ["--name"], "default": "world"}, + id="kw_only_default", + ), + # --- Underscore in flag names --- + pytest.param( + _make_func(str, name="my_param", default="x"), + {"option_strings": ["--my-param"], "default": "x"}, + id="underscore_flag", + ), + # --- Default type preservation --- + pytest.param( + _make_func(int, name="count", default="1"), + {"option_strings": ["--count"], "default": "1"}, + id="default_not_coerced", + ), + pytest.param( + _make_func(Path, name="file", default=Path("/tmp")), + {"option_strings": ["--file"], "default": Path("/tmp")}, + id="path_default", + ), + # --- Optional + Annotated (union inside) --- + pytest.param( + _make_func(Annotated[str | None, Option("--name")], name="name", default=None), + {"option_strings": ["--name"], "default": None}, + id="optional_annotated_inside", + ), + # --- Collections of complex element types --- + pytest.param( + _make_func(list[bool], name="flags"), {"option_strings": [], "nargs": "+", "type": _parse_bool}, id="list_bool" + ), + pytest.param( + _make_func(set[bool], name="flags"), {"option_strings": [], "nargs": "+", "type": _parse_bool}, id="set_bool" + ), + pytest.param( + _make_func(list[Path], name="files"), {"option_strings": [], "nargs": "+", "type": Path}, id="list_path" + ), + pytest.param( + _make_func(list[Literal["fast", "slow"]], name="modes"), + {"option_strings": [], "nargs": "+", "choices": ["fast", "slow"]}, + id="list_literal", + ), + pytest.param( + _make_func(list[_Color], name="colors"), + {"option_strings": [], "nargs": "+", "choices": _COLOR_CHOICE_ITEMS}, + id="list_enum", + ), + pytest.param( + _make_func(tuple[Path, Path], name="src_dst"), + {"option_strings": [], "nargs": 2, "type": Path}, + id="tuple_paths", + ), + pytest.param( + _make_func(tuple[_Color, _Color], name="pair"), + {"option_strings": [], "nargs": 2, "choices": _COLOR_CHOICE_ITEMS}, + id="tuple_enums", + ), + # --- Subclass fallback (Port(int) uses int converter) --- + pytest.param(_make_func(_Port, name="port"), {"option_strings": [], "type": int}, id="int_subclass"), + # --- Optional with non-None default --- + pytest.param( + _make_func(str | None, name="name", default="world"), + {"option_strings": ["--name"], "default": "world"}, + id="optional_str_nondefault", + ), + # --- typing.Optional[T] (vs T | None) end-to-end --- + pytest.param( + _make_func(Optional[int], name="count", default=None), # noqa: UP045 + {"option_strings": ["--count"], "type": int, "default": None}, + id="typing_optional", + ), + ], + ) + 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( + _make_func(Annotated[int, Option("--verbose", "-v", action="count")], name="verbose", default=0) + ) + assert isinstance(action, argparse._CountAction) + + def test_annotated_action_count_non_bool(self) -> None: + action = _get_param_action(_make_func(Annotated[int, Option("--count", action="count")], name="count", default=0)) + assert isinstance(action, argparse._CountAction) + assert action.default == 0 + + def test_annotated_action_store_true(self) -> None: + """``action='store_true'`` strips the inferred bool converter.""" + action = _get_param_action( + _make_func(Annotated[bool, Option("--verbose", action="store_true")], name="verbose", default=False) + ) + assert isinstance(action, argparse._StoreTrueAction) + assert action.type is None + assert action.default is False + + def test_annotated_action_store_false(self) -> None: + """``action='store_false'`` strips the inferred bool converter.""" + action = _get_param_action( + _make_func(Annotated[bool, Option("--quiet", action="store_false")], name="quiet", default=True) + ) + assert isinstance(action, argparse._StoreFalseAction) + assert action.type is None + assert action.default is True + + def test_annotated_action_append(self) -> None: + """``action='append'`` collects repeated flag values into a list.""" + action = _get_param_action(_make_func(Annotated[list[str], Option("--tag", action="append")], name="tag")) + assert isinstance(action, argparse._AppendAction) + assert action.option_strings == ["--tag"] + + def test_positional_with_default_is_optional(self) -> None: + """A positional with a default takes 0-or-1 tokens and falls back to the default when absent.""" + parser = build_parser_from_function(_make_func(Annotated[str, Argument()], name="arg", default="foo")) + assert parser.parse_args([]).arg == "foo" + assert parser.parse_args(["bar"]).arg == "bar" + + def test_str_default_on_int_option_coerced_at_parse(self) -> None: + """The decorator stores the default literally ('1', see ``default_not_coerced``); at parse + time argparse applies ``type=int`` to the string default, so an absent ``--count`` yields int 1. + """ + parser = build_parser_from_function(_make_func(int, name="count", default="1")) + assert parser.parse_args([]).count == 1 + assert parser.parse_args(["--count", "5"]).count == 5 + + def test_typing_optional_parses_end_to_end(self) -> None: + """typing.Optional[int] yields None when absent and coerces to int when provided.""" + parser = build_parser_from_function(_make_func(Optional[int], name="count", default=None)) # noqa: UP045 + assert parser.parse_args([]).count is None + parsed = parser.parse_args(["--count", "5"]).count + assert parsed == 5 + assert isinstance(parsed, int) + + @pytest.mark.parametrize( + "func", + [ + pytest.param(_make_func(set[str], name="tags"), id="set"), + pytest.param(_make_func(tuple[int, ...], name="values"), id="tuple"), + pytest.param(_make_func(str, name="args", kind="var"), id="star_args"), + ], + ) + def test_collection_uses_casting_action(self, func) -> None: + action = _get_param_action(func) + assert isinstance(action, _CollectionCastingAction) + + def test_star_args_bare_defaults_to_str(self) -> None: + """A bare ``*args`` (no element annotation) is treated as ``*args: str``.""" + action = _get_param_action(_make_func(_MISSING, name="args", kind="var")) + assert action.option_strings == [] + assert action.nargs == "*" + assert action.type is None + + def test_star_args_parses_to_tuple(self) -> None: + """``*args: int`` accepts zero or more values, coerced and collected into a tuple.""" + parser = build_parser_from_function(_make_func(int, name="args", kind="var")) + assert parser.parse_args([]).args == () + parsed = parser.parse_args(["1", "2", "3"]).args + assert parsed == (1, 2, 3) + assert isinstance(parsed, tuple) + + def test_self_skipped(self) -> None: + parser = build_parser_from_function(_make_func(str, name="name")) + 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_self_only_method_produces_empty_parser(self) -> None: + """A method whose only parameter is ``self`` produces a parser with no actions.""" + parser = build_parser_from_function(_func_empty) + dests = {a.dest for a in parser._actions if a.dest != "help"} + assert dests == set() + assert parser.parse_args([]) == argparse.Namespace() + + def test_get_type_hints_failure_raises(self) -> None: + def do_broken(self, name: "NonExistentType"): # noqa: F821 + pass + + 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: + """Base-command validation should raise, not swallow, type hint failures.""" + from cmd2.annotated import _resolve_parameters + + def do_broken(self, cmd2_handler, name: "NonExistentType"): # noqa: F821 + pass + + with pytest.raises(TypeError, match="Failed to resolve type hints"): + _resolve_parameters(do_broken, base_command=True) + + def test_dest_param_raises(self) -> None: + with pytest.raises(ValueError, match="dest"): + build_parser_from_function(_make_func(str, name="dest")) + + 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_with_annotated_positional_only_two_params_raises(self) -> None: + with pytest.raises(TypeError, match="positional-only"): + build_parser_from_function(_func_positional_only_xy) + + def test_with_annotated_positional_only_mixed_params_raises(self) -> None: + with pytest.raises(TypeError, match="positional-only"): + build_parser_from_function(_func_positional_only_mixed) + + def test_var_keyword_raises(self) -> None: + """``**kwargs`` cannot be mapped to command-line arguments and is rejected.""" + with pytest.raises(TypeError, match=r"variadic keyword"): + build_parser_from_function(_func_var_keyword) + + @pytest.mark.parametrize( + "func", + [ + pytest.param(_make_func(tuple[str, ...], name="files", kind="var"), id="tuple[str, ...]"), + pytest.param(_make_func(list[str], name="xs", kind="var"), id="list[str]"), + pytest.param(_make_func(list, name="xs", kind="var"), id="bare_list"), + ], + ) + def test_star_args_collection_element_raises(self, func) -> None: + """``*args`` annotated with a collection element is rejected with a targeted hint. + + The annotation on ``*args`` is each value's type, so a collection element (e.g. + ``*files: tuple[str, ...]``) would mean a tuple-of-collections. The error must steer + the user toward annotating the element type. + """ + with pytest.raises(TypeError, match=r"the type of each value"): + build_parser_from_function(func) + + def test_star_args_honors_argument_metadata(self) -> None: + """``Annotated[T, Argument(...)]`` on ``*args`` applies help/metavar to the variadic positional.""" + action = _get_param_action( + _make_func(Annotated[str, Argument(help_text="a file", metavar="FILE")], name="files", kind="var") + ) + assert action.option_strings == [] + assert action.nargs == "*" + assert action.help == "a file" + assert action.metavar == "FILE" + + def test_star_args_honors_argument_choices(self) -> None: + """``Argument(choices=...)`` on ``*args`` restricts every value to the choices.""" + parser = build_parser_from_function(_make_func(Annotated[str, Argument(choices=["a", "b"])], name="modes", kind="var")) + assert parser.parse_args(["a", "b", "a"]).modes == ("a", "b", "a") + with pytest.raises(SystemExit): + parser.parse_args(["a", "nope"]) + + def test_star_args_option_metadata_raises(self) -> None: + """``Option()`` on ``*args`` is rejected; *args is always positional.""" + with pytest.raises(TypeError, match=r"\*args is always a positional"): + build_parser_from_function(_make_func(Annotated[str, Option("--files")], name="files", kind="var")) + + def test_star_args_nargs_metadata_raises(self) -> None: + """An explicit ``nargs`` on ``*args`` is rejected; its arity is fixed to ``'*'``.""" + with pytest.raises(TypeError, match=r"arity cannot be overridden"): + build_parser_from_function(_make_func(Annotated[str, Argument(nargs=2)], name="files", kind="var")) + + @pytest.mark.parametrize( + "func", + [ + pytest.param(_make_func(Annotated[str, Argument()], name="name", kind="kw"), id="no_default"), + pytest.param(_make_func(Annotated[str, Argument()], name="name", default="x", kind="kw"), id="with_default"), + ], + ) + def test_kw_only_with_argument_metadata_raises(self, func) -> None: + """A keyword-only parameter cannot use ``Argument()`` (which marks a positional).""" + with pytest.raises(TypeError, match=r"keyword-only but uses Argument\(\)"): + build_parser_from_function(func) + + def test_option_no_default_is_enforced_at_parse_time(self) -> None: + """Omitting a no-default, non-Optional option errors instead of silently passing None.""" + parser = build_parser_from_function(_make_func(Annotated[str, Option("-c")], name="color")) + with pytest.raises(SystemExit): + parser.parse_args([]) + assert parser.parse_args(["-c", "red"]).color == "red" + + def test_optional_option_no_default_yields_none_when_omitted(self) -> None: + """``| None`` opts out of required: omitting it yields None, a valid value for the hint.""" + parser = build_parser_from_function(_make_func(Annotated[str | None, Option("-c")], name="color")) + assert parser.parse_args([]).color is None + assert parser.parse_args(["-c", "red"]).color == "red" + + def test_generator_choices_are_materialized(self) -> None: + """A single-use iterable (generator) as choices must survive repeated argparse iteration.""" + parser = build_parser_from_function( + _make_func(Annotated[str, Argument(choices=(c for c in ["a", "b"]))]), + ) + assert parser.parse_args(["a"]).value == "a" + assert parser.parse_args(["b"]).value == "b" + with pytest.raises(SystemExit): + parser.parse_args(["c"]) + + @pytest.mark.parametrize( + ("func", "dest", "default"), + [ + pytest.param( + _make_func(Annotated[bool, Option("-v", action="store_true")], name="verbose"), + "verbose", + False, + id="store_true", + ), + pytest.param(_make_func(Annotated[int, Option("-l", action="count")], name="level"), "level", 0, id="count"), + ], + ) + def test_flag_action_no_default_not_required(self, func, dest, default) -> None: + """Flag-style actions carry their own implicit default, so a missing default does not force required.""" + parser = build_parser_from_function(func) + action = _get_param_action(func) + assert action.required is False + assert getattr(parser.parse_args([]), dest) == default + + def test_bool_option_no_default_is_usable_flag(self) -> None: + """A bool option is a flag (absence -> False), not a required value. + + Marking it required would make a short-only flag unsatisfiable: ``-f`` can only set True + and there is no negation form, so a required ``-f`` could never be False. + """ + parser = build_parser_from_function(_make_func(Annotated[bool, Option("-f")], name="flag")) + action = _get_param_action(_make_func(Annotated[bool, Option("-f")], name="flag")) + assert action.required is False + assert parser.parse_args([]).flag is False + assert parser.parse_args(["-f"]).flag is True + + def test_optional_annotated_outside_raises(self) -> None: + with pytest.raises(TypeError, match="Annotated"): + build_parser_from_function(_make_func(Annotated[str, Option("--name")] | None, name="name", default=None)) + + def test_annotated_ambiguous_union_raises(self) -> None: + """Annotated[str | int, meta] must raise -- ambiguous inner union.""" + 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(_make_func(Annotated[_Color, Argument(choices_provider=_provider)], name="color")) + assert action.choices is None + assert action.get_choices_provider() is _provider # type: ignore[attr-defined] + assert action.get_completer() is None # type: ignore[attr-defined] + + def test_choices_provider_keeps_enum_coercion(self) -> None: + """A choices_provider on an Enum keeps the converter so values still coerce to the member.""" + action = _get_param_action(_make_func(Annotated[_Color, Argument(choices_provider=_provider)], name="color")) + assert action.type is not None + assert action.type("red") is _Color.red + + def test_choices_provider_keeps_literal_coercion(self) -> None: + """A choices_provider on a Literal keeps the converter (coercion) but drops the static choices.""" + + def func( + self, + level: Annotated[Literal[1, 2], Argument(choices_provider=_provider)], + ) -> None: ... + + action = _get_param_action(func) + assert action.choices is None + assert action.type is not None + assert action.type("1") == 1 + + def test_choices_provider_enum_coerces_at_parse(self) -> None: + """End-to-end: an Enum with a choices_provider still parses to the enum member, not a str.""" + parser = build_parser_from_function(_make_func(Annotated[_Color, Argument(choices_provider=_provider)], name="color")) + assert parser.parse_args(["red"]).color is _Color.red + + def test_choices_provider_literal_int_coerces_at_parse(self) -> None: + """End-to-end: a Literal[int] with a choices_provider parses to int, not a str.""" + + def func( + self, + level: Annotated[Literal[1, 2], Argument(choices_provider=_provider)], + ) -> None: ... + + parser = build_parser_from_function(func) + parsed = parser.parse_args(["1"]).level + assert parsed == 1 + assert isinstance(parsed, int) + + def test_bare_enum_literal_coerce_at_parse(self) -> None: + """Bare Enum/Literal positionals and options coerce to the declared type at parse time. + + Uses identity / isinstance (not ``==``) so a stripped converter returning a raw ``str`` + cannot hide behind StrEnum/IntEnum equality. + """ + assert build_parser_from_function(_make_func(Literal["fast", "slow"], name="mode")).parse_args(["fast"]).mode == "fast" + assert ( + build_parser_from_function(_make_func(Literal["fast", "slow"], name="mode", default="fast")) + .parse_args(["--mode", "slow"]) + .mode + == "slow" + ) + + level = build_parser_from_function(_make_func(Literal[1, 2, 3], name="level")).parse_args(["2"]).level + assert level == 2 + assert isinstance(level, int) + + assert build_parser_from_function(_make_func(_Color, name="color")).parse_args(["red"]).color is _Color.red + assert ( + build_parser_from_function(_make_func(_Color, name="color", default=_Color.blue)) + .parse_args(["--color", "red"]) + .color + is _Color.red + ) + # Non-StrEnum cases: identity defeats the StrEnum/IntEnum ``==`` masking property. + assert build_parser_from_function(_make_func(_IntColor, name="color")).parse_args(["1"]).color is _IntColor.red + assert build_parser_from_function(_make_func(_PlainColor, name="color")).parse_args(["red"]).color is _PlainColor.RED + + def test_completer_keeps_path_converter(self) -> None: + """User-supplied completer on Path preserves the (non-restrictive) Path converter.""" + action = _get_param_action(_make_func(Annotated[Path, Argument(completer=cmd2.Cmd.path_complete)], name="file")) + assert action.type is Path + + def test_completer_overrides_inferred_path_completion(self) -> None: + action = _get_param_action(_make_func(Annotated[Path, Argument(completer=cmd2.Cmd.path_complete)], name="file")) + assert action.get_choices_provider() is None # type: ignore[attr-defined] + assert action.get_completer() is cmd2.Cmd.path_complete # type: ignore[attr-defined] + + def test_inferred_enum_choices_match_type_converter(self) -> None: + """Enum choices must be convertible by the type converter.""" + action = _get_param_action(_make_func(_Color, name="color")) + converter = action.type + for choice in action.choices: + assert isinstance(converter(str(choice)), _Color) + + @pytest.mark.parametrize( + "func", + [ + pytest.param(_make_func(Path, name="file"), id="path_positional"), + pytest.param(_make_func(Path, name="file", default=Path(".")), id="path_option"), + pytest.param(_make_func(list[Path], name="files"), id="list_path"), + pytest.param(_make_func(tuple[Path, Path], name="src_dst"), id="tuple_paths"), + ], + ) + def test_path_annotation_wires_path_completer(self, func) -> None: + """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 +# --------------------------------------------------------------------------- + + +class TestArgumentGroups: + def test_groups_and_mutex_applied(self) -> None: + parser = build_parser_from_function( + _func_grouped, + 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] + 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=(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=(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=(Group("local"), Group("remote")), + mutually_exclusive_groups=(Group("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=(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"} + 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=(Group("verbose", "quiet"), Group("json", "csv"))) + assert len(parser._mutually_exclusive_groups) == 2 + + def test_required_member_in_mutex_group_raises(self) -> None: + """A required (no default, non-Optional) option in a mutex group is rejected with a clear error. + + argparse forbids required members in a mutex group, and it would be type-unsafe: only one + member is supplied, so the others arrive as None. The message must steer toward making it optional. + """ + + def func(self, local: Annotated[str, Option("--local")], remote: str | None = None) -> None: ... + + with pytest.raises(ValueError, match=r"mutually exclusive group members must be optional"): + build_parser_from_function(func, mutually_exclusive_groups=(Group("local", "remote"),)) + + def test_required_positional_member_in_mutex_group_raises(self) -> None: + """A required *positional* mutex member is rejected with the same clear, type-safety message. + + Positionals never carry argparse's ``required=`` flag, so the check keys on the parameter + being non-omittable, not on ``required`` (which is always False for a positional). + """ + + def func(self, a: int, b: Annotated[int, Option("--b")] = 0) -> None: ... + + with pytest.raises(ValueError, match=r"mutually exclusive group members must be optional"): + build_parser_from_function(func, mutually_exclusive_groups=(Group("a", "b"),)) + + def test_optional_members_in_mutex_group_build(self) -> None: + """Mutex members that are Optional or have defaults build fine (the regression guard).""" + + def func(self, local: Annotated[str | None, Option("--local")] = None, remote: str = "x") -> None: ... + + parser = build_parser_from_function(func, mutually_exclusive_groups=(Group("local", "remote"),)) + assert len(parser._mutually_exclusive_groups) == 1 + + def test_bool_flag_members_in_mutex_group_build(self) -> None: + """Plain bool flags (no default) are not required, so they belong in a mutex group.""" + + def func(self, verbose: Annotated[bool, Option("--verbose")], quiet: Annotated[bool, Option("--quiet")]) -> None: ... + + parser = build_parser_from_function(func, mutually_exclusive_groups=(Group("verbose", "quiet"),)) + assert len(parser._mutually_exclusive_groups) == 1 + + def test_nonexistent_member_reported_over_required_member(self) -> None: + """A typo'd member name surfaces as 'nonexistent', not masked by the required-member check.""" + + def func(self, local: Annotated[str, Option("--local")], remote: str | None = None) -> None: ... + + with pytest.raises(ValueError, match=r"nonexistent parameter 'ghost'"): + build_parser_from_function(func, mutually_exclusive_groups=(Group("local", "ghost"),)) + + def test_argument_group(self) -> None: + """Arguments in a group appear under a shared heading in help.""" + + def func(self, src: str, dst: str, recursive: bool = False, verbose: bool = False) -> None: ... + + 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 + 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): + @with_annotated(mutually_exclusive_groups=(Group("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=(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} + assert {"json", "csv"} <= all_custom_dests + with pytest.raises(SystemExit): + 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_description_and_epilog(self) -> None: + parser = build_parser_from_function(_make_func(str), 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 + + parser = build_parser_from_function(_make_func(str), formatter_class=MyFormatter) + assert parser.formatter_class is MyFormatter + + def test_custom_parser_class(self) -> None: + class MyParser(cmd2.Cmd2ArgumentParser): + pass + + parser = build_parser_from_function(_make_func(str), parser_class=MyParser) + assert isinstance(parser, MyParser) + + def test_ap_completer_type(self) -> None: + from cmd2.argparse_completer import ArgparseCompleter + + class MyCompleter(ArgparseCompleter): + pass + + parser = build_parser_from_function(_make_func(str), ap_completer_type=MyCompleter) + assert parser.ap_completer_type is MyCompleter + + def test_ap_completer_type_defaults_to_none(self) -> None: + assert build_parser_from_function(_make_func(str)).ap_completer_type is None + + def test_ap_completer_type_via_decorator(self) -> None: + from cmd2 import constants + from cmd2.argparse_completer import ArgparseCompleter + + class MyCompleter(ArgparseCompleter): + pass + + @with_annotated(ap_completer_type=MyCompleter) + def do_run(self, name: str) -> None: ... + + builder = getattr(do_run, constants.CMD_ATTR_PARSER_SOURCE) + assert builder().ap_completer_type is MyCompleter + + def test_ap_completer_type_threads_to_subcommand(self) -> None: + from cmd2 import constants + from cmd2.argparse_completer import ArgparseCompleter + + class MyCompleter(ArgparseCompleter): + pass + + @with_annotated(subcommand_to="team", ap_completer_type=MyCompleter) + def team_create(self, name: str) -> None: ... + + spec = getattr(team_create, constants.SUBCMD_ATTR_SPEC) + assert spec.parser_source().ap_completer_type is MyCompleter + + def test_customization_via_decorator(self) -> None: + """description/epilog/titled Group flow through @with_annotated end-to-end.""" + + 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"): + 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=(Group("src", "dst"),)) + assert set(target_for) == {"src", "dst"} + assert set(argument_group_for) == {"src", "dst"} + assert target_for["src"] is argument_group_for["src"] + assert target_for["dst"] is argument_group_for["dst"] + + def test_duplicate_argument_group_assignment_raises(self) -> None: + # Double-assignment is enforced by _CONSTRAINTS (the argument_group_indices membership fact), + # so it surfaces through the real build path rather than _build_argument_group_targets itself. + def func(self, *, verbose: bool = False) -> None: ... + + with pytest.raises(ValueError, match="argument group 1 and argument group 2"): + build_parser_from_function(func, groups=(Group("verbose"), Group("verbose"))) + + def test_apply_mutex_group_targets(self) -> None: + parser = argparse.ArgumentParser() + target_for, argument_group_for = _build_argument_group_targets(parser, groups=(Group("json", "csv"),)) + + _apply_mutex_group_targets( + parser, + target_for=target_for, + argument_group_for=argument_group_for, + mutually_exclusive_groups=(Group("json", "csv"),), + ) + + assert target_for["json"] is target_for["csv"] + assert isinstance(target_for["json"], argparse._MutuallyExclusiveGroup) + + def test_duplicate_mutex_group_assignment_raises(self) -> None: + # Double-assignment is enforced by _CONSTRAINTS (the mutex_group_indices membership fact). + def func(self, *, verbose: bool = False) -> None: ... + + with pytest.raises(ValueError, match="multiple mutually exclusive groups"): + build_parser_from_function(func, mutually_exclusive_groups=(Group("verbose"), Group("verbose"))) + + def test_apply_mutex_group_targets_rejects_cross_group_members(self) -> None: + parser = argparse.ArgumentParser() + _target_for, argument_group_for = _build_argument_group_targets(parser, groups=(Group("src"), Group("dst"))) + + with pytest.raises(ValueError, match="different argument groups"): + _apply_mutex_group_targets( + parser, + target_for={}, + argument_group_for=argument_group_for, + mutually_exclusive_groups=(Group("src", "dst"),), + ) + + +# --------------------------------------------------------------------------- +# _resolve_annotation: positional vs option classification +# --------------------------------------------------------------------------- + +_ARG_META = Argument(help_text="Name") +_OPT_META = Option("--color", "-c", help_text="Pick") + + +class TestResolveAnnotation: + @pytest.mark.parametrize( + ("annotation", "has_default", "expected_positional"), + [ + pytest.param(str, False, True, id="plain_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"), + 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) -> None: + # A non-None default is supplied when has_default (default=None on a non-Optional type + # is itself rejected); classification depends only on whether a default exists, not its value. + extra = {"default": 0} if has_default else {} + arg = _resolve_annotation(annotation, has_default=has_default, **extra) + assert arg.is_positional is expected_positional + + def test_optional_wrapping_annotated_with_none_inside(self) -> None: + """Optional[Annotated[T | None, meta]] is allowed (inner type contains None).""" + ann = Annotated[str | None, _OPT_META] | None + arg = _resolve_annotation(ann) + assert arg.metadata is _OPT_META + assert arg.is_positional is False + + def test_typing_union_optional(self) -> None: + ns: dict = {} + exec("import typing; t = typing.Union[str, None]", ns) + assert _resolve_annotation(ns["t"]).is_positional is True + + def test_typing_union_optional_with_default(self) -> None: + ns: dict = {} + exec("import typing; t = typing.Union[str, None]", ns) + assert _resolve_annotation(ns["t"], has_default=True, default=None).is_positional is False + + def test_annotated_multiple_metadata_picks_first(self) -> None: + meta1 = Argument(help_text="first") + meta2 = Option("--x", help_text="second") + arg = _resolve_annotation(Annotated[str, meta1, meta2]) + assert arg.metadata is meta1 + assert arg.extras.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(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", "resolve_kwargs"), + [ + pytest.param( + Annotated[tuple[int, int], Argument()], + {"has_default": True, "default": (1, 2)}, + id="fixed_tuple_with_default", + ), + pytest.param(Annotated[tuple[int, int] | None, Argument()], {}, id="fixed_tuple_optional"), + pytest.param( + Annotated[tuple[int, ...], Argument(nargs=2)], + {"has_default": True, "default": (1, 2)}, + id="explicit_nargs_with_default", + ), + pytest.param(Annotated[tuple[int, ...] | None, Argument(nargs=2)], {}, id="explicit_nargs_optional"), + ], + ) + def test_optional_fixed_arity_positional_raises(self, annotation, resolve_kwargs) -> None: + """argparse cannot make a fixed-arity positional optional, so the combination is rejected.""" + with pytest.raises(TypeError, match=r"fixed-arity positional"): + _resolve_annotation(annotation, **resolve_kwargs) + + @pytest.mark.parametrize( + "annotation", + [ + 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_subscripted_generic_raises(self, annotation) -> None: + """An unsupported subscripted generic must raise, not silently arrive as a plain str.""" + with pytest.raises(TypeError, match=r"Unsupported parameter type"): + _resolve_annotation(annotation) + + @pytest.mark.parametrize( + "annotation", + [ + pytest.param(Annotated[list[int] | None, Argument(nargs="?")], id="list_q"), + pytest.param(Annotated[list[int] | None, Argument(nargs=(0, 1))], id="list_0_1"), + pytest.param(Annotated[set[str] | None, Argument(nargs="?")], id="set_q"), + ], + ) + def test_optional_single_nargs_on_collection_raises(self, annotation) -> None: + """nargs='?'/(0,1) on a collection yields a single value the casting action cannot wrap.""" + with pytest.raises(TypeError, match=r"single value"): + _resolve_annotation(annotation) + + @pytest.mark.parametrize( + "annotation", + [ + pytest.param(datetime.datetime, id="datetime"), + pytest.param(datetime.date, id="date"), + pytest.param(uuid.UUID, id="uuid"), + pytest.param(bytes, id="bytes"), + pytest.param(complex, id="complex"), + ], + ) + def test_unsupported_scalar_type_raises(self, annotation) -> None: + """A type with no converter must not silently arrive as a plain string.""" + with pytest.raises(TypeError, match="Unsupported parameter type"): + _resolve_annotation(annotation) + + def test_unsupported_custom_class_raises(self) -> None: + class Money: + def __init__(self, raw: str) -> None: + self.raw = raw + + with pytest.raises(TypeError, match="Unsupported parameter type"): + _resolve_annotation(Money) + + @pytest.mark.parametrize( + "annotation", + [ + pytest.param(str, id="str"), + pytest.param(Any, id="any"), + pytest.param(object, id="object"), + ], + ) + def test_passthrough_scalar_types_keep_no_converter(self, annotation) -> None: + """str / Any / object are stored as the raw string (type stays None).""" + _flags, kwargs = _resolve_annotation(annotation)._emit() + assert "type" 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 + + @pytest.mark.parametrize( + ("values", "input_val"), + [ + pytest.param(["fast", "slow"], "medium", id="non_bool_string"), + # bool-like input ("yes"/"on") must still be rejected when the Literal has no bool member + pytest.param(["fast", "slow"], "yes", id="bool_like_no_bool_member"), + pytest.param([1, 2], "on", id="bool_like_int_literal"), + ], + ) + def test_invalid(self, values, input_val) -> None: + with pytest.raises(argparse.ArgumentTypeError, match="invalid choice"): + _make_literal_type(values)(input_val) + + 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 + + def test_extra_kwarg_forwarded_in_to_kwargs(self) -> None: + """A registered custom add_argument parameter is forwarded via **kwargs.""" + assert Argument(annotated_custom_attr="v").to_kwargs()["annotated_custom_attr"] == "v" + assert Option("--x", annotated_custom_attr="v").to_kwargs()["annotated_custom_attr"] == "v" + + def test_registered_custom_param_set_on_action(self) -> None: + """A registered custom parameter reaches the resulting argparse Action.""" + action = _action_for(Annotated[str, Argument(annotated_custom_attr="v")]) + assert action.get_annotated_custom_attr() == "v" # type: ignore[attr-defined] + + def test_unregistered_kwarg_raises_at_build(self) -> None: + """An unknown (unregistered) keyword is rejected when the parser is built.""" + _assert_build_error(Annotated[str, Argument(definitely_not_registered="v")]) + + +# --------------------------------------------------------------------------- +# _CollectionCastingAction +# --------------------------------------------------------------------------- + + +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" + + +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(_make_func(set[int], name="nums")) + ns = parser.parse_args(["1", "2", "2", "3"]) + assert isinstance(ns.nums, set) + assert ns.nums == {1, 2, 3} + + def test_tuple_ellipsis_returns_tuple(self) -> None: + parser = build_parser_from_function(_make_func(tuple[int, ...], name="values")) + ns = parser.parse_args(["1", "2", "3"]) + assert isinstance(ns.values, tuple) + assert ns.values == (1, 2, 3) + + def test_tuple_fixed_returns_tuple(self) -> None: + parser = build_parser_from_function(_make_func(tuple[int, int], name="pair")) + ns = parser.parse_args(["5", "10"]) + assert isinstance(ns.pair, tuple) + assert ns.pair == (5, 10) + + def test_list_bool_returns_list_of_bools(self) -> None: + parser = build_parser_from_function(_make_func(list[bool], name="flags")) + ns = parser.parse_args(["true", "no", "on"]) + assert ns.flags == [True, False, True] + + def test_tuple_paths_returns_tuple_of_paths(self) -> None: + parser = build_parser_from_function(_make_func(tuple[Path, Path], name="src_dst")) + ns = parser.parse_args(["/tmp/a", "/tmp/b"]) + assert isinstance(ns.src_dst, tuple) + assert ns.src_dst == (Path("/tmp/a"), Path("/tmp/b")) + + def test_append_action_collects_values(self) -> None: + parser = build_parser_from_function(_make_func(Annotated[list[str], Option("--tag", action="append")], name="tag")) + ns = parser.parse_args(["--tag", "a", "--tag", "b"]) + assert ns.tag == ["a", "b"] + + def test_int_subclass_uses_int_converter(self) -> None: + """``Port(int)`` falls back to ``int`` converter; argparse returns ``int``, not ``Port``.""" + parser = build_parser_from_function(_make_func(_Port, name="port")) + ns = parser.parse_args(["8080"]) + assert ns.port == 8080 + + +# --------------------------------------------------------------------------- +# _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(enum.StrEnum): + 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] + + @with_annotated + def do_greet(self, name: str, count: int = 1) -> None: + for _ in range(count): + self.poutput(f"Hello {name}") + + @with_annotated + def do_add(self, a: int, b: int = 0) -> None: + self.poutput(str(a + b)) + + @with_annotated + def do_paint( + self, + item: str, + color: Annotated[_Color, Option("--color", "-c", help_text="Color to use")] = _Color.blue, + verbose: bool = False, + ) -> None: + msg = f"Painting {item} {color.value}" + if verbose: + msg += " (verbose)" + self.poutput(msg) + + @with_annotated + def do_pick(self, item: Annotated[str, Argument(choices_provider=item_choices)]) -> None: + self.poutput(f"Picked: {item}") + + @with_annotated + def do_open(self, path: Path) -> None: + self.poutput(f"Opening: {path}") + + @with_annotated + def do_sport(self, sport: _Sport) -> None: + self.poutput(f"Playing: {sport.value}") + + @with_annotated + def do_toggle(self, enabled: bool) -> None: + self.poutput(f"Enabled: {enabled}") + + @with_annotated(preserve_quotes=True) + def do_raw(self, text: str) -> None: + self.poutput(f"raw: {text}") + + @with_annotated + def do_echo_all(self, *words: str) -> None: + self.poutput(f"words={list(words)}") + + @with_annotated + def do_total(self, label: str, *nums: int) -> None: + self.poutput(f"{label}={sum(nums)}") + + @with_annotated + def do_cat(self, *files: str, upper: bool = False) -> None: + joined = " ".join(files) + self.poutput(joined.upper() if upper else joined) + + @with_annotated + def do_ping(self) -> None: + self.poutput("pong") + + +@pytest.fixture +def runtime_app() -> _RuntimeAnnotatedApp: + 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"), + pytest.param("echo_all a b c", ["words=['a', 'b', 'c']"], id="star_args_values"), + pytest.param("echo_all", ["words=[]"], id="star_args_empty"), + pytest.param("total score 1 2 3", ["score=6"], id="leading_plus_star_args"), + pytest.param("total score", ["score=0"], id="leading_plus_empty_star_args"), + pytest.param("cat a b c", ["a b c"], id="star_args_with_kwonly_opt"), + pytest.param("cat a b c --upper", ["A B C"], id="star_args_with_kwonly_opt_set"), + pytest.param("ping", ["pong"], id="no_args_command"), + ], + ) + def test_command_execution(self, runtime_app, command, expected) -> None: + out, _err = run_cmd(runtime_app, command) + assert out == expected + + def test_no_args_command_rejects_extra_args(self, runtime_app) -> None: + """A no-parameter command accepts no positionals and errors on extras.""" + _out, err = run_cmd(runtime_app, "ping extra") + assert any("unrecognized" in line.lower() or "error" in line.lower() for line in err) + + def test_help_shows_arguments(self, runtime_app) -> None: + out, _ = run_cmd(runtime_app, "help greet") + assert "name" in "\n".join(out).lower() + + def test_help_shows_option_help(self, runtime_app) -> None: + out, _ = run_cmd(runtime_app, "help paint") + help_text = "\n".join(out) + assert "Color to use" 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] + + @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 + + @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) + + @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}") + + @with_annotated(preserve_quotes=True) + def do_raw(self, text: str) -> None: + self.poutput(f"raw: {text}") + + @with_annotated(ns_provider=namespace_provider) + def do_ns_test(self, cmd2_statement=None) -> None: + self.poutput("ok") + + @with_annotated + def do_prefixed(self, cmd2_mode: int = 1) -> None: + self.poutput(f"cmd2_mode={cmd2_mode}") + + +class _GroupedParserApp(cmd2.Cmd): + @with_annotated( + groups=(Group("local", "remote"), Group("force", "dry_run")), + mutually_exclusive_groups=(Group("local", "remote"), Group("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: + app = _IntegrationApp() + app.stdout = cmd2.utils.StdSim(app.stdout) + return app + + +@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"): + + @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"): + + @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_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_direct_call_no_arg_command(self) -> None: + """A no-parameter command parses an (empty) statement string on a direct call.""" + + class App(cmd2.Cmd): + @with_annotated + def do_ping(self) -> None: + self.poutput("pong") + + app = App() + app.stdout = cmd2.utils.StdSim(app.stdout) + app.do_ping("") + assert app.stdout.getvalue().splitlines()[-1] == "pong" + + def test_direct_call_star_args_command(self) -> None: + """A *args command parses a statement string positionally on a direct call.""" + + class App(cmd2.Cmd): + @with_annotated + def do_cat(self, *files: str, upper: bool = False) -> None: + joined = "|".join(files) + self.poutput(joined.upper() if upper else joined) + + app = App() + app.stdout = cmd2.utils.StdSim(app.stdout) + app.do_cat("a b c") + assert app.stdout.getvalue().splitlines()[-1] == "a|b|c" + app.do_cat("a b --upper") + assert app.stdout.getvalue().splitlines()[-1] == "A|B" + + def test_invoke_missing_leading_positional_raises(self) -> None: + """A leading positional missing from func_kwargs fails loud instead of shifting *args. + + ``*positional`` is unpacked by index, so silently skipping a missing leading + name would slide ``*var_values`` into the wrong parameter. Fail with KeyError. + """ + + def fn(self, first: str, *rest: str) -> None: ... + + with pytest.raises(KeyError, match="first"): + _invoke_command_func( + fn, + None, + {"rest": ("x", "y")}, + leading_names=["first"], + var_positional_name="rest", + ) + + def test_invoke_missing_var_positional_raises(self) -> None: + """A *args name missing from func_kwargs fails loud instead of defaulting to empty. + + argparse always populates the ``*args`` attribute (an empty list when nothing is + given), so a missing key signals a bug and must not be silently swallowed. + """ + + def fn(self, *rest: str) -> None: ... + + with pytest.raises(KeyError, match="rest"): + _invoke_command_func(fn, None, {}, leading_names=[], var_positional_name="rest") + + def test_bare_call_decorator(self) -> None: + """@with_annotated() with empty parens works same as @with_annotated.""" + + class App(cmd2.Cmd): + @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 + @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 + @with_annotated(subcommand_to="manage", help="add something") + def manage_add(self, value: str) -> None: + self.poutput(f"added: {value}") + + @with_annotated(subcommand_to="manage", help="sum values") + def manage_sum(self, *nums: int) -> None: + self.poutput(f"sum: {sum(nums)}") + + @with_annotated(subcommand_to="manage", help="list things", aliases=["ls"]) + def manage_list(self) -> None: + self.poutput("listing all") + + # Level 2: intermediate subcommand (also a base for level 3) + @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 + @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"), + pytest.param("manage sum 1 2 3", ["sum: 6"], id="subcommand_star_args"), + pytest.param("manage sum", ["sum: 0"], id="subcommand_star_args_empty"), + ], + ) + def test_subcommand_executes(self, subcmd_app, command, expected) -> None: + out, _err = run_cmd(subcmd_app, command) + assert out == expected + + @pytest.mark.parametrize( + ("command", "expected_error"), + [ + pytest.param("manage", "the following arguments are required: SUBCOMMAND", id="missing_subcmd"), + pytest.param("manage delete", "invalid choice: 'delete'", id="invalid_subcmd"), + pytest.param("manage member", "the following arguments are required: SUBCOMMAND", id="missing_nested_subcmd"), + ], + ) + def test_subcommand_errors(self, subcmd_app, command, expected_error) -> None: + _out, err = run_cmd(subcmd_app, command) + assert any(expected_error in line for line in err), f"expected {expected_error!r} in {err}" + + def test_subcommand_help(self, subcmd_app) -> None: + out, _err = run_cmd(subcmd_app, "help manage") + help_text = "\n".join(out) + assert "add" in help_text + assert "list" in help_text + assert "member" in help_text + + +class TestSubcommandValidation: + def test_subcommand_aliases_none_raises(self) -> None: + """aliases=None is off-spec (it must be a Sequence[str]); reject it with a clear message.""" + with pytest.raises(TypeError, match=r"aliases must be a sequence"): + with_annotated(subcommand_to="team", aliases=None) + + def test_base_command_positional_str_raises(self) -> None: + """Positional str param conflicts with subcommand name.""" + with pytest.raises(TypeError, match="positional"): + + @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"): + + @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"): + + @with_annotated(base_command=True) + def do_bad(self, verbose: bool = False) -> None: + pass + + def test_base_command_missing_handler_raises_with_no_parameters(self) -> None: + """A zero-parameter base command with no cmd2_handler must still raise. + + Guards the function-level ``cmd2_handler`` check (a plain ``if`` in ``_resolve_parameters``, + not a :data:`_CONSTRAINTS` row): the per-argument :data:`_CONSTRAINTS` loop never runs when + no arguments exist, so this case is the sole reason the missing-handler check lives at + function scope. + """ + with pytest.raises(TypeError, match="cmd2_handler"): + + @with_annotated(base_command=True) + def do_bad(self) -> None: + pass + + def test_cmd2_handler_without_base_command_raises(self) -> None: + """A 'cmd2_handler' parameter is only valid when base_command=True.""" + with pytest.raises(TypeError, match="base_command=True"): + + @with_annotated + def do_bad(self, cmd2_handler, name: str = "") -> None: + pass + + @pytest.mark.parametrize( + "kwargs", + [ + pytest.param({"help": "not allowed"}, id="help_only"), + pytest.param({"aliases": ["x"]}, id="aliases_only"), + pytest.param({"deprecated": True}, id="deprecated_only"), + ], + ) + def test_subcmd_only_params_without_subcommand_to_raises(self, kwargs) -> None: + with pytest.raises(TypeError, match="subcommand_to"): + + @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): + + @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): + @with_annotated(base_command=True) + def do_fmt(self, cmd2_handler) -> None: + handler = cmd2_handler + if handler: + handler() + + @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}") + + 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"): + + @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"): + + @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"): + with_annotated(subcommand_to=subcommand_to)(ns[func_name]) + + @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 + + @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 == expected_help + assert spec.aliases == expected_aliases + assert isinstance(spec.parser_source(), argparse.ArgumentParser) + + @pytest.mark.parametrize("deprecated", [True, False]) + def test_subcommand_deprecated_flows_to_spec(self, deprecated) -> None: + from cmd2 import constants + + @with_annotated(subcommand_to="team", deprecated=deprecated) + def team_create(self, name: str = "") -> None: ... + + spec = getattr(team_create, constants.SUBCMD_ATTR_SPEC) + assert spec.deprecated is deprecated + + +# --------------------------------------------------------------------------- +# A non-Optional type with a None default is rejected (None would violate the hint) +# --------------------------------------------------------------------------- + + +class TestNoneDefaultRejection: + @pytest.mark.parametrize( + ("annotation", "kind"), + [ + pytest.param(str, "pos", id="str"), + pytest.param(int, "pos", id="int"), + pytest.param(list[str], "pos", id="list"), + pytest.param(Annotated[str, Option("-n")], "pos", id="option"), + pytest.param(Annotated[str, Argument()], "pos", id="argument"), + pytest.param(str, "kw", id="kw_only"), + ], + ) + def test_none_default_raises(self, annotation, kind) -> None: + _assert_build_error(annotation, default=None, kind=kind, match="not Optional") + + def test_optional_none_default_builds(self) -> None: + parser = build_parser_from_function(_make_func(str | None, default=None)) + assert parser.parse_args([]).value is None + + @pytest.mark.parametrize( + "annotation", + [ + pytest.param(Any, id="any"), + pytest.param(object, id="object"), + pytest.param(_MISSING, id="unannotated"), + ], + ) + def test_none_accepting_types_exempt(self, annotation) -> None: + assert build_parser_from_function(_make_func(annotation, default=None)).parse_args([]).value is None + + +# --------------------------------------------------------------------------- +# Tightened action= contract (result type must match the declared type) +# --------------------------------------------------------------------------- + + +class TestActionTightening: + @pytest.mark.parametrize( + ("annotation", "pattern"), + [ + pytest.param(Annotated[str, Option("-t", action="append")], "list", id="append_on_scalar"), + pytest.param(Annotated[set[str], Option("-t", action="append")], "list", id="append_on_set"), + pytest.param(Annotated[tuple[str, str], Option("-t", action="append")], "list", id="append_on_tuple"), + pytest.param(Annotated[str, Option("-t", action="extend")], "list", id="extend_on_scalar"), + pytest.param(Annotated[bool, Option("-c", action="count")], "int", id="count_on_bool"), + pytest.param(Annotated[str, Option("-c", action="store_const")], "const", id="store_const"), + pytest.param(Annotated[list[str], Option("-c", action="append_const")], "const", id="append_const"), + pytest.param(Annotated[str, Option("-c", action="frobnicate")], "not supported", id="unknown_action"), + pytest.param(Annotated[list[str], Option("-c", action="store_true")], "collection", id="noncollection_action"), + pytest.param(Annotated[int, Option("-c", action="store")], "not supported", id="explicit_store"), + pytest.param(Annotated[int, Option("-c", action="store", const=5)], "ambiguous", id="store_with_const"), + # The ambiguity wins even when the const's type also mismatches the declared type. + pytest.param(Annotated[int, Option("-c", action="store", const="x")], "ambiguous", id="store_with_mistyped_const"), + ], + ) + def test_action_type_mismatch_raises(self, annotation, pattern) -> None: + _assert_build_error(annotation, match=pattern) + + def test_append_on_list_accumulates(self) -> None: + parser = build_parser_from_function(_make_func(Annotated[list[str], Option("--tag", action="append")])) + assert parser.parse_args([]).value == [] + assert parser.parse_args(["--tag", "a", "--tag", "b"]).value == ["a", "b"] + + def test_extend_on_list_flattens_and_converts(self) -> None: + parser = build_parser_from_function(_make_func(Annotated[list[int], Option("-n", action="extend")])) + assert parser.parse_args([]).value == [] + assert parser.parse_args(["-n", "1", "2", "-n", "3"]).value == [1, 2, 3] + + def test_count_no_default_yields_zero(self) -> None: + annotation = Annotated[int, Option("-l", action="count")] + action = _action_for(annotation) + assert action.required is False + assert build_parser_from_function(_make_func(annotation)).parse_args([]).value == 0 + + def test_append_with_nargs_raises(self) -> None: + _assert_build_error(Annotated[list[str], Option("--tag", action="append", nargs=2)], match="nargs") + + @pytest.mark.parametrize( + ("annotation", "omit_value"), + [ + pytest.param(Annotated[int | None, Option("-c", action="count")], None, id="count_optional"), + pytest.param(Annotated[list[str] | None, Option("-t", action="append")], None, id="append_optional"), + pytest.param(Annotated[bool | None, Option("-v", action="store_true")], None, id="store_true_optional"), + pytest.param(Annotated[bool | None, Option("-q", action="store_false")], None, id="store_false_optional"), + ], + ) + def test_self_defaulting_action_optional_yields_none(self, annotation, omit_value) -> None: + assert build_parser_from_function(_make_func(annotation)).parse_args([]).value is omit_value + + @pytest.mark.parametrize( + ("annotation", "omit_value"), + [ + pytest.param(Annotated[bool, Option("-v", action="store_true")], False, id="store_true"), + pytest.param(Annotated[bool, Option("-q", action="store_false")], True, id="store_false"), + ], + ) + def test_store_bool_action_non_optional_default(self, annotation, omit_value) -> None: + """A non-Optional store_true/store_false still defaults to its natural absence value.""" + assert build_parser_from_function(_make_func(annotation)).parse_args([]).value is omit_value + + +# --------------------------------------------------------------------------- +# const= on Option (store_const / append_const), inferred from the declared type +# --------------------------------------------------------------------------- + + +class TestConstOption: + def test_const_to_kwargs(self) -> None: + assert Option("-c", const=5).to_kwargs()["const"] == 5 + + def test_const_none_to_kwargs_preserved(self) -> None: + """An explicit const=None is kept (distinct from no const given).""" + assert Option("-c", const=None).to_kwargs()["const"] is None + assert "const" not in Option("-c").to_kwargs() + + def test_const_on_scalar_infers_store_const(self) -> None: + p = build_parser_from_function(_make_func(Annotated[int, Option("-v", const=2)], default=0)) + assert p.parse_args([]).value == 0 + assert p.parse_args(["-v"]).value == 2 + + def test_const_on_list_infers_append_const(self) -> None: + p = build_parser_from_function(_make_func(Annotated[list[str], Option("--tag", const="x")])) + assert p.parse_args([]).value == [] + assert p.parse_args(["--tag", "--tag"]).value == ["x", "x"] + + def test_explicit_store_const(self) -> None: + annotation = Annotated[int, Option("-v", action="store_const", const=2)] + assert build_parser_from_function(_make_func(annotation, default=0)).parse_args(["-v"]).value == 2 + + def test_explicit_append_const(self) -> None: + annotation = Annotated[list[str], Option("--tag", action="append_const", const="x")] + assert build_parser_from_function(_make_func(annotation)).parse_args(["--tag"]).value == ["x"] + + def test_store_const_optional_absent_is_none(self) -> None: + p = build_parser_from_function(_make_func(Annotated[str | None, Option("-m", const="fast")], default=None)) + assert p.parse_args([]).value is None + assert p.parse_args(["-m"]).value == "fast" + + def test_const_enum_member_stored_as_member(self) -> None: + class Color(enum.Enum): + RED = "red" + GREEN = "green" + + annotation = Annotated[Color | None, Option("-c", const=Color.RED)] + assert build_parser_from_function(_make_func(annotation, default=None)).parse_args(["-c"]).value is Color.RED + + @pytest.mark.parametrize( + ("annotation", "default", "expected"), + [ + # const matching the declared (non-str) scalar type is accepted and stored verbatim. + pytest.param(Annotated[float, Option("-x", const=1.5)], 0.0, 1.5, id="float_const"), + pytest.param(Annotated[float, Option("-x", const=5)], 0.0, 5, id="float_const_int_ok"), + pytest.param( + Annotated[decimal.Decimal, Option("-x", const=decimal.Decimal("1.5"))], + decimal.Decimal(0), + decimal.Decimal("1.5"), + id="decimal_const", + ), + pytest.param(Annotated[Path, Option("-x", const=Path("/tmp"))], Path("."), Path("/tmp"), id="path_const"), + ], + ) + def test_const_matching_scalar_type_accepted(self, annotation, default, expected) -> None: + p = build_parser_from_function(_make_func(annotation, default=default)) + assert p.parse_args(["-x"]).value == expected + + def test_path_store_const_drops_inferred_completer(self) -> None: + """A Path const infers store_const (a zero-arg action); the inferred path completer must be + dropped, else argparse rejects a completer on an action that takes no value.""" + p = build_parser_from_function(_make_func(Annotated[Path, Option("-x", const=Path("/tmp"))], default=Path("."))) + assert p.parse_args([]).value == Path(".") + assert p.parse_args(["-x"]).value == Path("/tmp") + + def test_path_append_const_drops_inferred_completer(self) -> None: + """append_const on list[Path] is also a zero-arg action; its inferred completer is dropped too.""" + p = build_parser_from_function( + _make_func(Annotated[list[Path], Option("-x", action="append_const", const=Path("/tmp"))]) + ) + assert p.parse_args(["-x", "-x"]).value == [Path("/tmp"), Path("/tmp")] + + # --- error cases --- + + @pytest.mark.parametrize( + ("annotation", "default", "match"), + [ + pytest.param(Annotated[int, Option("-n", const="notanint")], 0, "const", id="type_mismatch_scalar"), + pytest.param(Annotated[Literal["a", "b"], Option("-m", const="c")], "a", "const", id="not_a_literal_member"), + pytest.param(Annotated[float, Option("-x", const="s")], 0.0, "const", id="type_mismatch_float"), + pytest.param(Annotated[float, Option("-x", const=True)], 0.0, "const", id="float_const_rejects_bool"), + pytest.param( + Annotated[decimal.Decimal, Option("-x", const=5)], decimal.Decimal(0), "const", id="type_mismatch_decimal" + ), + pytest.param(Annotated[Path, Option("-x", const="/tmp")], Path("."), "const", id="type_mismatch_path"), + pytest.param( + Annotated[list[str], Option("-x", action="store_const", const="v")], + _MISSING, + "'list' is a collection", + id="store_const_on_list", + ), + pytest.param( + Annotated[str, Option("-x", action="append_const", const="v")], + "", + "'str' is not a list", + id="append_const_on_scalar", + ), + pytest.param(Annotated[str, Option("-x", action="store_const")], "", "const", id="action_without_const"), + pytest.param(Annotated[int, Option("-x", action="count", const=2)], 0, "const", id="incompatible_action"), + pytest.param(Annotated[set[str] | None, Option("-x", const="v")], None, "'set' is not a list", id="const_on_set"), + pytest.param(Annotated[int, Option("-x", const=5)], _MISSING, "default", id="non_optional_no_default"), + ], + ) + def test_const_error(self, annotation, default, match) -> None: + _assert_build_error(annotation, default=default, match=match) + + +class TestNargsOptionalConst: + """A scalar Option with an explicit nargs + const is argparse's optional-value-with-fallback idiom. + + It must NOT collapse to a value-less store_const (which would drop the explicit nargs and the type + converter); it keeps the ``store`` action so absent -> default, bare flag -> const, flag VALUE -> value. + """ + + def test_str_optional_nargs_const_three_way(self) -> None: + annotation = Annotated[str | None, Option("--log", nargs="?", const="CONSOLE")] + p = build_parser_from_function(_make_func(annotation, name="log", default="OFF")) + act = next(a for a in p._actions if a.dest == "log") + assert isinstance(act, argparse._StoreAction) # not _StoreConstAction + assert act.nargs == "?" + assert act.const == "CONSOLE" + assert p.parse_args([]).log == "OFF" # absent -> default + assert p.parse_args(["--log"]).log == "CONSOLE" # bare flag -> const + assert p.parse_args(["--log", "FILE"]).log == "FILE" # flag VALUE -> value + + def test_int_optional_nargs_const_converts_supplied_value(self) -> None: + """The supplied VALUE still runs through the inferred converter; the const is stored verbatim.""" + annotation = Annotated[int | None, Option("--n", nargs="?", const=99)] + p = build_parser_from_function(_make_func(annotation, name="n", default=0)) + assert p.parse_args([]).n == 0 + assert p.parse_args(["--n"]).n == 99 + assert p.parse_args(["--n", "7"]).n == 7 # type=int applied to the value + + def test_nargs_const_still_validates_const_type(self) -> None: + """const must still match the declared type even on the nargs='?' path (stored verbatim).""" + _assert_build_error(Annotated[int, Option("--n", nargs="?", const="notint")], name="n", default=0, match="const") + + def test_scalar_const_without_nargs_still_store_const(self) -> None: + """Regression guard: const alone (no nargs) keeps inferring the value-less store_const.""" + p = build_parser_from_function(_make_func(Annotated[str, Option("--log", const="X")], name="log", default="off")) + act = next(a for a in p._actions if a.dest == "log") + assert isinstance(act, argparse._StoreConstAction) + assert p.parse_args(["--log"]).log == "X" + with pytest.raises(SystemExit): + p.parse_args(["--log", "Y"]) # value-less action rejects a supplied value + + +class TestZeroArgActionRejectsUserCompletion: + """A user-supplied completer/choices_provider on a value-less action has nothing to complete. + + Raw cmd2 raises in this case, so @with_annotated must fail loud rather than silently dropping the + user's request. A *type-inferred* completer (e.g. Path's) is still dropped silently -- only an + explicit one is rejected. + """ + + def _provider(self) -> list[str]: + return ["a", "b"] + + @pytest.mark.parametrize("action", ["store_true", "store_false"]) + def test_bool_flag_action_rejects_user_completer(self, action) -> None: + annotation = Annotated[bool, Option("--flag", action=action, completer=cmd2.Cmd.path_complete)] + _assert_build_error(annotation, name="flag", default=False, match="cannot be used with action") + + def test_count_action_rejects_user_choices_provider(self) -> None: + annotation = Annotated[ + int, Option("-v", action="count", choices_provider=TestZeroArgActionRejectsUserCompletion._provider) + ] + _assert_build_error(annotation, name="v", default=0, match="cannot be used with action") + + def test_store_const_rejects_user_completer(self) -> None: + annotation = Annotated[str, Option("-m", action="store_const", const="x", completer=cmd2.Cmd.path_complete)] + _assert_build_error(annotation, name="m", default="off", match="cannot be used with action") + + def test_append_const_rejects_user_choices_provider(self) -> None: + annotation = Annotated[ + list[str], + Option( + "--tag", action="append_const", const="x", choices_provider=TestZeroArgActionRejectsUserCompletion._provider + ), + ] + _assert_build_error(annotation, name="tag", match="cannot be used with action") + + def test_value_action_keeps_user_completer(self) -> None: + """Control: a value-consuming option still accepts a user completer (not a zero-arg action).""" + annotation = Annotated[str, Option("--path", completer=cmd2.Cmd.path_complete)] + p = build_parser_from_function(_make_func(annotation, name="path", default="")) + act = next(a for a in p._actions if a.dest == "path") + assert act.get_completer() is cmd2.Cmd.path_complete + + +class TestConstArgumentRejected: + def test_argument_const_raises(self) -> None: + _assert_build_error(Annotated[str, Argument(const="x")], match="const") + + def test_positional_const_via_no_metadata_path(self) -> None: + """A const on a parameter that resolves to a positional is rejected, not silently ignored.""" + + def do_x(self, name: Annotated[str, Argument(const="x")], other: str = "y") -> None: ... + + with pytest.raises(TypeError, match="const"): + build_parser_from_function(do_x) + + +# --------------------------------------------------------------------------- +# Custom ``argparse.Action`` subclasses pass through to ``add_argument`` +# --------------------------------------------------------------------------- + + +class _UpperAction(argparse.Action): + """Test action: store the upper-case value.""" + + def __call__( + self, + _parser: argparse.ArgumentParser, + ns: argparse.Namespace, + values: Any, + _option_string: str | None = None, + ) -> None: + setattr(ns, self.dest, values.upper() if isinstance(values, str) else values) + + +class _FlagAction(argparse.Action): + """Test action: a presence-flag that takes no command-line value.""" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(nargs=0, **kwargs) + + def __call__( + self, + _parser: argparse.ArgumentParser, + ns: argparse.Namespace, + _values: Any, + _option_string: str | None = None, + ) -> None: + setattr(ns, self.dest, True) + + +class _ListAction(argparse.Action): + """Test action: store values as a plain list (no container_factory wrap).""" + + def __call__( + self, + _parser: argparse.ArgumentParser, + ns: argparse.Namespace, + values: Any, + _option_string: str | None = None, + ) -> None: + setattr(ns, self.dest, list(values)) + + +class _ConstAction(argparse.Action): + """Test action: store ``self.const`` on presence.""" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(nargs=0, **kwargs) + + def __call__( + self, + _parser: argparse.ArgumentParser, + ns: argparse.Namespace, + _values: Any, + _option_string: str | None = None, + ) -> None: + setattr(ns, self.dest, self.const) + + +class TestCustomActionClass: + def test_scalar_class_action_passes_through(self) -> None: + """A class action runs verbatim; the type-inferred passthrough doesn't get in the way.""" + + def f(self, name: Annotated[str, Option("--name", action=_UpperAction)] = "") -> None: ... + + parser = build_parser_from_function(f) + assert parser.parse_args(["--name", "hi"]).name == "HI" + + def test_class_action_overrides_inferred_boolean_optional(self) -> None: + """A class action on a bool option replaces the inferred ``BooleanOptionalAction``.""" + + def f(self, loud: Annotated[bool, Option("--loud", action=_FlagAction)] = False) -> None: ... + + parser = build_parser_from_function(f) + assert parser.parse_args(["--loud"]).loud is True + + def test_class_action_on_list_drops_container_factory(self) -> None: + """A class action on ``list[T]`` doesn't receive the casting action's ``container_factory``.""" + + def f(self, xs: Annotated[list[str], Option("--xs", action=_ListAction, nargs="+")] = ()) -> None: ... + + parser = build_parser_from_function(f) + assert parser.parse_args(["--xs", "a", "b"]).xs == ["a", "b"] + + def test_class_action_with_const_skips_type_check(self) -> None: + """``const`` paired with a class action is not validated against the declared type.""" + sentinel = object() + + def f(self, mode: Annotated[str, Option("-m", action=_ConstAction, const=sentinel)] = "x") -> None: ... + + parser = build_parser_from_function(f) + assert parser.parse_args(["-m"]).mode is sentinel + + def test_class_action_uses_inferred_converter_on_scalar(self) -> None: + """The inferred ``type=int`` reaches the class action so values still coerce.""" + + class CaptureAction(argparse.Action): + def __call__(self, _p: Any, ns: Any, values: Any, _o: str | None = None) -> None: + setattr(ns, self.dest, ("seen", values)) + + def f(self, n: Annotated[int, Option("-n", action=CaptureAction)] = 0) -> None: ... + + parser = build_parser_from_function(f) + assert parser.parse_args(["-n", "5"]).n == ("seen", 5) + + def test_unknown_string_action_still_rejected(self) -> None: + """A typo in an action string is still caught -- only class actions are exempt.""" + _assert_build_error(Annotated[str, Option("-x", action="frobnicate")], match="not supported") + + +# --------------------------------------------------------------------------- +# ``extra_kwargs`` rejects argparse kwargs that the decorator derives elsewhere +# --------------------------------------------------------------------------- + + +class TestReservedExtraKwargs: + @pytest.mark.parametrize("kw", ["type", "dest", "action", "required"]) + def test_argument_rejects_reserved_kwarg(self, kw: str) -> None: + with pytest.raises(TypeError, match=kw): + Argument(**{kw: "x"}) + + @pytest.mark.parametrize("kw", ["type", "dest"]) + def test_option_rejects_reserved_kwarg(self, kw: str) -> None: + with pytest.raises(TypeError, match=kw): + Option("--x", **{kw: "x"}) + + def test_option_keeps_named_action_and_required(self) -> None: + """``action=`` and ``required=`` are named parameters of ``Option`` and must still work.""" + opt = Option("--x", action="store_true", required=True) + assert opt.action == "store_true" + assert opt.required is True + + def test_error_message_includes_remediation_hint(self) -> None: + """The error names the offending kwarg and points at the signature-derived source.""" + with pytest.raises(TypeError) as excinfo: + Option("--x", type=int) + msg = str(excinfo.value) + assert "type" in msg + assert "annotation" in msg + + def test_unknown_registered_kwarg_still_passes_through(self) -> None: + """A user-registered custom add_argument kwarg still flows through ``extra_kwargs``.""" + # The custom parameter ``annotated_custom_attr`` is registered at module import. + action = _action_for(Annotated[str, Argument(annotated_custom_attr="value")]) + assert action.get_annotated_custom_attr() == "value" # type: ignore[attr-defined] + + +# --------------------------------------------------------------------------- +# ``default=`` as a metadata kwarg: equivalent to a signature default, with conflict and +# SUPPRESS rejected. +# --------------------------------------------------------------------------- + + +class TestMetadataDefault: + def test_option_metadata_default_equivalent_to_signature_default(self) -> None: + """``Option(default=v)`` (no sig default) behaves the same as ``Option() = v``.""" + + def f_meta(self, name: Annotated[str, Option("--name", default="HI")]) -> None: ... + def f_sig(self, name: Annotated[str, Option("--name")] = "HI") -> None: ... + + p_meta = build_parser_from_function(f_meta) + p_sig = build_parser_from_function(f_sig) + assert p_meta.parse_args([]).name == p_sig.parse_args([]).name == "HI" + assert p_meta.parse_args(["--name", "X"]).name == p_sig.parse_args(["--name", "X"]).name == "X" + + def test_argument_metadata_default_equivalent_to_signature_default(self) -> None: + """``Argument(default=v)`` (no sig default) behaves the same as ``Argument() = v``.""" + + def f_meta(self, name: Annotated[str, Argument(default="POS")]) -> None: ... + def f_sig(self, name: Annotated[str, Argument()] = "POS") -> None: ... + + p_meta = build_parser_from_function(f_meta) + p_sig = build_parser_from_function(f_sig) + assert p_meta.parse_args([]).name == p_sig.parse_args([]).name == "POS" + assert p_meta.parse_args(["X"]).name == p_sig.parse_args(["X"]).name == "X" + + def test_metadata_default_makes_option_not_required(self) -> None: + """A metadata default removes ``required`` -- same as a signature default.""" + action = _action_for(Annotated[str, Option("--x", default="HI")]) + assert action.required is False + + def test_metadata_default_makes_positional_nargs_optional(self) -> None: + """A metadata default on a positional sets ``nargs='?'`` -- same as a signature default.""" + action = _action_for(Annotated[str, Argument(default="HI")]) + assert action.nargs == "?" + + def test_default_conflict_signature_and_metadata_raises(self) -> None: + """Both a signature default and a metadata default is a conflict.""" + + def f(self, name: Annotated[str, Option("--name", default="HI")] = "hello") -> None: ... + + with pytest.raises(TypeError, match="default in both"): + build_parser_from_function(f) + + def test_default_conflict_message_names_both_values(self) -> None: + """The conflict error message includes both candidate values.""" + + def f(self, name: Annotated[str, Option("--name", default="meta")] = "sig") -> None: ... + + with pytest.raises(TypeError) as excinfo: + build_parser_from_function(f) + msg = str(excinfo.value) + assert "'sig'" in msg + assert "'meta'" in msg + + def test_argparse_suppress_metadata_default_rejected(self) -> None: + """``default=argparse.SUPPRESS`` in metadata is rejected (kwarg would vanish).""" + + def f(self, name: Annotated[str, Option("--name", default=argparse.SUPPRESS)]) -> None: ... + + with pytest.raises(TypeError, match="SUPPRESS"): + build_parser_from_function(f) + + def test_argparse_suppress_signature_default_rejected(self) -> None: + """``= argparse.SUPPRESS`` in the signature is rejected as well.""" + + def f(self, name: str = argparse.SUPPRESS) -> None: ... + + with pytest.raises(TypeError, match="SUPPRESS"): + build_parser_from_function(f) + + def test_metadata_default_none_on_non_optional_rejected(self) -> None: + """The 'None default on non-Optional' rule applies to a metadata default as well.""" + _assert_build_error(Annotated[str, Option("--x", default=None)], match="None") + + def test_metadata_default_none_on_optional_accepted(self) -> None: + """``default=None`` on a ``T | None`` annotation is fine (consistent with signature defaults).""" + action = _action_for(Annotated[str | None, Option("--x", default=None)]) + assert action.default is None + assert action.required is False + + def test_metadata_default_with_explicit_action(self) -> None: + """A metadata default flows through to the action layer (here: store_const).""" + annotation = Annotated[int, Option("-v", action="store_const", const=2, default=0)] + parser = build_parser_from_function(_make_func(annotation)) + assert parser.parse_args([]).value == 0 + assert parser.parse_args(["-v"]).value == 2 + + +# --------------------------------------------------------------------------- +# A variable-arity positional must be the last positional +# --------------------------------------------------------------------------- + + +class TestPositionalOrdering: + def test_optional_positional_before_required_raises(self) -> None: + def do_x(self, a: str | None, b: str) -> None: ... + + with pytest.raises(TypeError, match="variable arity"): + build_parser_from_function(do_x) + + def test_list_positional_before_positional_raises(self) -> None: + def do_x(self, items: list[str], b: str) -> None: ... + + with pytest.raises(TypeError, match="variable arity"): + build_parser_from_function(do_x) + + def test_required_positional_before_star_args_builds(self) -> None: + def do_x(self, a: str, *args: str) -> None: ... + + parser = build_parser_from_function(do_x) + ns = parser.parse_args(["one", "two", "three"]) + assert ns.a == "one" + assert ns.args == ("two", "three") + + def test_ranged_nargs_collection_before_positional_raises(self) -> None: + def do_x(self, items: Annotated[list[str], Argument(nargs=(1, 3))], b: str) -> None: ... + + with pytest.raises(TypeError, match="variable arity"): + build_parser_from_function(do_x) + + +# --------------------------------------------------------------------------- +# Ranged nargs (min, max) tuples +# --------------------------------------------------------------------------- + + +class TestRangedNargs: + def test_ranged_nargs_on_scalar_raises(self) -> None: + _assert_build_error(Annotated[str, Argument(nargs=(2, 4))], match="not a collection type") + + def test_ranged_nargs_on_list_builds(self) -> None: + parser = build_parser_from_function(_make_func(Annotated[list[str], Argument(nargs=(2, 4))])) + assert parser.parse_args(["a", "b", "c"]).value == ["a", "b", "c"] + + def test_ranged_nargs_zero_one_on_scalar_builds(self) -> None: + # cmd2 collapses the (0, 1) range to OPTIONAL ('?'), which yields a single optional value + # (not a list), so it is allowed on a scalar, like nargs='?'. Absent -> None. + parser = build_parser_from_function(_make_func(Annotated[str, Argument(nargs=(0, 1))])) + assert parser.parse_args(["x"]).value == "x" + assert parser.parse_args([]).value is None + + def test_optional_scalar_positional_nargs_question_builds(self) -> None: + # An explicit nargs='?' on a scalar positional is the standard argparse optional-positional; + # it is allowed (absent -> None). Developers wanting type-safe absence use 'T | None'. + parser = build_parser_from_function(_make_func(Annotated[int, Argument(nargs="?")])) + assert parser.parse_args(["5"]).value == 5 + assert parser.parse_args([]).value is None + + +# --------------------------------------------------------------------------- +# Subcommands group configuration (required / metavar / title / description) +# --------------------------------------------------------------------------- + + +class TestSubcommandGroupConfig: + @staticmethod + def _base_parser(**subcommand_kwargs): + from cmd2 import constants + + @with_annotated(base_command=True, **subcommand_kwargs) + def do_root(self, cmd2_handler) -> None: ... + + builder = getattr(do_root, constants.CMD_ATTR_PARSER_SOURCE) + return builder() + + @staticmethod + def _subparsers_action(parser): + return next(a for a in parser._actions if isinstance(a, argparse._SubParsersAction)) + + def test_defaults_required_with_subcommand_metavar(self) -> None: + action = self._subparsers_action(self._base_parser()) + assert action.required is True + assert action.metavar == "SUBCOMMAND" + + def test_subcommand_required_false(self) -> None: + action = self._subparsers_action(self._base_parser(subcommand_required=False)) + assert action.required is False + + def test_subcommand_metavar_override(self) -> None: + action = self._subparsers_action(self._base_parser(subcommand_metavar="CMD")) + assert action.metavar == "CMD" + + def test_subcommand_title_and_description(self) -> None: + parser = self._base_parser(subcommand_title="Commands", subcommand_description="pick one") + group = next((g for g in parser._action_groups if g.title == "Commands"), None) + assert group is not None + assert group.description == "pick one" + + +# --------------------------------------------------------------------------- +# Rich objects are accepted for description / epilog (HelpContent) +# --------------------------------------------------------------------------- + + +class TestRichHelpContent: + def test_rich_description_and_epilog_accepted(self) -> None: + from rich.text import Text + + desc = Text("a rich description") + parser = build_parser_from_function(_make_func(str), description=desc, epilog=Text("epilog")) + assert parser.description is desc + + +# --------------------------------------------------------------------------- +# Docstring auto-extraction for parser description +# --------------------------------------------------------------------------- + + +class TestDocstringDescription: + """The first paragraph of ``func.__doc__`` fills ``description`` when none is given.""" + + def test_first_paragraph_used_when_no_description(self) -> None: + def func(self, name: str) -> None: + """Summary line for the command. + + More detail here that should not appear in description. + + :param name: a name + """ + + parser = build_parser_from_function(func) + assert parser.description == "Summary line for the command." + + def test_multiline_first_paragraph_preserved(self) -> None: + def func(self, name: str) -> None: + """First line continues + onto the second line without a blank gap. + + Detail paragraph below the blank line is dropped. + """ + + parser = build_parser_from_function(func) + assert parser.description == "First line continues\nonto the second line without a blank gap." + + def test_explicit_description_overrides_docstring(self) -> None: + def func(self, name: str) -> None: + """Auto summary.""" + + parser = build_parser_from_function(func, description="explicit") + assert parser.description == "explicit" + + def test_no_docstring_means_no_description(self) -> None: + def func(self, name: str) -> None: ... + + parser = build_parser_from_function(func) + assert parser.description is None + + def test_empty_docstring_means_no_description(self) -> None: + def func(self, name: str) -> None: + """ """ + + parser = build_parser_from_function(func) + assert parser.description is None + + def test_field_directive_without_blank_line_does_not_leak(self) -> None: + """A ``:param:`` directly under the summary (no blank line) is stripped, not leaked.""" + + def func(self, name: str) -> None: + """Summary line. + :param name: should not leak into the description + """ + + parser = build_parser_from_function(func) + assert parser.description == "Summary line." + + def test_decorator_uses_docstring(self) -> None: + from cmd2 import constants + + @with_annotated + def do_run(self, name: str) -> None: + """Run the thing. + + Extra detail. + """ + + builder = getattr(do_run, constants.CMD_ATTR_PARSER_SOURCE) + assert builder().description == "Run the thing." + + def test_subcommand_uses_docstring(self) -> None: + from cmd2 import constants + + @with_annotated(subcommand_to="team") + def team_add(self, name: str) -> None: + """Add a member to the team.""" + + spec = getattr(team_add, constants.SUBCMD_ATTR_SPEC) + assert spec.parser_source().description == "Add a member to the team." + + +# --------------------------------------------------------------------------- +# Group(required=...) for mutually exclusive groups +# --------------------------------------------------------------------------- + + +class TestMutuallyExclusiveGroupRequired: + """``Group(required=True)`` reaches ``add_mutually_exclusive_group(required=True)``.""" + + def test_required_mutex_group_flag_set(self) -> None: + def func(self, verbose: bool = False, quiet: bool = False) -> None: ... + + parser = build_parser_from_function(func, mutually_exclusive_groups=(Group("verbose", "quiet", required=True),)) + assert parser._mutually_exclusive_groups[0].required is True + + def test_default_mutex_group_not_required(self) -> None: + def func(self, verbose: bool = False, quiet: bool = False) -> None: ... + + parser = build_parser_from_function(func, mutually_exclusive_groups=(Group("verbose", "quiet"),)) + assert parser._mutually_exclusive_groups[0].required is False + + def test_required_mutex_group_argparse_enforces(self) -> None: + def func(self, verbose: bool = False, quiet: bool = False) -> None: ... + + parser = build_parser_from_function(func, mutually_exclusive_groups=(Group("verbose", "quiet", required=True),)) + with pytest.raises(SystemExit): + parser.parse_args([]) + + def test_required_on_plain_group_rejected(self) -> None: + """``required=True`` is only valid in mutex groups; argparse doesn't support it elsewhere.""" + + def func(self, host: str, port: int = 22) -> None: ... + + with pytest.raises(ValueError, match="only valid in mutually_exclusive_groups"): + build_parser_from_function(func, groups=(Group("host", "port", required=True),)) + + +# --------------------------------------------------------------------------- +# Parser-level kwargs: prog / usage / parents / argument_default +# --------------------------------------------------------------------------- + + +class TestParserLevelKwargs: + """Forward ``prog``, ``usage``, ``parents``, ``argument_default`` to the parser ctor.""" + + def test_prog_passthrough(self) -> None: + parser = build_parser_from_function(_make_func(str), prog="myprog") + assert parser.prog == "myprog" + + def test_usage_passthrough(self) -> None: + parser = build_parser_from_function(_make_func(str), usage="usage: do stuff") + assert parser.usage == "usage: do stuff" + + def test_parents_passthrough(self) -> None: + """argparse ``parents=`` copies argument actions from each parent into the new parser.""" + parent = argparse.ArgumentParser(add_help=False) + parent.add_argument("--shared", help="from parent") + + parser = build_parser_from_function(_make_func(str), parents=[parent]) + dests = {a.dest for a in parser._actions} + assert "shared" in dests + + def test_argument_default_passthrough(self) -> None: + sentinel = "DEFAULT_FROM_PARSER" + parser = build_parser_from_function(_make_func(str), argument_default=sentinel) + assert parser.argument_default == sentinel + + def test_argument_default_suppress_works_with_explicit_defaults(self) -> None: + """``argument_default=SUPPRESS`` is safe when every argument sets its own default. + + Every ``@with_annotated`` argument either is positional (always supplied) or + has an explicit default, so SUPPRESS at the parser level can't drop a kwarg + the function expects. + """ + + def func(self, name: str, count: int = 1) -> None: ... + + parser = build_parser_from_function(func, argument_default=argparse.SUPPRESS) + ns = parser.parse_args(["alice"]) + assert ns.name == "alice" + assert ns.count == 1 + + def test_decorator_passes_parser_kwargs(self) -> None: + from cmd2 import constants + + @with_annotated(prog="myprog", usage="usage line") + def do_run(self, name: str) -> None: ... + + builder = getattr(do_run, constants.CMD_ATTR_PARSER_SOURCE) + parser = builder() + assert parser.prog == "myprog" + assert parser.usage == "usage line" + + def test_prog_rejected_with_subcommand_to(self) -> None: + """cmd2's subcommand machinery rewrites ``prog`` from the parent hierarchy.""" + with pytest.raises(TypeError, match=r"prog .* not supported with subcommand_to"): + + @with_annotated(subcommand_to="team", prog="something") + def team_add(self, name: str) -> None: ... + + def test_usage_allowed_on_subcommand(self) -> None: + """``usage`` doesn't conflict with subcommand prog rewriting.""" + from cmd2 import constants + + @with_annotated(subcommand_to="team", usage="team add NAME") + def team_add(self, name: str) -> None: ... + + spec = getattr(team_add, constants.SUBCMD_ATTR_SPEC) + assert spec.parser_source().usage == "team add NAME" + + def test_parents_allowed_on_subcommand(self) -> None: + from cmd2 import constants + + parent = argparse.ArgumentParser(add_help=False) + parent.add_argument("--shared") + + @with_annotated(subcommand_to="team", parents=[parent]) + def team_add(self, name: str) -> None: ... + + spec = getattr(team_add, constants.SUBCMD_ATTR_SPEC) + dests = {a.dest for a in spec.parser_source()._actions} + assert "shared" in dests + + +# --------------------------------------------------------------------------- +# Less-common parser-level kwargs: prefix_chars / fromfile_prefix_chars / +# conflict_handler / add_help / allow_abbrev / exit_on_error +# --------------------------------------------------------------------------- + + +class TestParserLowLevelKwargs: + """Forward the remaining argparse parser ctor kwargs.""" + + def test_prefix_chars_passthrough(self) -> None: + """A non-default ``prefix_chars`` propagates so ``Option('+flag')`` would be legal.""" + parser = build_parser_from_function(_make_func(str), prefix_chars="+-") + assert parser.prefix_chars == "+-" + + def test_fromfile_prefix_chars_enables_argument_files(self) -> None: + """argparse loads tokens from a file when an arg starts with the prefix char.""" + import tempfile + + def func(self, name: str) -> None: ... + + parser = build_parser_from_function(func, fromfile_prefix_chars="@") + with tempfile.NamedTemporaryFile("w", suffix=".args", delete=False) as fh: + fh.write("alice\n") + path = fh.name + try: + ns = parser.parse_args([f"@{path}"]) + assert ns.name == "alice" + finally: + Path(path).unlink() + + def test_conflict_handler_resolve_lets_parents_be_overridden(self) -> None: + """``conflict_handler='resolve'`` allows a parent's ``--flag`` to be redefined.""" + parent = argparse.ArgumentParser(add_help=False) + parent.add_argument("--mode", default="parent") + + def func(self, mode: str = "child") -> None: ... + + parser = build_parser_from_function(func, parents=[parent], conflict_handler="resolve") + ns = parser.parse_args([]) + # The locally-declared --mode wins after the resolve. + assert ns.mode == "child" + + def test_add_help_false_drops_help_action(self) -> None: + parser = build_parser_from_function(_make_func(str), add_help=False) + assert all(not isinstance(a, argparse._HelpAction) for a in parser._actions) + + def test_allow_abbrev_false_rejects_prefix_match(self) -> None: + """With abbreviations off, ``--verb`` no longer matches ``--verbose``.""" + + def func(self, verbose: bool = False) -> None: ... + + parser = build_parser_from_function(func, allow_abbrev=False) + with pytest.raises(SystemExit): + parser.parse_args(["--verb"]) + + def test_exit_on_error_false_raises_argument_error(self) -> None: + """``exit_on_error=False`` surfaces parse failures as exceptions instead of sys.exit.""" + + def func(self, count: int) -> None: ... + + parser = build_parser_from_function(func, exit_on_error=False) + with pytest.raises(argparse.ArgumentError): + parser.parse_args(["not-an-int"]) + + def test_decorator_threads_all_low_level_kwargs(self) -> None: + """End-to-end: each kwarg lands on the parser when set on the decorator.""" + from cmd2 import constants + + @with_annotated( + prefix_chars="+-", + conflict_handler="resolve", + add_help=False, + allow_abbrev=False, + exit_on_error=False, + fromfile_prefix_chars="@", + ) + def do_run(self, name: str) -> None: ... + + builder = getattr(do_run, constants.CMD_ATTR_PARSER_SOURCE) + parser = builder() + assert parser.prefix_chars == "+-" + assert parser.fromfile_prefix_chars == "@" + assert parser.conflict_handler == "resolve" + assert all(not isinstance(a, argparse._HelpAction) for a in parser._actions) + assert parser.allow_abbrev is False + assert parser.exit_on_error is False + + +class TestExplicitChoicesValueSpace: + """Explicit ``choices=`` are reconciled with the inferred type converter and completer.""" + + def test_string_choices_converted_to_declared_type(self) -> None: + """choices=['1','2'] on an int parameter match after argparse runs the int converter.""" + p = build_parser_from_function(_make_func(Annotated[int, Option("--x", choices=["1", "2"])], default=1, name="x")) + action = next(a for a in p._actions if a.dest == "x") + assert action.choices == [1, 2] + assert p.parse_args(["--x", "1"]).x == 1 + with pytest.raises(SystemExit): + p.parse_args(["--x", "3"]) + + def test_already_typed_choices_left_untouched(self) -> None: + p = build_parser_from_function(_make_func(Annotated[int, Option("--x", choices=[1, 2])], default=1, name="x")) + action = next(a for a in p._actions if a.dest == "x") + assert action.choices == [1, 2] + + def test_choice_invalid_for_type_is_build_error(self) -> None: + _assert_build_error(Annotated[int, Option("--x", choices=["1", "nope"])], default=1, match="not a valid") + + def test_explicit_choices_kept_over_inferred_path_completer(self) -> None: + """An explicit choices= on a Path is retained (and the inferred path completer dropped).""" + p = build_parser_from_function(_make_func(Annotated[Path, Argument(choices=[Path("/a"), Path("/b")])], name="p")) + action = next(a for a in p._actions if a.dest == "p") + assert action.choices == [Path("/a"), Path("/b")] + assert getattr(action, "completer", None) is None + assert p.parse_args(["/a"]).p == Path("/a") + with pytest.raises(SystemExit): + p.parse_args(["/c"]) + + def test_user_completer_still_overrides_choices(self) -> None: + """A user-supplied completer continues to drive completion in place of static choices.""" + + def completer(self, *args: Any) -> list[str]: + return ["x"] + + p = build_parser_from_function( + _make_func(Annotated[str, Option("--x", choices=["a", "b"], completer=completer)], default="a", name="x") + ) + action = next(a for a in p._actions if a.dest == "x") + assert action.choices is None + + +class TestStrConstValidation: + """A const on a str parameter must itself be a str (it is stored verbatim, not converted).""" + + def test_non_str_const_on_str_rejected(self) -> None: + _assert_build_error(Annotated[str, Option("--x", const=123)], default="a", match="does not match") + + def test_str_const_on_str_accepted(self) -> None: + p = build_parser_from_function(_make_func(Annotated[str, Option("--x", const="fast")], default="slow", name="x")) + assert p.parse_args(["--x"]).x == "fast" + + def test_const_on_untyped_param_not_validated(self) -> None: + """Any/object/unannotated are genuinely untyped, so a const of any type is accepted.""" + p = build_parser_from_function(_make_func(Annotated[Any, Option("--x", const=123)], default=None, name="x")) + assert p.parse_args(["--x"]).x == 123 + + +class TestArgumentDefaultSuppressGuard: + """``argument_default=argparse.SUPPRESS`` is rejected when it would drop an omittable argument.""" + + def test_suppress_with_optional_positional_rejected(self) -> None: + def do_t(self, x: int | None): ... + + with pytest.raises(TypeError, match="SUPPRESS"): + build_parser_from_function(do_t, argument_default=argparse.SUPPRESS) + + def test_suppress_safe_when_all_args_required_or_defaulted(self) -> None: + def do_t(self, a: int, b: str = "x"): ... + + # ``a`` is always supplied (required positional); ``b`` carries its own default -> safe. + parser = build_parser_from_function(do_t, argument_default=argparse.SUPPRESS) + assert parser is not None + + def test_suppress_safe_with_var_positional(self) -> None: + def do_t(self, *vals: int): ... + + # *args is substituted with () by the invocation path, so SUPPRESS cannot strand it. + parser = build_parser_from_function(do_t, argument_default=argparse.SUPPRESS) + assert parser is not None + + +class TestHelpKwargReserved: + """Raw ``help=`` is rejected so it cannot silently shadow the mapped ``help_text=``.""" + + def test_raw_help_rejected(self) -> None: + with pytest.raises(TypeError, match="help_text"): + Option("--x", help="raw") + + def test_help_text_still_works(self) -> None: + action = _action_for(Annotated[str, Option("--x", help_text="mapped")], default="a") + assert action.help == "mapped" + + +class TestEnumAcceptsNameAndValue: + """Enum parameters accept member values AND names (documented behavior, locked here).""" + + def test_enum_accepts_both_name_and_value(self) -> None: + class Color(enum.Enum): + RED = "r" + GREEN = "g" + + p = build_parser_from_function(_make_func(Color, name="c")) + assert p.parse_args(["r"]).c is Color.RED + assert p.parse_args(["RED"]).c is Color.RED