diff --git a/cmd2/__init__.py b/cmd2/__init__.py index dbfb5faa0..d9945eeb7 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -11,6 +11,10 @@ rich_utils, string_utils, ) +from .annotated import ( + Argument, + Option, +) from .argparse_completer import set_default_ap_completer_type from .argparse_custom import ( Cmd2ArgumentParser, @@ -35,6 +39,7 @@ ) from .decorators import ( as_subcommand_to, + with_annotated, with_argparser, with_argument_list, with_category, @@ -78,7 +83,11 @@ 'Choices', 'CompletionItem', 'Completions', + # Annotated + 'Argument', + 'Option', # Decorators + 'with_annotated', 'with_argument_list', 'with_argparser', 'with_category', diff --git a/cmd2/annotated.py b/cmd2/annotated.py new file mode 100644 index 000000000..c3b772d35 --- /dev/null +++ b/cmd2/annotated.py @@ -0,0 +1,875 @@ +"""Build argparse parsers from type-annotated function signatures. + +.. warning:: Experimental + + This module is experimental and its behavior may change in future releases. + +This module provides the :func:`with_annotated` decorator that inspects a +command function's type hints and default values to automatically construct +a ``Cmd2ArgumentParser``. It also provides :class:`Argument` and +:class:`Option` metadata classes for use with ``typing.Annotated`` when +finer control is needed. + +Basic usage -- parameters without defaults become positional arguments, +parameters with defaults become ``--option`` flags. Keyword-only +parameters (after ``*``) always become options; without a default they +are required. The parameter name ``dest`` is reserved and cannot be +used:: + + class MyApp(cmd2.Cmd): + @cmd2.with_annotated + def do_greet(self, name: str, count: int = 1, loud: bool = False): + for _ in range(count): + msg = f"Hello {name}" + self.poutput(msg.upper() if loud else msg) + +Use ``Annotated`` with :class:`Argument` or :class:`Option` for finer +control over individual parameters:: + + from typing import Annotated + + class MyApp(cmd2.Cmd): + def color_choices(self) -> cmd2.Choices: + return cmd2.Choices.from_values(["red", "green", "blue"]) + + @cmd2.with_annotated + def do_paint( + self, + item: str, + color: Annotated[str, Option("--color", "-c", + choices_provider=color_choices, + help_text="Color to use")] = "blue", + ): + self.poutput(f"Painting {item} {color}") + +How annotations map to argparse settings: + +- ``str`` -- default string argument +- ``int``, ``float`` -- sets ``type=`` for argparse +- ``bool`` with default -- ``--flag / --no-flag`` via ``BooleanOptionalAction`` +- positional ``bool`` -- parsed from ``true/false``, ``yes/no``, ``on/off``, ``1/0`` +- ``pathlib.Path`` -- sets ``type=Path`` +- ``enum.Enum`` subclass -- ``type=converter``, ``choices`` from member values +- ``decimal.Decimal`` -- sets ``type=Decimal`` +- ``Literal[...]`` -- sets ``type=converter`` and ``choices`` from literal values +- ``list[T]`` / ``set[T]`` / ``tuple[T, ...]`` -- ``nargs='+'`` (or ``'*'`` if has a default) +- ``tuple[T, T]`` (fixed arity, same type) -- ``nargs=N`` with ``type=T`` +- ``T | None`` -- unwrapped to ``T``, treated as optional + +Unsupported patterns (raise ``TypeError``): + +- ``str | int`` -- union of multiple non-None types is ambiguous +- ``tuple[int, str, float]`` -- mixed element types are not currently supported + because argparse can only apply a single ``type=`` converter per argument + +When combining ``Annotated`` with ``Optional``, the union must go +*inside*: ``Annotated[T | None, meta]``. Writing +``Annotated[T, meta] | None`` is ambiguous and raises ``TypeError``. + +Note: ``Path`` and ``Enum`` types also get automatic tab completion via +``ArgparseCompleter`` type inference. This works for both ``@with_annotated`` +and ``@with_argparser`` -- see the ``argparse_completer`` module. +If a user-supplied ``choices_provider`` or ``completer`` is set on an argument, +it always takes priority over the type-inferred completion. +""" + +import argparse +import decimal +import enum +import functools +import inspect +import pathlib +import types +from collections.abc import Callable, Container +from typing import ( + Annotated, + Any, + ClassVar, + Literal, + Union, + get_args, + get_origin, + get_type_hints, +) + +from .types import ChoicesProviderUnbound, CmdOrSet, CompleterUnbound + +# --------------------------------------------------------------------------- +# Metadata classes +# --------------------------------------------------------------------------- + + +class _BaseArgMetadata: + """Shared fields for ``Argument`` and ``Option`` metadata.""" + + _KWARGS_MAP: ClassVar[dict[str, str]] = { + 'help_text': 'help', + 'metavar': 'metavar', + 'choices': 'choices', + 'choices_provider': 'choices_provider', + 'completer': 'completer', + 'table_columns': 'table_columns', + 'suppress_tab_hint': 'suppress_tab_hint', + } + + def __init__( + self, + *, + help_text: str | None = None, + metavar: str | None = None, + nargs: int | str | tuple[int, ...] | None = None, + choices: list[Any] | None = None, + choices_provider: ChoicesProviderUnbound[CmdOrSet] | None = None, + completer: CompleterUnbound[CmdOrSet] | None = None, + table_columns: tuple[str, ...] | None = None, + suppress_tab_hint: bool | None = None, + ) -> None: + """Initialise shared metadata fields.""" + self.help_text = help_text + self.metavar = metavar + self.nargs = nargs + self.choices = choices + self.choices_provider = choices_provider + self.completer = completer + self.table_columns = table_columns + self.suppress_tab_hint = suppress_tab_hint + + def to_kwargs(self) -> dict[str, Any]: + """Return non-None fields as an argparse kwargs dict.""" + return {kwarg: val for attr, kwarg in self._KWARGS_MAP.items() if (val := getattr(self, attr)) is not None} + + +class Argument(_BaseArgMetadata): + """Metadata for a positional argument in an ``Annotated`` type hint. + + Example:: + + def do_greet(self, name: Annotated[str, Argument(help_text="Person to greet")]): + ... + """ + + +class Option(_BaseArgMetadata): + """Metadata for an optional/flag argument in an ``Annotated`` type hint. + + Positional ``*names`` are the flag strings (e.g. ``"--color"``, ``"-c"``). + When omitted, the decorator auto-generates ``--param_name``. + + Example:: + + def do_paint( + self, + color: Annotated[str, Option("--color", "-c", help_text="Color")] = "blue", + ): + ... + """ + + def __init__( + self, + *names: str, + action: str | None = None, + required: bool = False, + **kwargs: Any, + ) -> None: + """Initialise Option metadata.""" + super().__init__(**kwargs) + self.names = names + self.action = action + self.required = required + + +#: Metadata extracted from ``Annotated[T, meta]``, or ``None`` for plain types. +ArgMetadata = Argument | Option | None + +_NormalizedAnnotation = tuple[Any, ArgMetadata, bool] +_ResolvedParam = tuple[str, ArgMetadata, bool, list[str], dict[str, Any]] +_ArgumentTarget = argparse.ArgumentParser | argparse._MutuallyExclusiveGroup | argparse._ArgumentGroup + + +# --------------------------------------------------------------------------- +# Type resolvers +# --------------------------------------------------------------------------- +# +# Each resolver: (tp, args, *, is_positional, has_default, default, metadata) -> dict +# The returned dict is merged into the argparse kwargs. +# Internal keys ('base_type', 'is_collection', 'is_bool_flag') are stripped +# before passing to argparse. +# --------------------------------------------------------------------------- + +_BOOL_TRUE_VALUES = {'1', 'true', 't', 'yes', 'y', 'on'} +_BOOL_FALSE_VALUES = {'0', 'false', 'f', 'no', 'n', 'off'} + + +def _parse_bool(value: str) -> bool: + """Parse a string into a boolean value for argparse type conversion.""" + lowered = value.strip().lower() + if lowered in _BOOL_TRUE_VALUES: + return True + if lowered in _BOOL_FALSE_VALUES: + return False + raise argparse.ArgumentTypeError(f"invalid boolean value: {value!r} (choose from: 1, 0, true, false, yes, no, on, off)") + + +def _make_literal_type(literal_values: list[Any]) -> Callable[[str], Any]: + """Create an argparse converter for a Literal's exact values.""" + value_map: dict[str, Any] = {} + for value in literal_values: + key = str(value) + if key in value_map and value_map[key] is not value: + raise TypeError( + f"Literal values {value_map[key]!r} and {value!r} have the same string " + f"representation {key!r} and cannot be distinguished on the command line." + ) + value_map[key] = value + + def _convert(value: str) -> Any: + if value in value_map: + return value_map[value] + if value.lower() in _BOOL_TRUE_VALUES: + bool_value = True + elif value.lower() in _BOOL_FALSE_VALUES: + bool_value = False + else: + bool_value = None + + if bool_value is not None and bool_value in literal_values: + return bool_value + + valid = ', '.join(str(v) for v in literal_values) + raise argparse.ArgumentTypeError(f"invalid choice: {value!r} (choose from {valid})") + + _convert.__name__ = 'literal' + return _convert + + +def _make_enum_type(enum_class: type[enum.Enum]) -> Callable[[str], enum.Enum]: + """Create an argparse *type* converter for an Enum class. + + Accepts both member *values* and member *names*. + """ + _value_map = {str(m.value): m for m in enum_class} + + def _convert(value: str) -> enum.Enum: + member = _value_map.get(value) + if member is not None: + return member + try: + return enum_class[value] + except KeyError as err: + valid = ', '.join(_value_map) + raise argparse.ArgumentTypeError(f"invalid choice: {value!r} (choose from {valid})") from err + + _convert.__name__ = enum_class.__name__ + _convert._cmd2_enum_class = enum_class # type: ignore[attr-defined] + return _convert + + +class _CollectionCastingAction(argparse._StoreAction): + """Store action that can coerce parsed collection values to a container type.""" + + def __init__(self, *args: Any, container_factory: Callable[[list[Any]], Any] | None = None, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._container_factory = container_factory + + def __call__( + self, + _parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: Any, + _option_string: str | None = None, + ) -> None: + result = values + if self._container_factory is not None and isinstance(values, list): + result = self._container_factory(values) + setattr(namespace, self.dest, result) + + +# -- Individual resolvers ----------------------------------------------------- + + +def _make_simple_resolver(converter: Callable[..., Any] | type) -> Callable[..., dict[str, Any]]: + """Create a resolver for types that just need ``type=converter``.""" + + def _resolve(_tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> dict[str, Any]: + return {'type': converter} + + return _resolve + + +def _resolve_bool( + _tp: Any, + _args: tuple[Any, ...], + *, + is_positional: bool, + metadata: ArgMetadata, + **_ctx: Any, +) -> dict[str, Any]: + """Resolve bool -- flag or positional depending on context.""" + if not is_positional: + action_str = getattr(metadata, 'action', None) if metadata else None + if action_str: + return {'action': action_str, 'is_bool_flag': True} + return {'action': argparse.BooleanOptionalAction, 'is_bool_flag': True} + return {'type': _parse_bool} + + +def _resolve_element(tp: Any) -> tuple[Any, dict[str, Any]]: + """Resolve a collection element type and reject nested collections.""" + element_type, inner = _resolve_type(tp, is_positional=True) + if inner.get('is_collection'): + raise TypeError("Nested collections are not supported") + return element_type, inner + + +def _make_collection_resolver(collection_type: type) -> Callable[..., dict[str, Any]]: + """Create a resolver for single-arg collections (list[T], set[T]).""" + + def _resolve(_tp: Any, args: tuple[Any, ...], *, has_default: bool = False, **_ctx: Any) -> dict[str, Any]: + nargs = '*' if has_default else '+' + if len(args) == 0: + # Bare list/tuple without type args -- treat as list[str]/set[str] + return { + 'is_collection': True, + 'nargs': nargs, + 'base_type': str, + 'action': _CollectionCastingAction, + 'container_factory': collection_type, + } + if len(args) != 1: + raise TypeError( + f"{collection_type.__name__}[...] with {len(args)} type arguments is not supported; " + f"use {collection_type.__name__}[T] with a single element type." + ) + element_type, inner = _resolve_element(args[0]) + return { + **inner, + 'is_collection': True, + 'nargs': nargs, + 'base_type': element_type, + 'action': _CollectionCastingAction, + 'container_factory': collection_type, + } + + return _resolve + + +def _resolve_tuple(_tp: Any, args: tuple[Any, ...], *, has_default: bool = False, **_ctx: Any) -> dict[str, Any]: + """Resolve tuple[T, ...] and tuple[T1, T2, ...].""" + cast_kwargs = {'action': _CollectionCastingAction, 'container_factory': tuple} + + nargs = '*' if has_default else '+' + if not args: + # Bare tuple without type args -- treat as tuple[str, ...] + return {'is_collection': True, 'nargs': nargs, 'base_type': str, **cast_kwargs} + + if len(args) == 2 and args[1] is Ellipsis: + element_type, inner = _resolve_element(args[0]) + return {**inner, 'is_collection': True, 'nargs': nargs, 'base_type': element_type, **cast_kwargs} + + if Ellipsis not in args: + first = args[0] + if not all(a == first for a in args[1:]): + raise TypeError( + f"tuple[{', '.join(a.__name__ if hasattr(a, '__name__') else str(a) for a in args)}] " + f"has mixed element types which is not currently supported because argparse " + f"can only apply a single type= converter per argument. " + f"Use tuple[T, T] (same type) or tuple[T, ...] instead." + ) + _, inner = _resolve_element(first) + return {**inner, 'is_collection': True, 'nargs': len(args), 'base_type': first, **cast_kwargs} + + raise TypeError( + "tuple with Ellipsis in an unexpected position is not supported; " + "use tuple[T, ...] for variable-length or tuple[T, T] for fixed-arity." + ) + + +def _resolve_literal(_tp: Any, args: tuple[Any, ...], **_ctx: Any) -> dict[str, Any]: + """Resolve Literal["a", "b", ...] into converter + choices.""" + literal_values = list(args) + return {'type': _make_literal_type(literal_values), 'choices': literal_values} + + +def _resolve_enum(tp: Any, _args: tuple[Any, ...], **_ctx: Any) -> dict[str, Any]: + """Resolve Enum subclasses into converter + choices.""" + return {'type': _make_enum_type(tp), 'choices': [m.value for m in tp]} + + +# -- Registry ----------------------------------------------------------------- + +_TYPE_RESOLVERS: dict[Any, Callable[..., dict[str, Any]]] = { + # Subclass-matchable entries first -- iteration order matters for the + # issubclass fallback. enum.Enum must precede int (IntEnum <: int). + enum.Enum: _resolve_enum, + pathlib.Path: _make_simple_resolver(pathlib.Path), + # Exact-match entries (order among these doesn't affect subclass lookup). + bool: _resolve_bool, + int: _make_simple_resolver(int), + float: _make_simple_resolver(float), + decimal.Decimal: _make_simple_resolver(decimal.Decimal), + list: _make_collection_resolver(list), + set: _make_collection_resolver(set), + tuple: _resolve_tuple, + Literal: _resolve_literal, +} + + +def _resolve_type( + tp: type, + *, + is_positional: bool = False, + has_default: bool = False, + default: Any = None, + metadata: ArgMetadata = None, + is_kw_only: bool = False, +) -> tuple[type, dict[str, Any]]: + """Resolve a type into argparse kwargs via the registry. + + Lookup order: ``get_origin(tp)`` → ``tp`` → ``issubclass`` fallback. + + Returns ``(base_type, kwargs_dict)``. + """ + args = get_args(tp) + resolver_has_default = has_default or is_kw_only + ctx: dict[str, Any] = { + 'is_positional': is_positional, + 'has_default': resolver_has_default, + 'default': default, + 'metadata': metadata, + } + + resolver = _TYPE_RESOLVERS.get(get_origin(tp)) or _TYPE_RESOLVERS.get(tp) + + # Subclass fallback (e.g. MyEnum → enum.Enum, MyPath → pathlib.Path) + if resolver is None and isinstance(tp, type): + for parent, candidate in _TYPE_RESOLVERS.items(): + if isinstance(parent, type) and issubclass(tp, parent): + resolver = candidate + break + + if resolver is not None: + kwargs = resolver(tp, args, **ctx) + base_type = kwargs.pop('base_type', tp) + else: + base_type = tp + kwargs = {} + + if metadata: + kwargs.update(metadata.to_kwargs()) + if metadata.nargs is not None: + kwargs['nargs'] = metadata.nargs + + if (has_default and default is not None) or has_default: + kwargs['default'] = default + + if (is_kw_only and not has_default) or (isinstance(metadata, Option) and metadata.required): + kwargs['required'] = True + + if kwargs.get('choices_provider') or kwargs.get('completer'): + kwargs.pop('choices', None) + + return base_type, kwargs + + +def _unwrap_optional(tp: type) -> tuple[type, bool]: + """If *tp* is ``T | None``, return ``(T, True)``. Otherwise ``(tp, False)``. + + Raises ``TypeError`` for ambiguous unions like ``str | int`` or ``str | int | None``. + """ + origin = get_origin(tp) + if origin is Union or origin is types.UnionType: # type: ignore[comparison-overlap] + all_args = get_args(tp) + non_none = [a for a in all_args if a is not type(None)] + has_none = len(non_none) < len(all_args) + if len(non_none) == 1: + if has_none: + return non_none[0], True + raise TypeError( + f"Unexpected single-element Union without None: Union[{non_none[0]}]. " + f"Use the type directly instead of wrapping in Union." + ) + type_names = ' | '.join(a.__name__ if hasattr(a, '__name__') else str(a) for a in non_none) + raise TypeError(f"Union type {type_names} is ambiguous for auto-resolution.") + return tp, False + + +def _normalize_annotation(annotation: type) -> _NormalizedAnnotation: + """Normalize an annotation into its inner type, metadata, and optionality.""" + tp = annotation + metadata: ArgMetadata = None + is_optional = False + + tp, unwrapped = _unwrap_optional(tp) + if unwrapped: + is_optional = True + if get_origin(tp) is Annotated: # type: ignore[comparison-overlap] + inner_tp = get_args(tp)[0] + inner_origin = get_origin(inner_tp) + inner_is_union = inner_origin is Union or inner_origin is types.UnionType # type: ignore[comparison-overlap] + if not (inner_is_union and type(None) in get_args(inner_tp)): + raise TypeError("Annotated[T, meta] | None is ambiguous. Use Annotated[T | None, meta] instead.") + + if get_origin(tp) is Annotated: # type: ignore[comparison-overlap] + args = get_args(tp) + tp = args[0] + for meta in args[1:]: + if isinstance(meta, (Argument, Option)): + metadata = meta + break + + tp, inner_unwrapped = _unwrap_optional(tp) + if inner_unwrapped: + is_optional = True + + return tp, metadata, is_optional + + +# --------------------------------------------------------------------------- +# Annotation resolution +# --------------------------------------------------------------------------- + + +def _resolve_annotation( + annotation: type, + *, + has_default: bool = False, + default: Any = None, + is_kw_only: bool = False, +) -> tuple[dict[str, Any], ArgMetadata, bool, bool]: + """Decompose a type annotation into ``(type_kwargs, metadata, is_positional, is_bool_flag)``. + + Peels ``Annotated`` then ``Optional``. The only supported way to combine + ``Annotated`` with ``Optional`` is ``Annotated[T | None, meta]``. + Writing ``Annotated[T, meta] | None`` is ambiguous and raises ``TypeError``. + """ + tp, metadata, is_optional = _normalize_annotation(annotation) + + is_positional = isinstance(metadata, Argument) or ( + not isinstance(metadata, Option) and not has_default and not is_optional and not is_kw_only + ) + + # 4. Resolve type and finalize argparse kwargs + tp, type_kwargs = _resolve_type( + tp, + is_positional=is_positional, + has_default=has_default, + default=default, + metadata=metadata, + is_kw_only=is_kw_only, + ) + + # Strip internal keys not meant for argparse + is_bool_flag = type_kwargs.pop('is_bool_flag', False) + type_kwargs.pop('is_collection', None) + type_kwargs.pop('base_type', None) + + return type_kwargs, metadata, is_positional, is_bool_flag + + +# Parameter names that conflict with argparse internals and cannot be used +# as annotated parameter names. +_RESERVED_PARAM_NAMES = frozenset({'dest', 'subcommand'}) + + +# --------------------------------------------------------------------------- +# Signature → Parser conversion +# --------------------------------------------------------------------------- + + +def _validate_base_command_params( + func: Callable[..., Any], + *, + skip_params: frozenset[str] | None = None, +) -> None: + """Validate a ``base_command=True`` function has ``cmd2_handler`` and no positional args.""" + sig = inspect.signature(func) + + if 'cmd2_handler' not in sig.parameters: + raise TypeError(f"with_annotated(base_command=True) requires a 'cmd2_handler' parameter in {func.__qualname__}") + + if skip_params is None: + skip_params = _SKIP_PARAMS + + for name, metadata, positional, _flags, _kwargs in _resolve_parameters(func, skip_params=skip_params): + if positional and not isinstance(metadata, Argument): + raise TypeError( + f"Parameter '{name}' in {func.__qualname__} is positional, " + f"which conflicts with subcommand parsing. " + f"Use a keyword-only parameter (after *) or give it a default value." + ) + if isinstance(metadata, Argument): + raise TypeError( + f"Parameter '{name}' in {func.__qualname__} uses Argument() metadata, " + f"which creates a positional argument that conflicts with subcommand parsing." + ) + + +# Parameters that are handled specially by the decorator and should not +# be added to the argparse parser. The first positional parameter (self/cls) +# is always skipped by position; these cover additional decorator-managed names. +_SKIP_PARAMS = frozenset({'cmd2_handler', 'cmd2_statement'}) + + +def _resolve_parameters( + func: Callable[..., Any], + *, + skip_params: frozenset[str] = _SKIP_PARAMS, +) -> list[_ResolvedParam]: + """Resolve a function signature into parser-ready parameter records.""" + sig = inspect.signature(func) + try: + hints = get_type_hints(func, include_extras=True) + except (NameError, AttributeError, TypeError) as exc: + raise TypeError( + f"Failed to resolve type hints for {func.__qualname__}. Ensure all annotations use valid, importable types." + ) from exc + + resolved: list[_ResolvedParam] = [] + + # Skip the first parameter by position (self/cls for methods) + params = list(sig.parameters.items()) + if params: + params = params[1:] + + for name, param in params: + if name in skip_params: + continue + + if name in _RESERVED_PARAM_NAMES: + raise ValueError( + f"Parameter name {name!r} in {func.__qualname__} is reserved by argparse " + f"and cannot be used as an annotated parameter name." + ) + + annotation = hints.get(name, param.annotation) + has_default = param.default is not inspect.Parameter.empty + default = param.default if has_default else None + is_kw_only = param.kind == inspect.Parameter.KEYWORD_ONLY + + kwargs, metadata, positional, _is_bool_flag = _resolve_annotation( + annotation, + has_default=has_default, + default=default, + is_kw_only=is_kw_only, + ) + + if positional: + flags: list[str] = [] + else: + flags = list(metadata.names) if isinstance(metadata, Option) and metadata.names else [f'--{name}'] + kwargs['dest'] = name + + resolved.append((name, metadata, positional, flags, kwargs)) + + return resolved + + +def _filtered_namespace_kwargs( + ns: argparse.Namespace, + *, + accepted: Container[str] | None = None, + exclude_subcommand: bool = False, +) -> dict[str, Any]: + """Filter a parsed Namespace down to user-visible kwargs.""" + from .constants import NS_ATTR_SUBCMD_HANDLER + + filtered: dict[str, Any] = {} + for key, value in vars(ns).items(): + if accepted is not None and key not in accepted: + continue + if key == NS_ATTR_SUBCMD_HANDLER: + continue + if exclude_subcommand and key == 'subcommand': + continue + filtered[key] = value + + return filtered + + +def _validate_group_members( + member_names: tuple[str, ...], + *, + all_param_names: set[str], + group_type: str, +) -> None: + """Validate that all referenced group members exist.""" + for name in member_names: + if name not in all_param_names: + raise ValueError(f"{group_type} references nonexistent parameter {name!r}") + + +def _build_argument_group_targets( + parser: argparse.ArgumentParser, + *, + groups: tuple[tuple[str, ...], ...] | None, + all_param_names: set[str], +) -> tuple[dict[str, _ArgumentTarget], dict[str, argparse._ArgumentGroup]]: + """Build argument groups and return add_argument targets for their members.""" + target_for: dict[str, _ArgumentTarget] = {} + argument_group_for: dict[str, argparse._ArgumentGroup] = {} + argument_group_index_for: dict[str, int] = {} + + if not groups: + return target_for, argument_group_for + + for index, member_names in enumerate(groups, start=1): + _validate_group_members(member_names, all_param_names=all_param_names, group_type='groups') + for name in member_names: + if name in argument_group_for: + raise ValueError( + f"parameter {name!r} cannot be assigned to both argument " + f"group {argument_group_index_for[name]} and argument group {index}" + ) + + group = parser.add_argument_group() + for name in member_names: + argument_group_for[name] = group + argument_group_index_for[name] = index + target_for[name] = group + + return target_for, argument_group_for + + +def _apply_mutex_group_targets( + parser: argparse.ArgumentParser, + *, + target_for: dict[str, _ArgumentTarget], + argument_group_for: dict[str, argparse._ArgumentGroup], + mutually_exclusive_groups: tuple[tuple[str, ...], ...] | None, + all_param_names: set[str], +) -> None: + """Build mutually exclusive groups and update add_argument targets for their members.""" + mutex_target_for: dict[str, argparse._MutuallyExclusiveGroup] = {} + + if not mutually_exclusive_groups: + return + + for index, member_names in enumerate(mutually_exclusive_groups, start=1): + _validate_group_members( + member_names, + all_param_names=all_param_names, + group_type='mutually_exclusive_groups', + ) + for name in member_names: + if name in mutex_target_for: + raise ValueError(f"parameter {name!r} cannot be assigned to multiple mutually exclusive groups") + + parent_groups = {argument_group_for[name] for name in member_names if name in argument_group_for} + if len(parent_groups) > 1: + raise ValueError( + f"mutually exclusive group {index} spans parameters in different argument groups, " + "which argparse cannot represent cleanly" + ) + + mutex_parent: _ArgumentTarget = next(iter(parent_groups)) if parent_groups else parser + mutex_group = mutex_parent.add_mutually_exclusive_group() + for name in member_names: + mutex_target_for[name] = mutex_group + target_for[name] = mutex_group + + +def build_parser_from_function( + func: Callable[..., Any], + *, + skip_params: frozenset[str] = _SKIP_PARAMS, + groups: tuple[tuple[str, ...], ...] | None = None, + mutually_exclusive_groups: tuple[tuple[str, ...], ...] | None = None, +) -> argparse.ArgumentParser: + """Inspect a function's signature and build a ``Cmd2ArgumentParser``. + + Parameters without defaults become positional arguments. + Parameters with defaults become ``--option`` flags. + ``Annotated[T, Argument(...)]`` or ``Annotated[T, Option(...)]`` + overrides the default behavior. + + :param func: the command function to inspect + :param skip_params: parameter names to exclude from the parser + :param groups: tuples of parameter names to place in argument groups (for help display) + :param mutually_exclusive_groups: tuples of parameter names that are mutually exclusive + :return: a fully configured ``Cmd2ArgumentParser`` + """ + from .argparse_custom import DEFAULT_ARGUMENT_PARSER + + parser = DEFAULT_ARGUMENT_PARSER() + + resolved = _resolve_parameters(func, skip_params=skip_params) + + # Phase 2: build group lookup + all_param_names = {name for name, *_rest in resolved} + target_for, argument_group_for = _build_argument_group_targets( + parser, + groups=groups, + all_param_names=all_param_names, + ) + _apply_mutex_group_targets( + parser, + target_for=target_for, + argument_group_for=argument_group_for, + mutually_exclusive_groups=mutually_exclusive_groups, + all_param_names=all_param_names, + ) + + # Phase 3: add arguments to appropriate targets + for name, _metadata, positional, flags, kwargs in resolved: + target = target_for.get(name, parser) + if positional: + target.add_argument(name, **kwargs) + else: + target.add_argument(*flags, **kwargs) + + return parser + + +def _derive_subcommand_name(func: Callable[..., Any], subcommand_to: str) -> str: + """Derive the subcommand name from the function name and validate the naming convention. + + ``subcommand_to='team member'`` + ``func.__name__='team_member_add'`` → ``'add'``. + """ + expected_prefix = subcommand_to.replace(' ', '_') + '_' + if not func.__name__.startswith(expected_prefix): + raise TypeError( + f"Function '{func.__name__}' must be named '{expected_prefix}' " + f"when using subcommand_to='{subcommand_to}'" + ) + return func.__name__[len(expected_prefix) :] + + +def build_subcommand_handler( + func: Callable[..., Any], + subcommand_to: str, + *, + base_command: bool = False, + groups: tuple[tuple[str, ...], ...] | None = None, + mutually_exclusive_groups: tuple[tuple[str, ...], ...] | None = None, +) -> tuple[Callable[..., Any], str, Callable[[], argparse.ArgumentParser]]: + """Build a subcommand handler wrapper and its parser from type annotations. + + Validates the naming convention, builds a parser from annotations, and + returns a wrapper that unpacks ``argparse.Namespace`` into typed kwargs + before calling the original function. + + :param func: the subcommand handler function + :param subcommand_to: parent command name (space-delimited for nesting) + :param base_command: if True, the parser also gets ``add_subparsers()`` + :return: ``(handler, subcommand_name, parser_builder)`` + """ + subcmd_name = _derive_subcommand_name(func, subcommand_to) + + if base_command: + _validate_base_command_params(func) + + _accepted = set(list(inspect.signature(func).parameters.keys())[1:]) + + @functools.wraps(func) + def handler(self_arg: Any, ns: Any) -> Any: + """Unpack Namespace into typed kwargs for the subcommand handler.""" + filtered = _filtered_namespace_kwargs(ns, accepted=_accepted) + return func(self_arg, **filtered) + + def parser_builder() -> argparse.ArgumentParser: + parser = build_parser_from_function(func, groups=groups, mutually_exclusive_groups=mutually_exclusive_groups) + if base_command: + parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND', required=True) + return parser + + return handler, subcmd_name, parser_builder diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 0b2c3b3f9..f855ae454 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -5,7 +5,9 @@ import argparse import dataclasses +import enum import inspect +import pathlib from collections import ( defaultdict, deque, @@ -729,7 +731,29 @@ def _get_raw_choices(self, arg_state: _ArgumentState) -> list[CompletionItem] | ] choices_callable: ChoicesCallable | None = arg_state.action.get_choices_callable() # type: ignore[attr-defined] - return choices_callable + if choices_callable is not None: + return choices_callable + + # Type inference: auto-complete from action.type when no explicit + # choices or choices_callable is configured. + action_type = arg_state.action.type + if action_type is not None: + if action_type is pathlib.Path or (isinstance(action_type, type) and issubclass(action_type, pathlib.Path)): + from .cmd2 import Cmd + + return ChoicesCallable(is_completer=True, to_call=Cmd.path_complete) + + if isinstance(action_type, type) and issubclass(action_type, enum.Enum): + return [CompletionItem(str(m.value), display_meta=m.name) for m in action_type] + + enum_from_converter = getattr(action_type, '_cmd2_enum_class', None) + if isinstance(enum_from_converter, type) and issubclass(enum_from_converter, enum.Enum): + return [CompletionItem(str(m.value), display_meta=m.name) for m in enum_from_converter] + + if getattr(action_type, '__name__', None) == '_parse_bool': + return [CompletionItem(v) for v in ['true', 'false', 'yes', 'no', 'on', 'off', '1', '0']] + + return None def _prepare_callable_params( self, @@ -792,7 +816,10 @@ def _complete_arg( if isinstance(raw_choices, ChoicesCallable): args, kwargs = self._prepare_callable_params(raw_choices, arg_state, text, consumed_arg_values, cmd_set) choices_func = raw_choices.choices_provider - all_choices = list(choices_func(*args, **kwargs)) + all_choices = [ + choice if isinstance(choice, CompletionItem) else CompletionItem(choice) + for choice in choices_func(*args, **kwargs) + ] else: all_choices = raw_choices diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 3c8bc9ed6..2882e9173 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -1,6 +1,8 @@ """Decorators for ``cmd2`` commands.""" import argparse +import functools +import inspect from collections.abc import ( Callable, Sequence, @@ -344,6 +346,178 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: return arg_decorator +def with_annotated( + func: Callable[..., Any] | None = None, + *, + ns_provider: Callable[..., argparse.Namespace] | None = None, + preserve_quotes: bool = False, + with_unknown_args: bool = False, + base_command: bool = False, + subcommand_to: str | None = None, + help: str | None = None, # noqa: A002 + aliases: Sequence[str] | None = None, + groups: tuple[tuple[str, ...], ...] | None = None, + mutually_exclusive_groups: tuple[tuple[str, ...], ...] | None = None, +) -> Any: + """Decorate a ``do_*`` method to build its argparse parser from type annotations. + + :param func: the command function (when used without parentheses) + :param ns_provider: optional callable returning a prepopulated argparse.Namespace. + Not supported with ``subcommand_to``. + :param preserve_quotes: if True, preserve quotes in arguments. + Not supported with ``subcommand_to``. + :param with_unknown_args: if True, capture unknown args (passed as extra kwarg ``_unknown``). + Not supported with ``subcommand_to``. + :param base_command: if True, this command has subcommands (adds ``add_subparsers()``). + Requires a ``cmd2_handler`` parameter and no positional arguments. + :param subcommand_to: parent command name (e.g. ``'team'`` or ``'team member'``). + Function must be named ``{parent_underscored}_{subcommand}``. + :param help: help text for the subcommand (only valid with ``subcommand_to``) + :param aliases: alternative names for the subcommand (only valid with ``subcommand_to``) + :param groups: tuples of parameter names to place in argument groups (for help display) + :param mutually_exclusive_groups: tuples of parameter names that are mutually exclusive + + Example:: + + class MyApp(cmd2.Cmd): + @with_annotated + def do_greet(self, name: str, count: int = 1): ... + + @with_annotated(base_command=True) + def do_team(self, *, cmd2_handler): ... + + @with_annotated(subcommand_to='team', help='create a team') + def team_create(self, name: str): ... + + """ + from .annotated import ( + _SKIP_PARAMS, + _filtered_namespace_kwargs, + _validate_base_command_params, + build_parser_from_function, + build_subcommand_handler, + ) + from .argparse_custom import Cmd2AttributeWrapper + + if (help is not None or aliases is not None) and subcommand_to is None: + raise TypeError("'help' and 'aliases' are only valid with subcommand_to") + if subcommand_to is not None: + unsupported: list[str] = [] + if ns_provider is not None: + unsupported.append('ns_provider') + if preserve_quotes: + unsupported.append('preserve_quotes') + if with_unknown_args: + unsupported.append('with_unknown_args') + if unsupported: + names = ', '.join(unsupported) + raise TypeError( + f"{names} {'is' if len(unsupported) == 1 else 'are'} not supported with subcommand_to. " + "Configure these behaviors on the base command instead." + ) + + def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: + if with_unknown_args: + unknown_param = inspect.signature(fn).parameters.get('_unknown') + if unknown_param is None: + raise TypeError('with_annotated(with_unknown_args=True) requires a parameter named _unknown') + if unknown_param.kind is inspect.Parameter.POSITIONAL_ONLY: + raise TypeError('Parameter _unknown must be keyword-compatible when with_unknown_args=True') + + if subcommand_to is not None: + handler, subcmd_name, subcmd_parser_builder = build_subcommand_handler( + fn, + subcommand_to, + base_command=base_command, + groups=groups, + mutually_exclusive_groups=mutually_exclusive_groups, + ) + setattr(handler, constants.SUBCMD_ATTR_COMMAND, subcommand_to) + setattr(handler, constants.SUBCMD_ATTR_NAME, subcmd_name) + setattr(handler, constants.CMD_ATTR_ARGPARSER, subcmd_parser_builder) + add_parser_kwargs: dict[str, Any] = {} + if help is not None: + add_parser_kwargs['help'] = help + if aliases: + add_parser_kwargs['aliases'] = list(aliases) + setattr(handler, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, add_parser_kwargs) + return handler + + command_name = fn.__name__[len(constants.COMMAND_FUNC_PREFIX) :] + + skip_params = _SKIP_PARAMS | ({'_unknown'} if with_unknown_args else frozenset()) + if base_command: + _validate_base_command_params(fn, skip_params=skip_params) + + # Cache signature introspection at decoration time, not per-invocation + accepted = set(list(inspect.signature(fn).parameters.keys())[1:]) + + def parser_builder() -> argparse.ArgumentParser: + parser = build_parser_from_function( + fn, + skip_params=skip_params, + groups=groups, + mutually_exclusive_groups=mutually_exclusive_groups, + ) + if base_command: + parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND', required=True) + return parser + + @functools.wraps(fn) + def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: + cmd2_app, statement_arg = _parse_positionals(args) + owner = args[0] # Cmd or CommandSet instance + statement, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list( + command_name, statement_arg, preserve_quotes + ) + + arg_parser = cmd2_app._command_parsers.get(cmd_wrapper) + if arg_parser is None: + raise ValueError(f'No argument parser found for {command_name}') + + if ns_provider is None: + namespace = None + else: + provider_self = cmd2_app._resolve_func_self(ns_provider, args[0]) + namespace = ns_provider(provider_self if provider_self is not None else cmd2_app) + + try: + if with_unknown_args: + ns, unknown = arg_parser.parse_known_args(parsed_arglist, namespace) + else: + ns = arg_parser.parse_args(parsed_arglist, namespace) + unknown = None + except SystemExit as exc: + raise Cmd2ArgparseError from exc + + ns.cmd2_statement = Cmd2AttributeWrapper(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 = Cmd2AttributeWrapper(handler) + if hasattr(ns, constants.NS_ATTR_SUBCMD_HANDLER): + delattr(ns, constants.NS_ATTR_SUBCMD_HANDLER) + + func_kwargs = _filtered_namespace_kwargs(ns, accepted=accepted, exclude_subcommand=base_command) + + if with_unknown_args: + func_kwargs['_unknown'] = unknown + + func_kwargs.update(kwargs) + result: bool | None = fn(owner, **func_kwargs) + return result + + setattr(cmd_wrapper, constants.CMD_ATTR_ARGPARSER, parser_builder) + setattr(cmd_wrapper, constants.CMD_ATTR_PRESERVE_QUOTES, preserve_quotes) + + return cmd_wrapper + + # Support both @with_annotated and @with_annotated(...) + if func is not None: + return decorator(func) + return decorator + + def as_subcommand_to( command: str, subcommand: str, diff --git a/docs/features/argument_processing.md b/docs/features/argument_processing.md index 00a9b94c6..6e9deb434 100644 --- a/docs/features/argument_processing.md +++ b/docs/features/argument_processing.md @@ -16,18 +16,21 @@ following for you: 1. Adds the usage message from the argument parser to your command's help. 1. Checks if the `-h/--help` option is present, and if so, displays the help message for the command -These features are all provided by the [@with_argparser][cmd2.with_argparser] decorator which is -imported from `cmd2`. +These features are provided by two decorators: + +- [@with_argparser][cmd2.with_argparser] -- build parsers manually with `add_argument()` calls +- [@with_annotated][cmd2.decorators.with_annotated] -- build parsers automatically from type hints See the -[argparse_example](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_example.py) -example to learn more about how to use the various `cmd2` argument processing decorators in your -`cmd2` applications. +[argparse_completion](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_completion.py) +and [annotated_example](https://github.com/python-cmd2/cmd2/blob/main/examples/annotated_example.py) +examples to compare the two styles side by side. `cmd2` provides the following [decorators](../api/decorators.md) for assisting with parsing arguments passed to commands: - [cmd2.decorators.with_argparser][] +- [cmd2.decorators.with_annotated][] - [cmd2.decorators.with_argument_list][] All of these decorators accept an optional **preserve_quotes** argument which defaults to `False`. @@ -52,6 +55,229 @@ stores internally. A consequence is that parsers don't need to be unique across to dynamically modify this parser at a later time, you need to retrieve this deep copy. This can be done using `self._command_parsers.get(self.do_commandname)`. +## with_annotated decorator + +The [@with_annotated][cmd2.decorators.with_annotated] decorator builds an argparse parser +automatically from the decorated function's type annotations. No manual `add_argument()` calls are +required. + +### Basic usage + +Parameters without defaults become positional arguments. Parameters with defaults become `--option` +flags. Keyword-only parameters (after `*`) always become options, and without a default they become +required options. The function receives typed keyword arguments directly instead of an +`argparse.Namespace`. + +```py +from cmd2 import with_annotated + +class MyApp(cmd2.Cmd): + @with_annotated + def do_greet(self, name: str, count: int = 1, loud: bool = False): + """Greet someone.""" + for _ in range(count): + msg = f"Hello {name}" + self.poutput(msg.upper() if loud else msg) +``` + +The command `greet Alice --count 3 --loud` parses `name="Alice"`, `count=3`, `loud=True` and passes +them as keyword arguments. + +### How annotations map to argparse + +The decorator converts Python type annotations into `add_argument()` calls: + +| Type annotation | Generated argparse setting | +| -------------------------------------------------------- | --------------------------------------------------- | +| `str` | default (no `type=` needed) | +| `int`, `float` | `type=int` or `type=float` | +| `bool` with a default | boolean optional flag via `BooleanOptionalAction` | +| positional `bool` | parsed from `true/false`, `yes/no`, `on/off`, `1/0` | +| `Path` | `type=Path` | +| `Enum` subclass | `type=converter`, `choices` from member values | +| `decimal.Decimal` | `type=decimal.Decimal` | +| `Literal[...]` | `type=literal-converter`, `choices` from values | +| `Collection[T]` / `list[T]` / `set[T]` / `tuple[T, ...]` | `nargs='+'` (or `'*'` if it has a default) | +| `tuple[T, T]` | fixed `nargs=N` with `type=T` | +| `T \| None` | unwrapped to `T`, treated as optional | + +When collection types are used with `@with_annotated`, parsed values are passed to the command +function as: + +- `list[T]` and `Collection[T]` as `list` +- `set[T]` as `set` +- `tuple[T, ...]` as `tuple` + +Unsupported patterns raise `TypeError`, including: + +- unions with multiple non-`None` members such as `str | int` +- mixed-type tuples such as `tuple[int, str]` +- `Annotated[T, meta] | None`; write `Annotated[T | None, meta]` instead + +The parameter names `dest` and `subcommand` are reserved and may not be used as annotated parameter +names. + +### Annotated metadata + +For finer control, use `typing.Annotated` with [Argument][cmd2.annotated.Argument] or +[Option][cmd2.annotated.Option] metadata: + +```py +from typing import Annotated +from cmd2 import Argument, Option, with_annotated + +class MyApp(cmd2.Cmd): + def sport_choices(self) -> cmd2.Choices: + return cmd2.Choices.from_values(["football", "basketball"]) + + @with_annotated + def do_play( + self, + sport: Annotated[str, Argument( + choices_provider=sport_choices, + help_text="Sport to play", + )], + venue: Annotated[str, Option( + "--venue", "-v", + help_text="Where to play", + completer=cmd2.Cmd.path_complete, + )] = "home", + ): + self.poutput(f"Playing {sport} at {venue}") +``` + +Both `Argument` and `Option` accept the same cmd2-specific fields as `add_argument()`: `choices`, +`choices_provider`, `completer`, `table_columns`, `suppress_tab_hint`, `metavar`, `nargs`, and +`help_text`. + +`Option` additionally accepts `action`, `required`, and positional `*names` for custom flag strings +(e.g. `Option("--color", "-c")`). + +### Comparison with @with_argparser + +The two decorators are interchangeable. Here is the same command written both ways: + +**@with_argparser** + +```py +parser = Cmd2ArgumentParser() +parser.add_argument('name', help='person to greet') +parser.add_argument('--count', type=int, default=1, help='repetitions') +parser.add_argument('--loud', action='store_true', help='shout') + +@with_argparser(parser) +def do_greet(self, args): + for _ in range(args.count): + msg = f"Hello {args.name}" + self.poutput(msg.upper() if args.loud else msg) +``` + +**@with_annotated** + +```py +@with_annotated +def do_greet(self, name: str, count: int = 1, loud: bool = False): + for _ in range(count): + msg = f"Hello {name}" + self.poutput(msg.upper() if loud else msg) +``` + +The annotated version is more concise and gives you typed parameters. It also supports several +advanced cmd2 features directly, including `ns_provider`, `with_unknown_args`, and typed +subcommands. + +### Decorator options + +`@with_annotated` currently supports: + +- `ns_provider` -- prepopulate the namespace before parsing, mirroring `@with_argparser` +- `preserve_quotes` -- if `True`, quotes in arguments are preserved +- `with_unknown_args` -- if `True`, unrecognised arguments are passed as `_unknown` +- `subcommand_to` -- register the function as an annotated subcommand under a parent command +- `base_command` -- create a base command whose parser also adds subparsers and exposes + `cmd2_handler` +- `help` -- help text for an annotated subcommand +- `aliases` -- aliases for an annotated subcommand + +```py +@with_annotated(with_unknown_args=True) +def do_rawish(self, name: str, _unknown: list[str] | None = None): + self.poutput((name, _unknown)) +``` + +### Annotated subcommands + +`@with_annotated` can also build typed subcommand trees without manually constructing subparsers. + +```py +@with_annotated(base_command=True) +def do_manage(self, *, cmd2_handler): + handler = cmd2_handler.get() + 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.get() + if handler: + handler() + +@with_annotated(subcommand_to="manage project", help="add a project") +def manage_project_add(self, name: str): + self.poutput(f"added {name}") +``` + +### Lower-level parser building + +If you need parser grouping or mutually-exclusive groups while still using annotation-driven parser +generation, [cmd2.annotated.build_parser_from_function][cmd2.annotated.build_parser_from_function] +also supports: + +- `groups=((...), (...))` +- `mutually_exclusive_groups=((...), (...))` + +```py +@with_annotated(preserve_quotes=True) +def do_raw(self, text: str): + self.poutput(f"raw: {text}") +``` + +## Automatic Completion from Types + +When an argparse argument has `type=Path` or `type=MyEnum` set -- whether manually via +`add_argument()` or automatically via `@with_annotated` -- the completer will provide tab completion +without needing an explicit `choices_provider` or `completer`. + +This applies to both `@with_argparser` and `@with_annotated`: + +- `type=pathlib.Path` (or any `Path` subclass) triggers filesystem path completion +- `type=MyEnum` (any `enum.Enum` subclass) triggers completion from enum member values + +For example, with `@with_argparser`: + +```py +parser = Cmd2ArgumentParser() +parser.add_argument('filepath', type=Path) +parser.add_argument('color', type=MyColorEnum) + +@with_argparser(parser) +def do_load(self, args): + ... # filepath gets path completion, color gets enum completion +``` + +With `@with_annotated`, the same inference happens because `Path` and `Enum` annotations generate +the equivalent parser configuration automatically. + ## 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..512bb6bc9 --- /dev/null +++ b/examples/annotated_example.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +"""Annotated decorator example -- type-hint-driven argument parsing. + +Shows how ``@with_annotated`` eliminates boilerplate compared to +``@with_argparser``. The focus is on features that are unique to +the annotated style -- type inference, auto-completion from types, and +typed function parameters -- while also demonstrating that all of cmd2's +advanced completion features (choices_provider, completer, table_columns, +arg_tokens) remain available via ``Annotated`` metadata. + +Compare with ``argparse_completion.py`` which uses ``@with_argparser`` +for the same completion features. + +Usage:: + + python examples/annotated_example.py +""" + +import sys +from argparse import Namespace +from decimal import Decimal +from enum import Enum +from pathlib import Path +from typing import ( + Annotated, + Literal, +) + +import cmd2 +from cmd2 import ( + Choices, + Cmd, +) + + +class Color(str, Enum): + red = "red" + green = "green" + blue = "blue" + yellow = "yellow" + + +class LogLevel(str, Enum): + debug = "debug" + info = "info" + warning = "warning" + error = "error" + + +ANNOTATED_CATEGORY = "Annotated Commands" + + +class AnnotatedExample(Cmd): + """Demonstrates @with_annotated strengths over @with_argparser.""" + + intro = "Welcome! Try tab-completing the commands below.\n" + prompt = "annotated> " + + def __init__(self) -> None: + super().__init__(include_ipy=True) + self._sports = ['Basketball', 'Football', 'Tennis', 'Hockey'] + self._default_region = "staging" + + # -- Type inference: int, float, bool ------------------------------------ + # With @with_argparser you'd manually set type=int and action='store_true'. + # Here the decorator infers everything from the annotations. + + @cmd2.with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_add(self, a: int, b: int = 0, verbose: bool = False) -> None: + """Add two integers. Types are inferred from annotations. + + Examples: + add 2 --b 3 + add 10 --b 5 --verbose + """ + result = a + b + if verbose: + self.poutput(f"{a} + {b} = {result}") + else: + self.poutput(str(result)) + + # -- Enum auto-completion ------------------------------------------------ + # With @with_argparser you'd list every member in choices=[...]. + # Here the Enum type provides choices and validation automatically. + + @cmd2.with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_paint( + self, + item: str, + color: Annotated[Color, cmd2.Option("--color", "-c", help_text="Color to use")] = Color.blue, + level: LogLevel = LogLevel.info, + ) -> None: + """Paint an item. Enum types auto-complete their member values. + + Try: + paint wall --color + paint wall --level + """ + self.poutput(f"[{level.value}] Painting {item} {color.value}") + + # -- Path auto-completion ------------------------------------------------ + # With @with_argparser you'd wire completer=Cmd.path_complete on each arg. + # Here the Path type triggers filesystem completion automatically. + + @cmd2.with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_copy(self, src: Path, dst: Path) -> None: + """Copy a file. Path parameters auto-complete filesystem paths. + + Try: + copy ./ /tmp/ + """ + self.poutput(f"Copying {src} -> {dst}") + + # -- Bool flags ---------------------------------------------------------- + # With @with_argparser you'd spell out the action. + # Here bool defaults drive the generated boolean option. + + @cmd2.with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_build( + self, + target: str, + verbose: bool = False, + color: bool = True, + ) -> None: + """Build a target. Bool flags are inferred from defaults. + + ``verbose: bool = False`` becomes a boolean optional flag. + ``color: bool = True`` becomes a ``--color`` / ``--no-color`` style option. + + Try: + build app --verbose --no-color + """ + parts = [f"Building {target}"] + if verbose: + parts.append("(verbose)") + if not color: + parts.append("(no color)") + self.poutput(" ".join(parts)) + + # -- List arguments ------------------------------------------------------ + # With @with_argparser you'd set type=float and nargs='+'. + # Here list[float] does both at once. + + @cmd2.with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_sum(self, numbers: list[float]) -> None: + """Sum numbers. ``list[T]`` becomes ``nargs='+'`` automatically. + + Try: + sum 1.5 2.5 3.0 + """ + self.poutput(f"{' + '.join(str(n) for n in numbers)} = {sum(numbers)}") + + # -- Literal + Decimal --------------------------------------------------- + # Literal values become validated choices. Decimal values preserve precision. + + @cmd2.with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_deploy( + self, + service: str, + mode: Literal["safe", "fast"] = "safe", + budget: Decimal = Decimal("1.50"), + ) -> None: + """Deploy using Literal choices and Decimal parsing. + + Try: + deploy api --mode + deploy api --mode fast --budget 2.75 + """ + self.poutput(f"Deploying {service} in {mode} mode with budget {budget}") + + # -- Typed kwargs -------------------------------------------------------- + # With @with_argparser you'd access args.name, args.count on a Namespace. + # Here each parameter is a typed local variable. + + @cmd2.with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_greet(self, name: str, count: int = 1, loud: bool = False) -> None: + """Greet someone. Parameters are typed -- no Namespace unpacking. + + Try: + greet Alice --count 3 --loud + """ + for _ in range(count): + msg = f"Hello {name}!" + self.poutput(msg.upper() if loud else msg) + + # -- Advanced: choices_provider + arg_tokens ----------------------------- + # These cmd2-specific features still work via Annotated metadata. + + def sport_choices(self) -> Choices: + """choices_provider using instance data.""" + return Choices.from_values(self._sports) + + def context_choices(self, arg_tokens: dict[str, list[str]]) -> Choices: + """arg_tokens-aware completion -- choices depend on prior arguments.""" + sport = arg_tokens.get("sport", [""])[0] + if sport == "Basketball": + return Choices.from_values(["3-pointer", "dunk", "layup"]) + if sport == "Football": + return Choices.from_values(["touchdown", "field-goal", "punt"]) + return Choices.from_values(["play"]) + + @cmd2.with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_score( + self, + sport: Annotated[ + str, + cmd2.Argument( + choices_provider=sport_choices, + help_text="Sport to score", + ), + ], + play: Annotated[ + str, + cmd2.Argument( + choices_provider=context_choices, + help_text="Type of play (depends on sport)", + ), + ], + points: int = 1, + ) -> None: + """Score a play. Demonstrates choices_provider and arg_tokens. + + Try: + score + score Basketball + score Football + """ + self.poutput(f"{sport}: {play} for {points} point(s)") + + # -- Namespace provider -------------------------------------------------- + # This mirrors one of @with_argparser's advanced features. + + def default_namespace(self) -> Namespace: + return Namespace(region=self._default_region) + + @cmd2.with_annotated(ns_provider=default_namespace) + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_ship(self, package: str, region: str = "local") -> None: + """Use ns_provider to prepopulate parser defaults at runtime. + + Try: + ship parcel + ship parcel --region remote + """ + self.poutput(f"Shipping {package} to {region}") + + # -- Unknown args -------------------------------------------------------- + + @cmd2.with_annotated(with_unknown_args=True) + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_flex(self, name: str, _unknown: list[str] | None = None) -> None: + """Capture unknown arguments instead of failing parse. + + Try: + flex alice --future-flag value + """ + self.poutput(f"name={name}") + if _unknown: + self.poutput(f"unknown={_unknown}") + + # -- Subcommands --------------------------------------------------------- + # @with_annotated also supports typed subcommand trees. + + @cmd2.with_annotated(base_command=True) + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_manage(self, verbose: bool = False, *, cmd2_handler) -> None: + """Base command for annotated subcommands. + + Try: + help manage + manage project add demo + """ + if verbose: + self.poutput("verbose mode") + handler = cmd2_handler.get() + if handler: + handler() + + @cmd2.with_annotated(subcommand_to="manage", base_command=True, help="manage projects") + def manage_project(self, *, cmd2_handler) -> None: + handler = cmd2_handler.get() + if handler: + handler() + + @cmd2.with_annotated(subcommand_to="manage project", help="add a project") + def manage_project_add(self, name: str) -> None: + self.poutput(f"project added: {name}") + + @cmd2.with_annotated(subcommand_to="manage project", help="list projects") + def manage_project_list(self) -> None: + self.poutput("project list: demo") + + # -- Preserve quotes ----------------------------------------------------- + + @cmd2.with_annotated(preserve_quotes=True) + @cmd2.with_category(ANNOTATED_CATEGORY) + def do_echo(self, text: str) -> None: + """Echo text with quotes preserved. + + Try: + echo "hello world" + """ + self.poutput(text) + + +if __name__ == '__main__': + app = AnnotatedExample() + sys.exit(app.cmdloop()) diff --git a/tests/test_annotated.py b/tests/test_annotated.py new file mode 100644 index 000000000..17828e3f0 --- /dev/null +++ b/tests/test_annotated.py @@ -0,0 +1,1382 @@ +"""Unit tests for cmd2.annotated -- verify build_parser_from_function produces correct actions. + +The focus is on testing that type annotations are correctly translated into +argparse action attributes (option_strings, type, nargs, choices, action, default, etc.). +We do NOT re-test argparse parsing logic or cmd2 integration here. +""" + +import argparse +import decimal +import enum +from pathlib import Path +from typing import ( + Annotated, + Literal, +) + +import pytest + +import cmd2 +from cmd2 import Cmd2ArgumentParser +from cmd2.annotated import ( + Argument, + Option, + _apply_mutex_group_targets, + _build_argument_group_targets, + _CollectionCastingAction, + _make_enum_type, + _make_literal_type, + _parse_bool, + _resolve_annotation, + _validate_group_members, + build_parser_from_function, +) + +from .conftest import run_cmd + +# --------------------------------------------------------------------------- +# Test enums +# --------------------------------------------------------------------------- + + +class _Color(str, enum.Enum): + red = "red" + green = "green" + blue = "blue" + + +class _IntColor(enum.IntEnum): + red = 1 + green = 2 + blue = 3 + + +class _PlainColor(enum.Enum): + RED = "red" + GREEN = "green" + BLUE = "blue" + + +# --------------------------------------------------------------------------- +# Single-parameter test functions for build_parser_from_function. +# Each has exactly one param (besides self) so dest is auto-derived. +# --------------------------------------------------------------------------- + + +def _func_str(self, name: str) -> None: ... +def _func_int_option(self, count: int = 1) -> None: ... +def _func_float_option(self, rate: float = 1.0) -> None: ... +def _func_bool_false(self, verbose: bool = False) -> None: ... +def _func_bool_true(self, debug: bool = True) -> None: ... +def _func_bool_positional(self, flag: bool) -> None: ... +def _func_path(self, file: Path) -> None: ... +def _func_path_option(self, file: Path = Path(".")) -> None: ... +def _func_decimal(self, amount: decimal.Decimal) -> None: ... +def _func_enum(self, color: _Color) -> None: ... +def _func_enum_option(self, color: _Color = _Color.blue) -> None: ... +def _func_literal(self, mode: Literal["fast", "slow"]) -> None: ... +def _func_literal_option(self, mode: Literal["fast", "slow"] = "fast") -> None: ... +def _func_literal_int(self, level: Literal[1, 2, 3]) -> None: ... +def _func_optional(self, name: str | None = None) -> None: ... +def _func_list(self, files: list[str]) -> None: ... +def _func_list_default(self, items: list[str] | None = None) -> None: ... +def _func_set(self, tags: set[str]) -> None: ... +def _func_tuple_ellipsis(self, values: tuple[int, ...]) -> None: ... +def _func_tuple_fixed(self, pair: tuple[int, int]) -> None: ... +def _func_bare_list(self, items: list) -> None: ... +def _func_bare_tuple(self, items: tuple) -> None: ... +def _func_annotated_arg(self, name: Annotated[str, Argument(help_text="Your name")]) -> None: ... +def _func_annotated_option(self, color: Annotated[str, Option("--color", "-c", help_text="Pick")] = "blue") -> None: ... +def _func_annotated_metavar(self, name: Annotated[str, Argument(metavar="NAME")]) -> None: ... +def _func_annotated_nargs(self, names: Annotated[str, Argument(nargs=2)]) -> None: ... +def _func_annotated_action(self, verbose: Annotated[bool, Option("--verbose", "-v", action="count")] = False) -> None: ... +def _func_annotated_required(self, name: Annotated[str, Option("--name", required=True)]) -> None: ... +def _func_annotated_required_auto_flag(self, name: Annotated[str, Option(required=True)]) -> None: ... +def _func_annotated_choices(self, food: Annotated[str, Argument(choices=["a", "b"])]) -> None: ... +def _func_dest_param(self, dest: str) -> None: ... +def _func_kw_only(self, *, name: str) -> None: ... +def _func_kw_only_with_default(self, *, name: str = "world") -> None: ... +def _func_underscore_option(self, my_param: str = "x") -> None: ... +def _func_default_type_mismatch(self, count: int = "1") -> None: ... # type: ignore[assignment] +def _func_path_default(self, file: Path = Path("/tmp")) -> None: ... +def _func_optional_annotated_inside(self, name: Annotated[str | None, Option("--name")] = None) -> None: ... +def _func_optional_annotated_outside(self, name: Annotated[str, Option("--name")] | None = None) -> None: ... +def _func_int_enum(self, color: _IntColor) -> None: ... +def _func_plain_enum(self, color: _PlainColor) -> None: ... +def _func_list_int(self, nums: list[int]) -> None: ... +def _func_set_int(self, nums: set[int]) -> None: ... +def _func_tuple_fixed_triple(self, triple: tuple[int, int, int]) -> None: ... +def _func_multi(self, a: str, b: int, c: int = 1) -> None: ... +def _func_grouped( + self, + *, + local: str | None = None, + remote: str | None = None, + force: bool = False, + dry_run: bool = False, +) -> None: ... + + +def _provider(cmd: cmd2.Cmd): + return [] + + +def _func_choices_provider_on_enum( + self, + color: Annotated[_Color, Argument(choices_provider=_provider)], +) -> None: ... + + +def _func_completer_on_path( + self, + file: Annotated[Path, Argument(completer=cmd2.Cmd.path_complete)], +) -> None: ... + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + + +def _get_param_action(func: object) -> argparse.Action: + """Build parser from a single-param function and return its action.""" + import inspect + + sig = inspect.signature(func) # type: ignore[arg-type] + param_names = [n for n in sig.parameters if n != 'self'] + assert len(param_names) == 1, f"Expected 1 param besides self, got {param_names}" + parser = build_parser_from_function(func) # type: ignore[arg-type] + for action in parser._actions: + if action.dest == param_names[0]: + return action + raise ValueError(f"No action with dest={param_names[0]!r}") + + +def _complete_cmd(app: cmd2.Cmd, line: str, text: str) -> list[str]: + begidx = len(line) - len(text) + endidx = len(line) + completions = app.complete(text, line, begidx, endidx) + return list(completions.to_strings()) + + +# --------------------------------------------------------------------------- +# Core: build_parser_from_function produces correct action attributes +# --------------------------------------------------------------------------- + + +class TestBuildParser: + """Verify action attributes produced by build_parser_from_function.""" + + @pytest.mark.parametrize( + ("func", "expected"), + [ + # --- Positionals --- + pytest.param(_func_str, {"option_strings": [], "type": None}, id="str_positional"), + pytest.param(_func_path, {"option_strings": [], "type": Path}, id="path_positional"), + pytest.param(_func_decimal, {"option_strings": [], "type": decimal.Decimal}, id="decimal_positional"), + pytest.param(_func_bool_positional, {"option_strings": [], "type": _parse_bool}, id="bool_positional"), + pytest.param(_func_enum, {"option_strings": [], "choices": ["red", "green", "blue"]}, id="enum_positional"), + pytest.param(_func_literal, {"option_strings": [], "choices": ["fast", "slow"]}, id="literal_positional"), + pytest.param(_func_literal_int, {"option_strings": [], "choices": [1, 2, 3]}, id="literal_int_positional"), + pytest.param(_func_int_enum, {"option_strings": [], "choices": [1, 2, 3]}, id="int_enum_positional"), + pytest.param( + _func_plain_enum, {"option_strings": [], "choices": ["red", "green", "blue"]}, id="plain_enum_positional" + ), + pytest.param(_func_list_int, {"option_strings": [], "nargs": "+", "type": int}, id="list_int"), + pytest.param(_func_set_int, {"option_strings": [], "nargs": "+", "type": int}, id="set_int"), + pytest.param(_func_tuple_fixed_triple, {"option_strings": [], "nargs": 3, "type": int}, id="tuple_fixed_triple"), + pytest.param(_func_list, {"option_strings": [], "nargs": "+"}, id="list_positional"), + pytest.param(_func_set, {"option_strings": [], "nargs": "+"}, id="set_positional"), + pytest.param(_func_tuple_ellipsis, {"option_strings": [], "nargs": "+", "type": int}, id="tuple_ellipsis"), + pytest.param(_func_tuple_fixed, {"option_strings": [], "nargs": 2, "type": int}, id="tuple_fixed"), + pytest.param(_func_bare_list, {"option_strings": [], "nargs": "+"}, id="bare_list"), + pytest.param(_func_bare_tuple, {"option_strings": [], "nargs": "+"}, id="bare_tuple"), + # --- Options --- + pytest.param(_func_int_option, {"option_strings": ["--count"], "type": int, "default": 1}, id="int_option"), + pytest.param(_func_float_option, {"option_strings": ["--rate"], "type": float, "default": 1.0}, id="float_option"), + pytest.param(_func_bool_false, {"option_strings": ["--verbose", "--no-verbose"]}, id="bool_optional_action"), + pytest.param( + _func_bool_true, + {"option_strings": ["--debug", "--no-debug"], "default": True}, + id="bool_optional_action_true", + ), + pytest.param(_func_path_option, {"option_strings": ["--file"], "type": Path}, id="path_option"), + pytest.param( + _func_enum_option, + {"option_strings": ["--color"], "choices": ["red", "green", "blue"], "default": _Color.blue}, + id="enum_option", + ), + pytest.param( + _func_literal_option, {"option_strings": ["--mode"], "choices": ["fast", "slow"]}, id="literal_option" + ), + pytest.param(_func_optional, {"option_strings": ["--name"], "default": None}, id="optional_str"), + pytest.param(_func_list_default, {"option_strings": ["--items"], "nargs": "*"}, id="list_with_default"), + # --- Annotated metadata --- + pytest.param(_func_annotated_arg, {"option_strings": [], "help": "Your name"}, id="annotated_help"), + pytest.param( + _func_annotated_option, {"option_strings": ["--color", "-c"], "help": "Pick"}, id="annotated_custom_flags" + ), + pytest.param(_func_annotated_metavar, {"option_strings": [], "metavar": "NAME"}, id="annotated_metavar"), + pytest.param(_func_annotated_nargs, {"option_strings": [], "nargs": 2}, id="annotated_nargs"), + pytest.param(_func_annotated_required, {"option_strings": ["--name"], "required": True}, id="annotated_required"), + pytest.param( + _func_annotated_required_auto_flag, + {"option_strings": ["--name"], "required": True}, + id="annotated_required_auto_flag", + ), + pytest.param(_func_annotated_choices, {"option_strings": [], "choices": ["a", "b"]}, id="annotated_choices"), + # --- Keyword-only --- + pytest.param(_func_kw_only, {"option_strings": ["--name"], "required": True}, id="kw_only_required"), + pytest.param(_func_kw_only_with_default, {"option_strings": ["--name"], "default": "world"}, id="kw_only_default"), + # --- Underscore in flag names --- + pytest.param(_func_underscore_option, {"option_strings": ["--my_param"], "default": "x"}, id="underscore_flag"), + # --- Default type preservation --- + pytest.param( + _func_default_type_mismatch, {"option_strings": ["--count"], "default": "1"}, id="default_not_coerced" + ), + pytest.param(_func_path_default, {"option_strings": ["--file"], "default": Path("/tmp")}, id="path_default"), + # --- Optional + Annotated (union inside) --- + pytest.param( + _func_optional_annotated_inside, + {"option_strings": ["--name"], "default": None}, + id="optional_annotated_inside", + ), + ], + ) + def test_action_attributes(self, func, expected) -> None: + action = _get_param_action(func) + for key, value in expected.items(): + assert getattr(action, key) == value, f"{key}: expected {value!r}, got {getattr(action, key)!r}" + + def test_annotated_action_count(self) -> None: + action = _get_param_action(_func_annotated_action) + assert isinstance(action, argparse._CountAction) + + @pytest.mark.parametrize( + "func", + [ + pytest.param(_func_set, id="set"), + pytest.param(_func_tuple_ellipsis, id="tuple"), + ], + ) + def test_collection_uses_casting_action(self, func) -> None: + action = _get_param_action(func) + assert isinstance(action, _CollectionCastingAction) + + def test_self_skipped(self) -> None: + parser = build_parser_from_function(_func_str) + dests = {a.dest for a in parser._actions} + assert 'self' not in dests + + def test_no_params_produces_empty_parser(self) -> None: + """A function with zero parameters (not even self) produces a parser with no actions.""" + + def bare() -> None: ... + + parser = build_parser_from_function(bare) + dests = {a.dest for a in parser._actions if a.dest != 'help'} + assert dests == set() + + def test_get_type_hints_failure_raises(self) -> None: + def do_broken(self, name: 'NonExistentType'): # noqa: F821 + pass + + with pytest.raises(TypeError, match="Failed to resolve type hints"): + build_parser_from_function(do_broken) + + def test_validate_base_command_type_hints_failure_raises(self) -> None: + """_validate_base_command_params should raise, not swallow, type hint failures.""" + from cmd2.annotated import _validate_base_command_params + + def do_broken(self, cmd2_handler, name: 'NonExistentType'): # noqa: F821 + pass + + with pytest.raises(TypeError, match="Failed to resolve type hints"): + _validate_base_command_params(do_broken) + + def test_choices_provider_overrides_enum_choices(self) -> None: + action = _get_param_action(_func_choices_provider_on_enum) + assert action.choices is None + assert action.get_choices_callable() is not None # type: ignore[attr-defined] + + def test_completer_overrides_path_choices(self) -> None: + action = _get_param_action(_func_completer_on_path) + cc = action.get_choices_callable() # type: ignore[attr-defined] + assert cc is not None + assert cc.is_completer is True + + def test_dest_param_raises(self) -> None: + with pytest.raises(ValueError, match="dest"): + build_parser_from_function(_func_dest_param) + + def test_subcommand_param_raises(self) -> None: + def func(self, subcommand: str) -> None: ... + + with pytest.raises(ValueError, match="subcommand"): + build_parser_from_function(func) + + def test_optional_annotated_outside_raises(self) -> None: + with pytest.raises(TypeError, match="Annotated"): + build_parser_from_function(_func_optional_annotated_outside) + + def test_annotated_ambiguous_union_raises(self) -> None: + """Annotated[str | int, meta] must raise -- ambiguous inner union.""" + with pytest.raises(TypeError, match="ambiguous"): + _resolve_annotation(Annotated[str | int, Option("--name")]) + + def test_enum_choices_match_converted_type(self) -> None: + """Enum choices must be convertible by the type converter.""" + action = _get_param_action(_func_enum) + converter = action.type + for choice in action.choices: + assert isinstance(converter(str(choice)), _Color) + + 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 + + +# --------------------------------------------------------------------------- +# Argument groups and mutually exclusive groups +# --------------------------------------------------------------------------- + + +class TestArgumentGroups: + def test_groups_and_mutex_applied(self) -> None: + parser = build_parser_from_function( + _func_grouped, + groups=(("local", "remote"), ("force", "dry_run")), + mutually_exclusive_groups=(("local", "remote"), ("force", "dry_run")), + ) + + nonempty_groups = [group for group in parser._action_groups if group._group_actions] + grouped_dests = [{action.dest for action in group._group_actions} for group in nonempty_groups] + assert {"local", "remote"} in grouped_dests + assert {"force", "dry_run"} in grouped_dests + + mutex_groups = [{action.dest for action in group._group_actions} for group in parser._mutually_exclusive_groups] + assert {"local", "remote"} in mutex_groups + assert {"force", "dry_run"} in mutex_groups + + def test_group_nonexistent_param_raises(self) -> None: + with pytest.raises(ValueError, match="nonexistent parameter"): + build_parser_from_function(_func_grouped, groups=(("missing",),)) + + def test_param_in_multiple_groups_raises(self) -> None: + with pytest.raises(ValueError, match="cannot be assigned to both argument group"): + build_parser_from_function(_func_grouped, groups=(("local",), ("local", "remote"))) + + def test_mutex_group_spanning_different_argument_groups_raises(self) -> None: + with pytest.raises(ValueError, match="spans parameters in different argument groups"): + build_parser_from_function( + _func_grouped, + groups=(("local",), ("remote",)), + mutually_exclusive_groups=(("local", "remote"),), + ) + + def test_mutually_exclusive_group(self) -> None: + """Mutually exclusive params cannot be used together.""" + + def func(self, verbose: bool = False, quiet: bool = False) -> None: ... + + parser = build_parser_from_function(func, mutually_exclusive_groups=(("verbose", "quiet"),)) + assert len(parser._mutually_exclusive_groups) == 1 + group_dests = {a.dest for a in parser._mutually_exclusive_groups[0]._group_actions} + assert group_dests == {"verbose", "quiet"} + with pytest.raises(SystemExit): + parser.parse_args(["--verbose", "--quiet"]) + + def test_multiple_mutually_exclusive_groups(self) -> None: + """Multiple mutually exclusive groups.""" + + def func(self, verbose: bool = False, quiet: bool = False, json: bool = False, csv: bool = False) -> None: ... + + parser = build_parser_from_function(func, mutually_exclusive_groups=(("verbose", "quiet"), ("json", "csv"))) + assert len(parser._mutually_exclusive_groups) == 2 + + def test_argument_group(self) -> None: + """Arguments in a group appear under a shared heading in help.""" + + def func(self, src: str, dst: str, recursive: bool = False, verbose: bool = False) -> None: ... + + parser = build_parser_from_function(func, groups=(("src", "dst"),)) + default_titles = {'Positional Arguments', 'options'} + custom_groups = [g for g in parser._action_groups if g.title not in default_titles] + assert len(custom_groups) >= 1 + all_custom_dests = {a.dest for g in custom_groups for a in g._group_actions} + assert {"src", "dst"} <= all_custom_dests + + def test_mutually_exclusive_via_decorator(self) -> None: + """@with_annotated(mutually_exclusive_groups=...) works end-to-end.""" + + class App(cmd2.Cmd): + @cmd2.with_annotated(mutually_exclusive_groups=(("verbose", "quiet"),)) + def do_run(self, verbose: bool = False, quiet: bool = False) -> None: + if verbose: + self.poutput("verbose") + elif quiet: + self.poutput("quiet") + else: + self.poutput("normal") + + app = App() + out, _err = run_cmd(app, "run --verbose") + assert out == ["verbose"] + + _out, err = run_cmd(app, "run --verbose --quiet") + assert any("not allowed" in line.lower() for line in err) + + def test_group_and_mutex_can_overlap(self) -> None: + def func(self, json: bool = False, csv: bool = False, plain: bool = False) -> None: ... + + parser = build_parser_from_function( + func, + groups=(("json", "csv"),), + mutually_exclusive_groups=(("json", "csv"),), + ) + custom_groups = [g for g in parser._action_groups if g.title not in {'Positional Arguments', 'options'}] + all_custom_dests = {a.dest for g in custom_groups for a in g._group_actions} + assert {"json", "csv"} <= all_custom_dests + with pytest.raises(SystemExit): + parser.parse_args(["--json", "--csv"]) + + +class TestGroupHelpers: + def test_validate_group_members_rejects_nonexistent_param(self) -> None: + with pytest.raises(ValueError, match="nonexistent"): + _validate_group_members(("verbose", "nonexistent"), all_param_names={"verbose"}, group_type="groups") + + def test_build_argument_group_targets(self) -> None: + parser = argparse.ArgumentParser() + target_for, argument_group_for = _build_argument_group_targets( + parser, + groups=(("src", "dst"),), + all_param_names={"src", "dst", "recursive"}, + ) + assert set(target_for) == {"src", "dst"} + assert set(argument_group_for) == {"src", "dst"} + assert target_for["src"] is argument_group_for["src"] + assert target_for["dst"] is argument_group_for["dst"] + + def test_build_argument_group_targets_rejects_duplicate_assignment(self) -> None: + parser = argparse.ArgumentParser() + with pytest.raises(ValueError, match="argument group 1 and argument group 2"): + _build_argument_group_targets( + parser, + groups=(("verbose",), ("verbose",)), + all_param_names={"verbose"}, + ) + + def test_apply_mutex_group_targets(self) -> None: + parser = argparse.ArgumentParser() + target_for, argument_group_for = _build_argument_group_targets( + parser, + groups=(("json", "csv"),), + all_param_names={"json", "csv", "plain"}, + ) + + _apply_mutex_group_targets( + parser, + target_for=target_for, + argument_group_for=argument_group_for, + mutually_exclusive_groups=(("json", "csv"),), + all_param_names={"json", "csv", "plain"}, + ) + + assert target_for["json"] is target_for["csv"] + assert isinstance(target_for["json"], argparse._MutuallyExclusiveGroup) + + def test_apply_mutex_group_targets_rejects_duplicate_assignment(self) -> None: + parser = argparse.ArgumentParser() + with pytest.raises(ValueError, match="multiple mutually exclusive groups"): + _apply_mutex_group_targets( + parser, + target_for={}, + argument_group_for={}, + mutually_exclusive_groups=(("verbose",), ("verbose",)), + all_param_names={"verbose"}, + ) + + def test_apply_mutex_group_targets_rejects_cross_group_members(self) -> None: + parser = argparse.ArgumentParser() + _target_for, argument_group_for = _build_argument_group_targets( + parser, + groups=(("src",), ("dst",)), + all_param_names={"src", "dst"}, + ) + + with pytest.raises(ValueError, match="different argument groups"): + _apply_mutex_group_targets( + parser, + target_for={}, + argument_group_for=argument_group_for, + mutually_exclusive_groups=(("src", "dst"),), + all_param_names={"src", "dst"}, + ) + + +# --------------------------------------------------------------------------- +# _resolve_annotation: positional vs option classification + bool flag +# --------------------------------------------------------------------------- + +_ARG_META = Argument(help_text="Name") +_OPT_META = Option("--color", "-c", help_text="Pick") + + +class TestResolveAnnotation: + @pytest.mark.parametrize( + ("annotation", "has_default", "expected_positional", "expected_bool_flag"), + [ + pytest.param(str, False, True, False, id="plain_str"), + pytest.param(str | None, False, False, False, id="optional_str"), + pytest.param(Annotated[str, _ARG_META], False, True, False, id="annotated_argument"), + pytest.param(Annotated[str, _OPT_META], False, False, False, id="annotated_option"), + pytest.param(Annotated[str, "some doc"], False, True, False, id="annotated_no_meta"), + pytest.param(str, True, False, False, id="has_default"), + pytest.param(bool, True, False, True, id="bool_flag"), + ], + ) + def test_classification(self, annotation, has_default, expected_positional, expected_bool_flag) -> None: + _kwargs, _meta, positional, is_bool_flag = _resolve_annotation(annotation, has_default=has_default) + assert positional is expected_positional + assert is_bool_flag is expected_bool_flag + + def test_optional_wrapping_annotated_with_none_inside(self) -> None: + """Optional[Annotated[T | None, meta]] is allowed (inner type contains None).""" + ann = Annotated[str | None, _OPT_META] | None + _kwargs, meta, positional, _bf = _resolve_annotation(ann) + assert meta is _OPT_META + assert positional is False + + def test_typing_union_optional(self) -> None: + ns: dict = {} + exec("import typing; t = typing.Union[str, None]", ns) + _kwargs, _meta, positional, _bool_flag = _resolve_annotation(ns["t"]) + assert positional is False + + def test_annotated_multiple_metadata_picks_first(self) -> None: + meta1 = Argument(help_text="first") + meta2 = Option("--x", help_text="second") + kwargs, meta, _, _ = _resolve_annotation(Annotated[str, meta1, meta2]) + assert meta is meta1 + assert kwargs.get('help') == "first" + + +# --------------------------------------------------------------------------- +# Error paths +# --------------------------------------------------------------------------- + + +class TestUnsupportedPatterns: + def test_union_raises_with_diagnostic_message(self) -> None: + with pytest.raises(TypeError, match=r"str.*int") as exc_info: + _resolve_annotation(str | int) + assert "Union" in str(exc_info.value) + + def test_tuple_mixed_raises(self) -> None: + with pytest.raises(TypeError, match="mixed element types"): + _resolve_annotation(tuple[int, str, float]) + + @pytest.mark.parametrize( + "annotation", + [ + pytest.param(list[set[int]], id="list_of_set"), + pytest.param(set[list[str]], id="set_of_list"), + pytest.param(tuple[list[int], ...], id="tuple_of_list"), + ], + ) + def test_nested_collection_raises(self, annotation) -> None: + with pytest.raises(TypeError, match="Nested collections are not supported"): + _resolve_annotation(annotation) + + @pytest.mark.parametrize( + "annotation", + [ + pytest.param(frozenset[str], id="frozenset"), + pytest.param(dict[str, int], id="dict"), + ], + ) + def test_unsupported_collection_no_nargs(self, annotation) -> None: + kwargs, _, _, _ = _resolve_annotation(annotation) + assert 'nargs' not in kwargs + assert 'action' not in kwargs + + @pytest.mark.parametrize( + "annotation", + [ + pytest.param(list[int, str], id="list_multi_args"), + pytest.param(set[int, str], id="set_multi_args"), + ], + ) + def test_collection_multiple_type_args_raises(self, annotation) -> None: + with pytest.raises(TypeError, match="type arguments is not supported"): + _resolve_annotation(annotation) + + def test_tuple_ellipsis_wrong_position_raises(self) -> None: + with pytest.raises(TypeError, match="Ellipsis in an unexpected position"): + _resolve_annotation(tuple[..., int]) + + def test_single_element_union_without_none_raises(self) -> None: + """Union with one non-None type and no None should raise.""" + from typing import Union + from unittest.mock import patch + + from cmd2.annotated import _unwrap_optional + + # Python normalizes Union[str] to str, so we can't construct this + # through normal typing. Patch get_origin/get_args to simulate it. + sentinel = object() + with ( + patch('cmd2.annotated.get_origin', return_value=Union), + patch('cmd2.annotated.get_args', return_value=(str,)), + pytest.raises(TypeError, match="single-element Union"), + ): + _unwrap_optional(sentinel) + + +# --------------------------------------------------------------------------- +# Converters +# --------------------------------------------------------------------------- + + +class TestParseBool: + @pytest.mark.parametrize("value", ['1', 'true', 'True', 't', 'yes', 'y', 'on']) + def test_true(self, value) -> None: + assert _parse_bool(value) is True + + @pytest.mark.parametrize("value", ['0', 'false', 'False', 'f', 'no', 'n', 'off']) + def test_false(self, value) -> None: + assert _parse_bool(value) is False + + def test_invalid(self) -> None: + with pytest.raises(argparse.ArgumentTypeError, match="invalid boolean"): + _parse_bool("maybe") + + +class TestEnumConverter: + @pytest.mark.parametrize( + ("enum_cls", "input_val", "expected"), + [ + pytest.param(_Color, "red", _Color.red, id="str_by_value"), + pytest.param(_IntColor, "1", _IntColor.red, id="int_by_value"), + pytest.param(_IntColor, "red", _IntColor.red, id="int_by_name"), + pytest.param(_PlainColor, "red", _PlainColor.RED, id="plain_by_value"), + pytest.param(_PlainColor, "BLUE", _PlainColor.BLUE, id="plain_by_name"), + ], + ) + def test_convert(self, enum_cls, input_val, expected) -> None: + assert _make_enum_type(enum_cls)(input_val) is expected + + def test_invalid(self) -> None: + with pytest.raises(argparse.ArgumentTypeError, match="invalid choice"): + _make_enum_type(_Color)("purple") + + def test_preserves_class(self) -> None: + assert _make_enum_type(_Color)._cmd2_enum_class is _Color + + +class TestLiteralConverter: + @pytest.mark.parametrize( + ("values", "input_val", "expected"), + [ + pytest.param(["fast", "slow"], "fast", "fast", id="str_match"), + pytest.param([1, 2, 3], "2", 2, id="int_match"), + pytest.param([True, False], "yes", True, id="bool_true_coercion"), + pytest.param([True, False], "0", False, id="bool_false_coercion"), + ], + ) + def test_convert(self, values, input_val, expected) -> None: + assert _make_literal_type(values)(input_val) == expected + + def test_invalid(self) -> None: + with pytest.raises(argparse.ArgumentTypeError, match="invalid choice"): + _make_literal_type(["fast", "slow"])("medium") + + def test_direct_match_before_bool_coercion(self) -> None: + assert _make_literal_type(["yes", "no"])("yes") == "yes" + + def test_colliding_str_representations_raises(self) -> None: + with pytest.raises(TypeError, match="same string representation"): + _make_literal_type(["1", 1]) + + +# --------------------------------------------------------------------------- +# Metadata classes +# --------------------------------------------------------------------------- + + +class TestMetadata: + @pytest.mark.parametrize( + ("meta_kwargs", "expected"), + [ + pytest.param({}, {}, id="empty"), + pytest.param({'help_text': "Name"}, {'help': 'Name'}, id="help_text"), + pytest.param({'metavar': "NAME"}, {'metavar': 'NAME'}, id="metavar"), + pytest.param({'choices': ["a", "b"]}, {'choices': ['a', 'b']}, id="choices"), + pytest.param({'table_columns': ("Name", "Age")}, {'table_columns': ("Name", "Age")}, id="table_columns"), + pytest.param({'suppress_tab_hint': True}, {'suppress_tab_hint': True}, id="suppress_tab_hint"), + ], + ) + def test_to_kwargs(self, meta_kwargs, expected) -> None: + assert Argument(**meta_kwargs).to_kwargs() == expected + + def test_nargs_not_in_to_kwargs(self) -> None: + """nargs is set directly by the resolver, not via to_kwargs.""" + assert 'nargs' not in Argument(nargs=2).to_kwargs() + + 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_excludes_names_action_required(self) -> None: + opt = Option("--color", "-c", action="count", required=True, help_text="Pick") + kwargs = opt.to_kwargs() + assert 'names' not in kwargs + assert 'action' not in kwargs + assert 'required' not in kwargs + assert kwargs['help'] == "Pick" + + def test_choices_provider_in_kwargs(self) -> None: + def provider(cmd): + return [] + + assert Argument(choices_provider=provider).to_kwargs()['choices_provider'] is provider + + def test_completer_in_kwargs(self) -> None: + assert Argument(completer=cmd2.Cmd.path_complete).to_kwargs()['completer'] is cmd2.Cmd.path_complete + + +# --------------------------------------------------------------------------- +# _CollectionCastingAction +# --------------------------------------------------------------------------- + + +class TestCollectionCastingAction: + def test_casts_list_to_container(self) -> None: + action = _CollectionCastingAction( + option_strings=[], + dest='items', + nargs='+', + container_factory=set, + ) + ns = argparse.Namespace() + action(argparse.ArgumentParser(), ns, ["a", "b", "a"]) + assert ns.items == {"a", "b"} + + def test_non_list_passthrough(self) -> None: + action = _CollectionCastingAction( + option_strings=[], + dest='items', + nargs='?', + container_factory=set, + ) + ns = argparse.Namespace() + action(argparse.ArgumentParser(), ns, "single_value") + assert ns.items == "single_value" + + +# --------------------------------------------------------------------------- +# _filtered_namespace_kwargs edge cases +# --------------------------------------------------------------------------- + + +class TestFilteredNamespaceKwargs: + def test_excludes_subcmd_handler_key(self) -> None: + from cmd2.annotated import _filtered_namespace_kwargs + from cmd2.constants import NS_ATTR_SUBCMD_HANDLER + + ns = argparse.Namespace(**{NS_ATTR_SUBCMD_HANDLER: lambda: None, 'name': 'Alice'}) + result = _filtered_namespace_kwargs(ns) + assert NS_ATTR_SUBCMD_HANDLER not in result + assert result == {'name': 'Alice'} + + def test_excludes_subcommand_key(self) -> None: + from cmd2.annotated import _filtered_namespace_kwargs + + ns = argparse.Namespace(subcommand='add', name='Alice') + result = _filtered_namespace_kwargs(ns, exclude_subcommand=True) + assert 'subcommand' not in result + assert result == {'name': 'Alice'} + + +# --------------------------------------------------------------------------- +# _parse_positionals edge case +# --------------------------------------------------------------------------- + + +class TestParsePositionals: + def test_skips_non_statement_next_arg(self) -> None: + """When next_arg after Cmd is not Statement/str, loop continues.""" + from cmd2.decorators import _parse_positionals + + app = cmd2.Cmd() + # Two Cmd-like objects: first has non-str next, second has str next + result_cmd, result_stmt = _parse_positionals((app, 42, app, 'hello')) + assert result_cmd is app + assert result_stmt == 'hello' + + def test_matches_statement_type(self) -> None: + """When next_arg is a Statement, it is accepted.""" + from cmd2.decorators import _parse_positionals + from cmd2.parsing import Statement + + app = cmd2.Cmd() + stmt = Statement('hello') + result_cmd, result_stmt = _parse_positionals((app, stmt)) + assert result_cmd is app + assert result_stmt is stmt + + +# --------------------------------------------------------------------------- +# Runtime coverage +# --------------------------------------------------------------------------- + + +class _Sport(str, enum.Enum): + football = "football" + basketball = "basketball" + tennis = "tennis" + + +class _RuntimeAnnotatedApp(cmd2.Cmd): + def __init__(self) -> None: + super().__init__() + self._items = ["apple", "banana", "cherry"] + + def item_choices(self) -> list[str]: + return self._items + + @cmd2.with_annotated + def do_greet(self, name: str, count: int = 1) -> None: + for _ in range(count): + self.poutput(f"Hello {name}") + + @cmd2.with_annotated + def do_add(self, a: int, b: int = 0) -> None: + self.poutput(str(a + b)) + + @cmd2.with_annotated + def do_paint( + self, + item: str, + color: Annotated[_Color, Option("--color", "-c", help_text="Color")] = _Color.blue, + verbose: bool = False, + ) -> None: + msg = f"Painting {item} {color.value}" + if verbose: + msg += " (verbose)" + self.poutput(msg) + + @cmd2.with_annotated + def do_pick(self, item: Annotated[str, Argument(choices_provider=item_choices)]) -> None: + self.poutput(f"Picked: {item}") + + @cmd2.with_annotated + def do_open(self, path: Path) -> None: + self.poutput(f"Opening: {path}") + + @cmd2.with_annotated + def do_sport(self, sport: _Sport) -> None: + self.poutput(f"Playing: {sport.value}") + + @cmd2.with_annotated(preserve_quotes=True) + def do_raw(self, text: str) -> None: + self.poutput(f"raw: {text}") + + +@pytest.fixture +def runtime_app() -> _RuntimeAnnotatedApp: + app = _RuntimeAnnotatedApp() + app.stdout = cmd2.utils.StdSim(app.stdout) + return app + + +class TestRuntimeExecution: + @pytest.mark.parametrize( + ("command", "expected"), + [ + pytest.param("greet Alice", ["Hello Alice"], id="greet_basic"), + pytest.param("greet Alice --count 3", ["Hello Alice", "Hello Alice", "Hello Alice"], id="greet_count"), + pytest.param("add 2 --b 3", ["5"], id="add"), + pytest.param("add 10", ["10"], id="add_default"), + pytest.param("paint wall", ["Painting wall blue"], id="paint_default_color"), + pytest.param("paint wall --color red", ["Painting wall red"], id="paint_color"), + pytest.param("paint wall --verbose", ["Painting wall blue (verbose)"], id="paint_verbose"), + pytest.param("sport football", ["Playing: football"], id="sport_enum"), + ], + ) + def test_command_execution(self, runtime_app, command, expected) -> None: + out, _err = run_cmd(runtime_app, command) + assert out == expected + + def test_help_shows_arguments(self, runtime_app) -> None: + out, _ = run_cmd(runtime_app, "help greet") + assert "name" in "\n".join(out).lower() + + def test_help_shows_option_help(self, runtime_app) -> None: + out, _ = run_cmd(runtime_app, "help paint") + help_text = "\n".join(out) + assert "Color" in help_text or "color" in help_text + + +class TestRuntimeCompletion: + def test_enum_completion(self, runtime_app) -> None: + assert sorted(_complete_cmd(runtime_app, "paint wall --color ", "")) == ["blue", "green", "red"] + + def test_enum_completion_partial(self, runtime_app) -> None: + assert _complete_cmd(runtime_app, "paint wall --color r", "r") == ["red"] + + def test_choices_provider_completion(self, runtime_app) -> None: + assert sorted(_complete_cmd(runtime_app, "pick ", "")) == ["apple", "banana", "cherry"] + + def test_positional_enum_completion(self, runtime_app) -> None: + assert _complete_cmd(runtime_app, "sport foot", "foot") == ["football"] + + +class _InferColor(str, enum.Enum): + red = "red" + green = "green" + + +class _RuntimeTypeInferenceApp(cmd2.Cmd): + path_parser = Cmd2ArgumentParser() + path_parser.add_argument("filepath", type=Path) + + @cmd2.with_argparser(path_parser) + def do_read(self, args: argparse.Namespace) -> None: + self.poutput(str(args.filepath)) + + enum_parser = Cmd2ArgumentParser() + enum_parser.add_argument("color", type=_InferColor) + + @cmd2.with_argparser(enum_parser) + def do_pick_color(self, args: argparse.Namespace) -> None: + self.poutput(args.color.value) + + +@pytest.fixture +def infer_app() -> _RuntimeTypeInferenceApp: + app = _RuntimeTypeInferenceApp() + app.stdout = cmd2.utils.StdSim(app.stdout) + return app + + +class TestTypeInference: + def test_enum_type_inference(self, infer_app) -> None: + assert sorted(_complete_cmd(infer_app, "pick_color ", "")) == ["green", "red"] + + def test_path_type_inference(self, infer_app, tmp_path) -> None: + test_file = tmp_path / "testfile.txt" + test_file.touch() + text = str(tmp_path) + "/" + result_strings = _complete_cmd(infer_app, f"read {text}", text) + assert len(result_strings) > 0 + assert any("testfile.txt" in item for item in result_strings) + + +class _AnnotatedCommandSet(cmd2.CommandSet): + def __init__(self) -> None: + super().__init__() + self._sports = ["football", "baseball"] + + def sport_choices(self) -> list[str]: + return self._sports + + @cmd2.with_annotated + def do_play(self, sport: Annotated[str, Argument(choices_provider=sport_choices)]) -> None: + self._cmd.poutput(f"Playing {sport}") + + +@pytest.fixture +def cmdset_app() -> cmd2.Cmd: + cmdset = _AnnotatedCommandSet() + app = cmd2.Cmd(command_sets=[cmdset]) + app.stdout = cmd2.utils.StdSim(app.stdout) + return app + + +class TestCommandSet: + def test_command_set_execution(self, cmdset_app) -> None: + out, _err = run_cmd(cmdset_app, "play football") + assert out == ["Playing football"] + + def test_command_set_completion(self, cmdset_app) -> None: + assert sorted(_complete_cmd(cmdset_app, "play ", "")) == ["baseball", "football"] + + +# --------------------------------------------------------------------------- +# Integration: with_annotated decorator runs commands through cmd2 +# --------------------------------------------------------------------------- + + +class _IntegrationApp(cmd2.Cmd): + def __init__(self) -> None: + super().__init__() + self.ns_calls = 0 + + def namespace_provider(self) -> argparse.Namespace: + self.ns_calls += 1 + ns = argparse.Namespace() + ns.custom_stuff = "custom" + return ns + + @cmd2.with_annotated + def do_greet(self, name: str, count: int = 1, loud: bool = False, *, keyword_arg: str | None = None) -> None: + """Greet someone.""" + for _ in range(count): + msg = f"Hello {name}" + self.poutput(msg.upper() if loud else msg) + if keyword_arg is not None: + self.poutput(keyword_arg) + + @cmd2.with_annotated(with_unknown_args=True) + def do_flex(self, name: str, _unknown: list[str] | None = None) -> None: + self.poutput(f"name={name}") + if _unknown: + self.poutput(f"unknown={_unknown}") + + @cmd2.with_annotated(preserve_quotes=True) + def do_raw(self, text: str) -> None: + self.poutput(f"raw: {text}") + + @cmd2.with_annotated(ns_provider=namespace_provider) + def do_ns_test(self, cmd2_statement=None) -> None: + self.poutput("ok") + + @cmd2.with_annotated + def do_prefixed(self, cmd2_mode: int = 1) -> None: + self.poutput(f"cmd2_mode={cmd2_mode}") + + +class _GroupedParserApp(cmd2.Cmd): + @cmd2.with_annotated( + groups=(("local", "remote"), ("force", "dry_run")), + mutually_exclusive_groups=(("local", "remote"), ("force", "dry_run")), + ) + def do_transfer( + self, + *, + local: str | None = None, + remote: str | None = None, + force: bool = False, + dry_run: bool = False, + ) -> None: + target = local if local is not None else remote + mode = "force" if force else "dry-run" if dry_run else "normal" + self.poutput(f"Transfer {target} in {mode} mode") + + +@pytest.fixture +def app() -> _IntegrationApp: + return _IntegrationApp() + + +@pytest.fixture +def grouped_app() -> _GroupedParserApp: + return _GroupedParserApp() + + +class TestWithAnnotatedIntegration: + """Integration tests covering the decorator's cmd_wrapper runtime paths.""" + + @pytest.mark.parametrize( + ("command", "expected"), + [ + pytest.param("greet Alice", ["Hello Alice"], id="basic"), + pytest.param("greet Alice --count 2 --loud", ["HELLO ALICE", "HELLO ALICE"], id="options"), + pytest.param("greet Alice --no-loud", ["Hello Alice"], id="bool_no_flag"), + pytest.param("greet Alice --loud", ["HELLO ALICE"], id="bool_flag"), + pytest.param("flex Alice", ["name=Alice"], id="unknown_args_empty"), + ], + ) + def test_command_execution(self, app, command, expected) -> None: + out, _err = run_cmd(app, command) + assert out == expected + + def test_with_unknown_args(self, app) -> None: + out, _err = run_cmd(app, "flex Alice --extra stuff") + assert out[0] == "name=Alice" + assert "unknown=" in out[1] + + def test_preserve_quotes(self, app) -> None: + out, _err = run_cmd(app, 'raw "hello world"') + assert out == ['raw: "hello world"'] + + def test_error_produces_stderr(self, app) -> None: + _out, err = run_cmd(app, "greet") + assert any('error' in line.lower() or 'usage' in line.lower() for line in err) + + def test_no_args_raises_type_error(self, app) -> None: + with pytest.raises(TypeError, match="Expected arguments"): + app.do_greet() + + def test_with_unknown_args_requires_param(self) -> None: + with pytest.raises(TypeError, match="_unknown"): + + @cmd2.with_annotated(with_unknown_args=True) + def do_broken(self, name: str) -> None: + pass + + def test_positional_only_unknown_rejected(self) -> None: + with pytest.raises(TypeError, match="keyword-compatible"): + + @cmd2.with_annotated(with_unknown_args=True) + def do_broken(self, _unknown: list[str], /) -> None: + pass + + def test_ns_provider(self, app) -> None: + out, _err = run_cmd(app, "ns_test") + assert out == ["ok"] + assert app.ns_calls == 1 + + def test_cmd2_prefixed_param_is_preserved(self, app) -> None: + out, _err = run_cmd(app, "prefixed --cmd2_mode 5") + assert out == ["cmd2_mode=5"] + + def test_kwargs_passthrough(self, app) -> None: + app.do_greet("Alice", keyword_arg="kwarg_value") + + def test_bare_call_decorator(self) -> None: + """@with_annotated() with empty parens works same as @with_annotated.""" + + class App(cmd2.Cmd): + @cmd2.with_annotated() + def do_echo(self, text: str) -> None: + self.poutput(text) + + out, _err = run_cmd(App(), "echo hi") + assert out == ["hi"] + + def test_missing_parser_raises(self, app) -> None: + from unittest.mock import patch + + with ( + patch.object(app._command_parsers, 'get', return_value=None), + pytest.raises(ValueError, match="No argument parser found"), + ): + app.do_greet("Alice") + + +class TestGroupedParserIntegration: + def test_grouped_command_executes(self, grouped_app) -> None: + out, _err = run_cmd(grouped_app, "transfer --local build.tar.gz --dry_run") + assert out == ["Transfer build.tar.gz in dry-run mode"] + + def test_grouped_command_mutex_error(self, grouped_app) -> None: + _out, err = run_cmd(grouped_app, "transfer --local a --remote b") + assert any("not allowed with argument" in line.lower() for line in err) + + def test_grouped_command_help_lists_flags(self, grouped_app) -> None: + out, _err = run_cmd(grouped_app, "help transfer") + help_text = "\n".join(out) + assert "--local" in help_text + assert "--remote" in help_text + assert "--force" in help_text + assert "--dry_run" in help_text + + +# --------------------------------------------------------------------------- +# Subcommands: @with_annotated(base_command=True) + @with_annotated(subcommand_to=...) +# --------------------------------------------------------------------------- + + +class _SubcommandApp(cmd2.Cmd): + # Level 1: base command + @cmd2.with_annotated(base_command=True) + def do_manage(self, cmd2_handler, verbose: bool = False) -> None: + """Management command with subcommands.""" + if verbose: + self.poutput("verbose mode") + handler = cmd2_handler.get() + if handler: + handler() + + # Level 2: leaf subcommands + @cmd2.with_annotated(subcommand_to='manage', help='add something') + def manage_add(self, value: str) -> None: + self.poutput(f"added: {value}") + + @cmd2.with_annotated(subcommand_to='manage', help='list things', aliases=['ls']) + def manage_list(self) -> None: + self.poutput("listing all") + + # Level 2: intermediate subcommand (also a base for level 3) + @cmd2.with_annotated(subcommand_to='manage', base_command=True, help='manage members') + def manage_member(self, cmd2_handler) -> None: + handler = cmd2_handler.get() + if handler: + handler() + + # Level 3: nested subcommand + @cmd2.with_annotated(subcommand_to='manage member', help='add a member') + def manage_member_add(self, name: str) -> None: + self.poutput(f"member added: {name}") + + +@pytest.fixture +def subcmd_app() -> _SubcommandApp: + return _SubcommandApp() + + +class TestSubcommands: + @pytest.mark.parametrize( + ("command", "expected"), + [ + pytest.param("manage add hello", ["added: hello"], id="add"), + pytest.param("manage list", ["listing all"], id="list"), + pytest.param("manage ls", ["listing all"], id="list_alias"), + pytest.param("manage member add Alice", ["member added: Alice"], id="nested_3_levels"), + ], + ) + def test_subcommand_executes(self, subcmd_app, command, expected) -> None: + out, _err = run_cmd(subcmd_app, command) + assert out == expected + + @pytest.mark.parametrize( + "command", + [ + pytest.param("manage", id="missing_subcmd"), + pytest.param("manage delete", id="invalid_subcmd"), + pytest.param("manage member", id="missing_nested_subcmd"), + ], + ) + def test_subcommand_errors(self, subcmd_app, command) -> None: + _out, err = run_cmd(subcmd_app, command) + assert any('error' in line.lower() or 'usage' in line.lower() or 'invalid' in line.lower() for line in err) + + def test_subcommand_help(self, subcmd_app) -> None: + out, _err = run_cmd(subcmd_app, 'help manage') + help_text = '\n'.join(out) + assert 'add' in help_text + assert 'list' in help_text + assert 'member' in help_text + + +class TestSubcommandValidation: + def test_base_command_positional_str_raises(self) -> None: + """Positional str param conflicts with subcommand name.""" + with pytest.raises(TypeError, match="positional"): + + @cmd2.with_annotated(base_command=True) + def do_bad(self, name: str, cmd2_handler) -> None: + pass + + def test_base_command_positional_annotated_raises(self) -> None: + """Explicit Argument() metadata forces positional -- conflict.""" + with pytest.raises(TypeError, match="positional"): + + @cmd2.with_annotated(base_command=True) + def do_bad(self, a: Annotated[str, Argument(help_text="x")], cmd2_handler) -> None: + pass + + def test_base_command_missing_handler_raises(self) -> None: + with pytest.raises(TypeError, match="cmd2_handler"): + + @cmd2.with_annotated(base_command=True) + def do_bad(self, verbose: bool = False) -> None: + pass + + @pytest.mark.parametrize( + "kwargs", + [ + pytest.param({"help": "not allowed"}, id="help_only"), + pytest.param({"aliases": ["x"]}, id="aliases_only"), + ], + ) + def test_subcmd_only_params_without_subcommand_to_raises(self, kwargs) -> None: + with pytest.raises(TypeError, match="subcommand_to"): + + @cmd2.with_annotated(**kwargs) + def do_bad(self, name: str) -> None: + pass + + @pytest.mark.parametrize( + ("kwargs", "pattern"), + [ + pytest.param({"with_unknown_args": True}, "with_unknown_args", id="with_unknown_args"), + pytest.param({"preserve_quotes": True}, "preserve_quotes", id="preserve_quotes"), + pytest.param({"ns_provider": lambda self: argparse.Namespace()}, "ns_provider", id="ns_provider"), + ], + ) + def test_subcommand_rejects_unsupported_runtime_options(self, kwargs, pattern) -> None: + with pytest.raises(TypeError, match=pattern): + + @cmd2.with_annotated(subcommand_to='team', **kwargs) + def team_add(self, name: str, _unknown: list[str] | None = None) -> None: + pass + + def test_subcommand_with_mutually_exclusive_groups(self) -> None: + """mutually_exclusive_groups should work on subcommands.""" + + class App(cmd2.Cmd): + @cmd2.with_annotated(base_command=True) + def do_fmt(self, cmd2_handler) -> None: + handler = cmd2_handler.get() + if handler: + handler() + + @cmd2.with_annotated(subcommand_to='fmt', help='output', mutually_exclusive_groups=(("json", "csv"),)) + def fmt_out(self, msg: str, json: bool = False, csv: bool = False) -> None: + self.poutput(f"json={json} csv={csv} {msg}") + + app = App() + out, _err = run_cmd(app, "fmt out hello --json") + assert out == ["json=True csv=False hello"] + _out, err = run_cmd(app, "fmt out hello --json --csv") + assert any("not allowed" in line.lower() for line in err) + + def test_intermediate_base_command_positional_raises(self) -> None: + with pytest.raises(TypeError, match="positional"): + + @cmd2.with_annotated(subcommand_to='team', base_command=True) + def team_member(self, name: str, cmd2_handler) -> None: + pass + + def test_intermediate_base_command_missing_handler_raises(self) -> None: + with pytest.raises(TypeError, match="cmd2_handler"): + + @cmd2.with_annotated(subcommand_to='team', base_command=True) + def team_member(self) -> None: + pass + + @pytest.mark.parametrize( + ("subcommand_to", "func_name"), + [ + pytest.param("team", "wrong_name", id="wrong_prefix"), + pytest.param("team member", "team_wrong", id="wrong_nested_prefix"), + ], + ) + def test_subcommand_naming_enforced(self, subcommand_to, func_name) -> None: + ns: dict = {} + exec(f"def {func_name}(self, x: str) -> None: ...", ns) + with pytest.raises(TypeError, match="must be named"): + cmd2.with_annotated(subcommand_to=subcommand_to)(ns[func_name]) + + def test_subcommand_attributes_set(self) -> None: + from cmd2 import constants + + @cmd2.with_annotated(subcommand_to='team', help='create', aliases=['c']) + def team_create(self, name: str) -> None: ... + + assert getattr(team_create, constants.SUBCMD_ATTR_COMMAND) == 'team' + assert getattr(team_create, constants.SUBCMD_ATTR_NAME) == 'create' + assert getattr(team_create, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS) == {'help': 'create', 'aliases': ['c']} + parser = getattr(team_create, constants.CMD_ATTR_ARGPARSER)() + assert isinstance(parser, argparse.ArgumentParser) + + def test_subcommand_without_help(self) -> None: + """Subcommand with no help or aliases -- covers the None/empty branches.""" + from cmd2 import constants + + @cmd2.with_annotated(subcommand_to='team') + def team_delete(self) -> None: ... + + assert getattr(team_delete, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS) == {} diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index a7e1b3a1b..89e5e7334 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -1134,6 +1134,38 @@ def test_complete_command_help_no_tokens(ac_app) -> None: assert not completions +def test_complete_for_arg_type_without_name(ac_app) -> None: + from cmd2.argparse_completer import ( + ArgparseCompleter, + ) + + parser = Cmd2ArgumentParser() + parser.add_argument('value') + action = next(action for action in parser._actions if action.dest == 'value') + + class TypeWithoutName: + __name__ = property(lambda self: (_ for _ in ()).throw(AttributeError("__name__"))) + + action.type = TypeWithoutName() + arg_state = argparse_completer._ArgumentState(action) + ac = ArgparseCompleter(parser, ac_app) + + assert ac._get_raw_choices(arg_state) is None + + +def test_bool_type_completion_values(ac_app) -> None: + from cmd2.annotated import _parse_bool + + parser = Cmd2ArgumentParser() + parser.add_argument("flag", type=_parse_bool) + ac = argparse_completer.ArgparseCompleter(parser, ac_app) + action = next(action for action in parser._actions if action.dest == 'flag') + arg_state = argparse_completer._ArgumentState(action) + + completions = ac._complete_arg(text="", line="", begidx=0, endidx=0, arg_state=arg_state, consumed_arg_values={}) + assert list(completions.to_strings()) == ["0", "1", "false", "no", "off", "on", "true", "yes"] + + @pytest.mark.parametrize( ('flag', 'expected'), [