From dc009efed6cea827dbdfef8ea7711135be61f8ca Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Mon, 23 Mar 2026 17:50:15 +0000 Subject: [PATCH 1/7] feat: add annotated argparse building --- cmd2/__init__.py | 9 + cmd2/annotated.py | 345 +++++++++++++++++++++++++++ cmd2/argparse_completer.py | 19 +- cmd2/decorators.py | 73 ++++++ docs/features/argument_processing.md | 161 ++++++++++++- examples/annotated_example.py | 219 +++++++++++++++++ 6 files changed, 820 insertions(+), 6 deletions(-) create mode 100644 cmd2/annotated.py create mode 100755 examples/annotated_example.py 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..8382fa82d --- /dev/null +++ b/cmd2/annotated.py @@ -0,0 +1,345 @@ +"""Build argparse parsers from type-annotated function signatures. + +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:: + + 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 ``False`` -- ``--flag`` with ``store_true`` +- ``bool`` with default ``True`` -- ``--no-flag`` with ``store_false`` +- ``pathlib.Path`` -- sets ``type=Path`` +- ``enum.Enum`` subclass -- ``type=converter``, ``choices`` from member values +- ``list[T]`` -- ``nargs='+'`` (or ``'*'`` if has a default) +- ``T | None`` -- unwrapped to ``T``, treated as optional + +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. +""" + +import argparse +import enum +import inspect +import pathlib +import types +from collections.abc import Callable +from typing import ( + Annotated, + Any, + 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.""" + + 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 = False, + ) -> 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 + + +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 + + +# --------------------------------------------------------------------------- +# Type helpers +# --------------------------------------------------------------------------- + +_NoneType = type(None) + + +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*. + """ + # Pre-build a value→member lookup for O(1) conversion + _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 + # Fallback to name lookup + 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__ + return _convert + + +def _unwrap_type(annotation: Any) -> tuple[Any, Argument | Option | None]: + """Unwrap ``Annotated[T, metadata]`` and return ``(base_type, metadata)``. + + Returns ``(annotation, None)`` when there is no ``Annotated`` wrapper or + no ``Argument``/``Option`` metadata inside it. + """ + if get_origin(annotation) is Annotated: + args = get_args(annotation) + base_type = args[0] + for meta in args[1:]: + if isinstance(meta, (Argument, Option)): + return base_type, meta + return base_type, None + return annotation, None + + +def _unwrap_optional(tp: Any) -> tuple[Any, bool]: + """Strip ``Optional[T]`` / ``T | None`` and return ``(inner_type, is_optional)``.""" + origin = get_origin(tp) + if origin is Union or origin is types.UnionType: + args = [a for a in get_args(tp) if a is not _NoneType] + if len(args) == 1: + return args[0], True + return tp, False + + +def _unwrap_list(tp: Any) -> tuple[Any, bool]: + """Strip ``list[T]`` and return ``(inner_type, is_list)``.""" + if get_origin(tp) is list: + args = get_args(tp) + if args: + return args[0], True + return tp, False + + +# --------------------------------------------------------------------------- +# Signature → Parser conversion +# --------------------------------------------------------------------------- + + +def build_parser_from_function(func: Callable[..., Any]) -> 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 behaviour. + + :param func: the command function to inspect + :return: a fully configured ``Cmd2ArgumentParser`` + """ + from .argparse_custom import DEFAULT_ARGUMENT_PARSER + + parser = DEFAULT_ARGUMENT_PARSER() + + sig = inspect.signature(func) + try: + hints = get_type_hints(func, include_extras=True) + except (NameError, AttributeError, TypeError): + hints = {} + + for name, param in sig.parameters.items(): + if name == 'self': + continue + + annotation = hints.get(name, param.annotation) + has_default = param.default is not inspect.Parameter.empty + default = param.default if has_default else None + + # 1. Unwrap Annotated[T, metadata] + base_type, metadata = _unwrap_type(annotation) + + # 2. Unwrap Optional[T] / T | None + base_type, is_optional = _unwrap_optional(base_type) + + # 3. Unwrap list[T] + inner_type, is_list = _unwrap_list(base_type) + if is_list: + base_type = inner_type + + # 4. Determine positional vs option + if isinstance(metadata, Argument): + is_positional = True + elif isinstance(metadata, Option): + is_positional = False + elif not has_default and not is_optional: + is_positional = True + else: + is_positional = False + + # 5. Build add_argument kwargs + kwargs: dict[str, Any] = {} + + # Help text + help_text = metadata.help_text if metadata else None + if help_text: + kwargs['help'] = help_text + + # Metavar + metavar = metadata.metavar if metadata else None + if metavar: + kwargs['metavar'] = metavar + + # Nargs from metadata + explicit_nargs = metadata.nargs if metadata else None + if explicit_nargs is not None: + kwargs['nargs'] = explicit_nargs + elif is_list: + kwargs['nargs'] = '*' if has_default else '+' + + # Type-specific handling + is_bool_flag = False + if base_type is bool and not is_list and not is_positional: + is_bool_flag = True + action_str = getattr(metadata, 'action', None) if metadata else None + if action_str: + kwargs['action'] = action_str + elif has_default and default is True: + kwargs['action'] = 'store_false' + else: + kwargs['action'] = 'store_true' + elif isinstance(base_type, type) and issubclass(base_type, enum.Enum): + kwargs['type'] = _make_enum_type(base_type) + kwargs['choices'] = [m.value for m in base_type] + elif base_type is pathlib.Path or (isinstance(base_type, type) and issubclass(base_type, pathlib.Path)): + kwargs['type'] = pathlib.Path + elif base_type in (int, float, str): + if base_type is not str: + kwargs['type'] = base_type + + if has_default: + kwargs['default'] = default + + # Static choices from metadata (unless already set by enum inference) + explicit_choices = getattr(metadata, 'choices', None) + if explicit_choices is not None and 'choices' not in kwargs: + kwargs['choices'] = explicit_choices + + # cmd2-specific fields from metadata + choices_provider = getattr(metadata, 'choices_provider', None) + completer_func = getattr(metadata, 'completer', None) + table_columns = getattr(metadata, 'table_columns', None) + suppress_tab_hint = getattr(metadata, 'suppress_tab_hint', False) + + if choices_provider: + kwargs['choices_provider'] = choices_provider + if completer_func: + kwargs['completer'] = completer_func + if table_columns: + kwargs['table_columns'] = table_columns + if suppress_tab_hint: + kwargs['suppress_tab_hint'] = suppress_tab_hint + + # 6. Call add_argument + if is_positional: + parser.add_argument(name, **kwargs) + else: + # Option + option_metadata = metadata if isinstance(metadata, Option) else None + if option_metadata and option_metadata.names: + flag_names = list(option_metadata.names) + else: + flag_names = [f'--{name}'] + if is_bool_flag and has_default and default is True: + flag_names = [f'--no-{name}'] + + if option_metadata and option_metadata.required: + kwargs['required'] = True + + # Set dest explicitly so it matches the parameter name + kwargs['dest'] = name + + parser.add_argument(*flag_names, **kwargs) + + return parser diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 0b2c3b3f9..bbfb920e4 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,22 @@ 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] + + return None def _prepare_callable_params( self, diff --git a/cmd2/decorators.py b/cmd2/decorators.py index eb159d157..14289401e 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -1,6 +1,7 @@ """Decorators for ``cmd2`` commands.""" import argparse +import functools from collections.abc import ( Callable, Sequence, @@ -344,6 +345,78 @@ def cmd_wrapper(*args: Any, **kwargs: dict[str, Any]) -> bool | None: return arg_decorator +def with_annotated( + func: Callable[..., Any] | None = None, + *, + preserve_quotes: bool = False, + with_unknown_args: bool = False, +) -> Any: + """Decorate a ``do_*`` method to build its argparse parser from type annotations. + + Can be used bare or with keyword arguments:: + + @with_annotated + def do_greet(self, name: str, count: int = 1): ... + + @with_annotated(preserve_quotes=True) + def do_raw(self, text: str): ... + + :param func: the command function (when used without parentheses) + :param preserve_quotes: if True, preserve quotes in arguments + :param with_unknown_args: if True, capture unknown args (passed as extra kwarg ``_unknown``) + """ + from .annotated import build_parser_from_function + + def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: + command_name = fn.__name__[len(constants.COMMAND_FUNC_PREFIX) :] + + @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}') + + try: + if with_unknown_args: + ns, unknown = arg_parser.parse_known_args(parsed_arglist) + else: + ns = arg_parser.parse_args(parsed_arglist) + unknown = None + except SystemExit as exc: + raise Cmd2ArgparseError from exc + + # Unpack Namespace into function kwargs + func_kwargs: dict[str, Any] = {} + for key, value in vars(ns).items(): + if key.startswith('cmd2_') or key == constants.NS_ATTR_SUBCMD_HANDLER: + continue + func_kwargs[key] = value + + if with_unknown_args: + func_kwargs['_unknown'] = unknown + + result: bool | None = fn(owner, **func_kwargs) + return result + + # Store a parser-builder callable — _CommandParsers._build_parser() + # already handles callables by calling them with no arguments. + setattr(cmd_wrapper, constants.CMD_ATTR_ARGPARSER, lambda: build_parser_from_function(fn)) + 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..034108df2 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.annotated.with_annotated] -- build parsers automatically from type hints See the -[argparse_example](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_example.py) -example to learn more about how to use the various `cmd2` argument processing decorators in your -`cmd2` applications. +[argparse_completion](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_completion.py) +and [annotated_example](https://github.com/python-cmd2/cmd2/blob/main/examples/annotated_example.py) +examples to compare the two styles side by side. `cmd2` provides the following [decorators](../api/decorators.md) for assisting with parsing arguments passed to commands: - [cmd2.decorators.with_argparser][] +- [cmd2.annotated.with_annotated][] - [cmd2.decorators.with_argument_list][] All of these decorators accept an optional **preserve_quotes** argument which defaults to `False`. @@ -52,6 +55,154 @@ 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.annotated.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. 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` (default `False`) | `--flag` with `action='store_true'` | +| `bool` (default `True`) | `--no-flag` with `action='store_false'` | +| `Path` | `type=Path` | +| `Enum` subclass | `type=converter`, `choices` from member values | +| `list[T]` | `nargs='+'` (or `'*'` if it has a default) | +| `T \| None` | unwrapped to `T`, treated as optional | + +### 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. The argparse version gives you +more control (e.g. `ns_provider`, subcommand handlers via `cmd2_handler`). + +### Decorator options + +`@with_annotated` accepts the same keyword arguments as `@with_argparser`: + +- `preserve_quotes` -- if `True`, quotes in arguments are preserved +- `with_unknown_args` -- if `True`, unrecognised arguments are passed as `_unknown` + +```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 +`type=Path` and `type=converter` in the underlying parser. + ## 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..9966c2804 --- /dev/null +++ b/examples/annotated_example.py @@ -0,0 +1,219 @@ +#!/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 enum import Enum +from pathlib import Path +from typing import Annotated + +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" + + +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'] + + # -- 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 + 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 + 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 + 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 set action='store_true' or 'store_false'. + # Here bool defaults drive the flag style automatically. + # False default -> --flag (store_true) + # True default -> --no-flag (store_false) + + @cmd2.with_annotated + 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 ``--verbose`` (store_true). + ``color: bool = True`` becomes ``--no-color`` (store_false). + + 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 + 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)}") + + # -- 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 + 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 + 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)") + + # -- Preserve quotes ----------------------------------------------------- + + @cmd2.with_annotated(preserve_quotes=True) + 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()) From efe03d49191b95affae4fc7ebc6a8c8ddf39b2b1 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Mon, 23 Mar 2026 17:50:27 +0000 Subject: [PATCH 2/7] chore: add test --- tests/test_annotated.py | 721 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 721 insertions(+) create mode 100644 tests/test_annotated.py diff --git a/tests/test_annotated.py b/tests/test_annotated.py new file mode 100644 index 000000000..cdcd886be --- /dev/null +++ b/tests/test_annotated.py @@ -0,0 +1,721 @@ +"""Tests for the @with_annotated decorator and type inference in ArgparseCompleter.""" + +import argparse +import enum +from pathlib import Path +from typing import Annotated + +import pytest + +import cmd2 +from cmd2 import ( + Choices, + Cmd2ArgumentParser, +) +from cmd2.annotated import ( + Argument, + Option, + build_parser_from_function, +) + +from .conftest import run_cmd + +# --------------------------------------------------------------------------- +# Test functions for build_parser_from_function (not on any class) +# --------------------------------------------------------------------------- + + +def _func_positional_str(self, name: str) -> None: ... +def _func_option_with_default(self, count: int = 1) -> None: ... +def _func_bool_false(self, verbose: bool = False) -> None: ... +def _func_bool_true(self, debug: bool = True) -> None: ... + + +class _Color(str, enum.Enum): + red = "red" + green = "green" + blue = "blue" + + +def _func_enum(self, color: _Color) -> None: ... +def _func_path(self, path: Path = Path(".")) -> None: ... +def _func_list(self, files: list[str]) -> None: ... +def _func_optional(self, name: str | None = None) -> 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_metavar(self, name: Annotated[str, Argument(metavar="NAME")]) -> None: ... +def _func_explicit_nargs(self, names: Annotated[str, Argument(nargs=2)]) -> None: ... +def _func_explicit_action(self, verbose: Annotated[bool, Option(action="count")] = False) -> None: ... +def _func_unknown_type(self, data: dict | None = None) -> None: ... +def _func_completer(self, path: Annotated[str, Argument(completer=cmd2.Cmd.path_complete)]) -> None: ... +def _func_table_columns(self, item: Annotated[str, Argument(table_columns=("ID", "Name"))]) -> None: ... +def _func_suppress_hint(self, item: Annotated[str, Argument(suppress_tab_hint=True)]) -> None: ... +def _func_required_option(self, name: Annotated[str, Option("--name", required=True)]) -> None: ... +def _func_annotated_no_metadata(self, name: Annotated[str, "some doc"]) -> None: ... +def _func_list_with_default(self, items: list[str] | None = None) -> None: ... +def _func_float_option(self, rate: float = 1.0) -> None: ... +def _func_positional_bool(self, flag: bool) -> None: ... +def _func_enum_with_default(self, color: _Color = _Color.blue) -> None: ... +def _func_positional_path(self, path: Path) -> None: ... + + +FOOD_ITEMS = ['Pizza', 'Ham', 'Potato'] + + +def _func_static_choices(self, food: Annotated[str, Argument(choices=FOOD_ITEMS)]) -> None: ... + + +def _func_option_choices(self, food: Annotated[str, Option("--food", choices=FOOD_ITEMS)] = "Pizza") -> None: ... + + +class _IntColor(enum.IntEnum): + red = 1 + green = 2 + blue = 3 + + +def _func_int_enum(self, color: _IntColor) -> None: ... + + +# --------------------------------------------------------------------------- +# Parametrized parser construction tests +# --------------------------------------------------------------------------- + + +def _find_action(parser: argparse.ArgumentParser, dest: str) -> argparse.Action: + for action in parser._actions: + if action.dest == dest: + return action + raise ValueError(f"No action with dest={dest!r}") + + +class TestBuildParserParams: + @pytest.mark.parametrize( + ("func", "param_name", "expected"), + [ + pytest.param( + _func_positional_str, + "name", + {"option_strings": [], "type": None}, + id="positional_str", + ), + pytest.param( + _func_option_with_default, + "count", + {"option_strings": ["--count"], "type": int, "default": 1}, + id="option_with_default", + ), + pytest.param( + _func_bool_false, + "verbose", + {"option_strings": ["--verbose"]}, + id="bool_flag_false", + ), + pytest.param( + _func_bool_true, + "debug", + {"option_strings": ["--no-debug"]}, + id="bool_flag_true", + ), + pytest.param( + _func_enum, + "color", + {"option_strings": [], "choices": ["red", "green", "blue"]}, + id="enum_choices", + ), + pytest.param( + _func_path, + "path", + {"option_strings": ["--path"], "type": Path}, + id="path_type", + ), + pytest.param( + _func_list, + "files", + {"option_strings": [], "nargs": "+"}, + id="list_nargs", + ), + pytest.param( + _func_optional, + "name", + {"option_strings": ["--name"], "default": None}, + id="optional_type", + ), + pytest.param( + _func_float_option, + "rate", + {"option_strings": ["--rate"], "type": float, "default": 1.0}, + id="float_option", + ), + pytest.param( + _func_positional_bool, + "flag", + {"option_strings": [], "type": None}, + id="positional_bool_no_action", + ), + pytest.param( + _func_enum_with_default, + "color", + {"option_strings": ["--color"], "choices": ["red", "green", "blue"]}, + id="enum_with_default_becomes_option", + ), + pytest.param( + _func_positional_path, + "path", + {"option_strings": [], "type": Path}, + id="positional_path_no_default", + ), + pytest.param( + _func_static_choices, + "food", + {"option_strings": [], "choices": FOOD_ITEMS}, + id="static_choices_positional", + ), + pytest.param( + _func_option_choices, + "food", + {"option_strings": ["--food"], "choices": FOOD_ITEMS, "default": "Pizza"}, + id="static_choices_option", + ), + ], + ) + def test_build_parser_params(self, func, param_name, expected): + parser = build_parser_from_function(func) + action = _find_action(parser, param_name) + for key, value in expected.items(): + assert getattr(action, key) == value, f"{key}: expected {value!r}, got {getattr(action, key)!r}" + + +class TestBuildParserEdgeCases: + @pytest.mark.parametrize( + ("func", "param_name", "expected"), + [ + pytest.param( + _func_metavar, + "name", + {"metavar": "NAME"}, + id="metavar", + ), + pytest.param( + _func_explicit_nargs, + "names", + {"nargs": 2}, + id="explicit_nargs", + ), + pytest.param( + _func_unknown_type, + "data", + {"default": None, "option_strings": ["--data"]}, + id="unknown_type_with_default", + ), + pytest.param( + _func_required_option, + "name", + {"required": True, "option_strings": ["--name"]}, + id="required_option", + ), + pytest.param( + _func_annotated_no_metadata, + "name", + {"option_strings": []}, + id="annotated_no_arg_option_metadata", + ), + pytest.param( + _func_list_with_default, + "items", + {"nargs": "*", "option_strings": ["--items"]}, + id="list_with_default_star_nargs", + ), + ], + ) + def test_edge_cases(self, func, param_name, expected): + parser = build_parser_from_function(func) + action = _find_action(parser, param_name) + for key, value in expected.items(): + assert getattr(action, key) == value, f"{key}: expected {value!r}, got {getattr(action, key)!r}" + + def test_completer_wired(self): + parser = build_parser_from_function(_func_completer) + action = _find_action(parser, "path") + cc = action.get_choices_callable() + assert cc is not None + assert cc.is_completer is True + + def test_table_columns_wired(self): + parser = build_parser_from_function(_func_table_columns) + action = _find_action(parser, "item") + assert action.get_table_columns() == ("ID", "Name") + + def test_suppress_tab_hint_wired(self): + parser = build_parser_from_function(_func_suppress_hint) + action = _find_action(parser, "item") + assert action.get_suppress_tab_hint() is True + + def test_enum_by_value(self): + """Test that enum type converter accepts member values.""" + from cmd2.annotated import _make_enum_type + + converter = _make_enum_type(_Color) + assert converter("red") == _Color.red + assert converter("green") == _Color.green + + def test_enum_by_name_fallback(self): + """Test enum lookup by name when value doesn't match. + + _IntColor has int values (1, 2, 3) so string "red" won't match + any value — falls through to name lookup. + """ + from cmd2.annotated import _make_enum_type + + converter = _make_enum_type(_IntColor) + assert converter("red") == _IntColor.red + assert converter("blue") == _IntColor.blue + + def test_enum_invalid_value(self): + """Test enum converter raises on invalid value.""" + from cmd2.annotated import _make_enum_type + + converter = _make_enum_type(_Color) + with pytest.raises(argparse.ArgumentTypeError, match="invalid choice"): + converter("purple") + + def test_explicit_action_in_metadata(self): + parser = build_parser_from_function(_func_explicit_action) + action = _find_action(parser, "verbose") + # 'count' action from metadata + assert isinstance(action, argparse._CountAction) + + +class TestAnnotatedMetadata: + @pytest.mark.parametrize( + ("func", "param_name", "expected"), + [ + pytest.param( + _func_annotated_arg, + "name", + {"option_strings": [], "help": "Your name"}, + id="annotated_argument_help", + ), + pytest.param( + _func_annotated_option, + "color", + {"option_strings": ["--color", "-c"], "help": "Pick"}, + id="annotated_option_custom_names", + ), + ], + ) + def test_annotated_metadata(self, func, param_name, expected): + parser = build_parser_from_function(func) + action = _find_action(parser, param_name) + for key, value in expected.items(): + assert getattr(action, key) == value, f"{key}: expected {value!r}, got {getattr(action, key)!r}" + + +# --------------------------------------------------------------------------- +# Integration test app +# --------------------------------------------------------------------------- + + +class _Sport(str, enum.Enum): + football = "football" + basketball = "basketball" + tennis = "tennis" + + +class AnnotatedApp(cmd2.Cmd): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._items = ["apple", "banana", "cherry"] + + def item_choices(self) -> Choices: + return Choices.from_values(self._items) + + @cmd2.with_annotated + def do_greet(self, name: str, count: int = 1) -> None: + """Greet someone.""" + for _ in range(count): + self.poutput(f"Hello {name}") + + @cmd2.with_annotated + def do_add(self, a: int, b: int = 0) -> None: + """Add two numbers.""" + 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: + """Paint an item.""" + 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: + """Pick an item with completion.""" + self.poutput(f"Picked: {item}") + + @cmd2.with_annotated + def do_open(self, path: Path) -> None: + """Open a file.""" + self.poutput(f"Opening: {path}") + + @cmd2.with_annotated + def do_sport(self, sport: _Sport) -> None: + """Pick a sport.""" + self.poutput(f"Playing: {sport.value}") + + @cmd2.with_annotated(preserve_quotes=True) + def do_raw(self, text: str) -> None: + """Echo raw text.""" + self.poutput(f"raw: {text}") + + +@pytest.fixture +def ann_app() -> AnnotatedApp: + app = AnnotatedApp() + app.stdout = cmd2.utils.StdSim(app.stdout) + return app + + +# --------------------------------------------------------------------------- +# Integration: command execution +# --------------------------------------------------------------------------- + + +class TestCommandExecution: + @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, ann_app, command, expected): + out, _err = run_cmd(ann_app, command) + assert out == expected + + +# --------------------------------------------------------------------------- +# Integration: tab completion +# --------------------------------------------------------------------------- + + +class TestTabCompletion: + def test_enum_completion(self, ann_app): + text = "" + line = "paint wall --color " + endidx = len(line) + begidx = endidx - len(text) + completions = ann_app.complete(text, line, begidx, endidx) + values = sorted(completions.to_strings()) + assert values == ["blue", "green", "red"] + + def test_enum_completion_partial(self, ann_app): + text = "r" + line = f"paint wall --color {text}" + endidx = len(line) + begidx = endidx - len(text) + completions = ann_app.complete(text, line, begidx, endidx) + assert list(completions.to_strings()) == ["red"] + + def test_choices_provider_completion(self, ann_app): + text = "" + line = "pick " + endidx = len(line) + begidx = endidx - len(text) + completions = ann_app.complete(text, line, begidx, endidx) + values = sorted(completions.to_strings()) + assert values == ["apple", "banana", "cherry"] + + def test_positional_enum_completion(self, ann_app): + text = "foot" + line = f"sport {text}" + endidx = len(line) + begidx = endidx - len(text) + completions = ann_app.complete(text, line, begidx, endidx) + assert list(completions.to_strings()) == ["football"] + + +# --------------------------------------------------------------------------- +# Type inference tests (benefits @with_argparser users too) +# --------------------------------------------------------------------------- + + +class _InferColor(str, enum.Enum): + red = "red" + green = "green" + + +class TypeInferenceApp(cmd2.Cmd): + """App using manual @with_argparser to test type inference.""" + + 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() -> TypeInferenceApp: + app = TypeInferenceApp() + app.stdout = cmd2.utils.StdSim(app.stdout) + return app + + +class TestTypeInference: + def test_enum_type_inference(self, infer_app): + text = "" + line = "pick_color " + endidx = len(line) + begidx = endidx - len(text) + completions = infer_app.complete(text, line, begidx, endidx) + assert sorted(completions.to_strings()) == ["green", "red"] + + def test_path_type_inference(self, infer_app, tmp_path): + """type=Path on a manual parser triggers path_complete via type inference.""" + # Create a file so path completion has something to find + test_file = tmp_path / "testfile.txt" + test_file.touch() + + text = str(tmp_path) + "/" + line = f"read {text}" + endidx = len(line) + begidx = endidx - len(text) + completions = infer_app.complete(text, line, begidx, endidx) + assert len(completions) > 0 + result_strings = list(completions.to_strings()) + assert any("testfile.txt" in s for s in result_strings) + + +# --------------------------------------------------------------------------- +# Help output test +# --------------------------------------------------------------------------- + + +class TestHelpOutput: + def test_help_shows_arguments(self, ann_app): + out, _ = run_cmd(ann_app, "help greet") + help_text = "\n".join(out) + assert "name" in help_text.lower() + + def test_help_shows_option_help(self, ann_app): + out, _ = run_cmd(ann_app, "help paint") + help_text = "\n".join(out) + assert "Color" in help_text or "color" in help_text + + +# --------------------------------------------------------------------------- +# Preserve quotes test +# --------------------------------------------------------------------------- + + +class TestPreserveQuotes: + def test_preserve_quotes(self, ann_app): + out, _ = run_cmd(ann_app, 'raw "hello world"') + assert out == ['raw: "hello world"'] + + +# --------------------------------------------------------------------------- +# with_unknown_args test +# --------------------------------------------------------------------------- + + +class UnknownArgsApp(cmd2.Cmd): + @cmd2.with_annotated(with_unknown_args=True) + def do_flex(self, name: str, _unknown: list[str] | None = None) -> None: + """Command that accepts unknown args.""" + self.poutput(f"name={name}") + if _unknown: + self.poutput(f"unknown={_unknown}") + + +@pytest.fixture +def unknown_app() -> UnknownArgsApp: + app = UnknownArgsApp() + app.stdout = cmd2.utils.StdSim(app.stdout) + return app + + +class TestUnknownArgs: + def test_with_unknown_args(self, unknown_app): + out, _ = run_cmd(unknown_app, "flex Alice --extra stuff") + assert out[0] == "name=Alice" + assert "unknown=" in out[1] + + +# --------------------------------------------------------------------------- +# Argparse error test +# --------------------------------------------------------------------------- + + +class TestArgparseError: + def test_invalid_args_raise_error(self, ann_app): + """Missing required positional arg should not crash.""" + _out, err = run_cmd(ann_app, "add") + # argparse prints usage/error to stderr + err_text = "\n".join(err) + assert "required" in err_text.lower() or "error" in err_text.lower() or "usage" in err_text.lower() + + +# --------------------------------------------------------------------------- +# get_type_hints failure fallback +# --------------------------------------------------------------------------- + + +class TestGetTypeHintsFailure: + def test_bad_annotation_falls_back(self): + """When get_type_hints raises, build_parser_from_function still works using raw annotations.""" + # Create a function with a forward reference that can't be resolved + exec_globals: dict = {} + exec( + "from cmd2.annotated import build_parser_from_function\n" + "def func(self, name: 'NonExistentType' = 'default'): ...\n" + "result = build_parser_from_function(func)\n", + exec_globals, + ) + parser = exec_globals["result"] + # Should still produce a parser (falls back to raw signature) + assert parser is not None + + +# --------------------------------------------------------------------------- +# _parse_positionals error path +# --------------------------------------------------------------------------- + + +class TestParsePositionalsError: + def test_raises_on_bad_args(self): + """_parse_positionals raises TypeError when no Cmd/CommandSet is found.""" + from cmd2.decorators import _parse_positionals + + with pytest.raises(TypeError, match="Expected arguments"): + _parse_positionals(("not_a_cmd", "not_a_statement")) + + +# --------------------------------------------------------------------------- +# NS_ATTR_SUBCMD_HANDLER filtering +# --------------------------------------------------------------------------- + + +class SubcmdApp(cmd2.Cmd): + @cmd2.with_annotated + def do_echo(self, msg: str) -> None: + """Echo a message.""" + self.poutput(msg) + + +@pytest.fixture +def subcmd_app() -> SubcmdApp: + app = SubcmdApp() + app.stdout = cmd2.utils.StdSim(app.stdout) + return app + + +class TestNamespaceFiltering: + def test_subcmd_handler_filtered(self, subcmd_app): + """Verify __subcmd_handler__ is filtered from kwargs passed to the function. + + We can't easily inject __subcmd_handler__ through argparse, but we can verify + the command works correctly which exercises the filtering loop. + """ + out, _ = run_cmd(subcmd_app, "echo hello") + assert out == ["hello"] + + def test_typing_union_optional(self): + """typing.Union[str, None] should be treated the same as str | None.""" + from cmd2.annotated import _unwrap_optional + + # Build a typing.Union type dynamically to exercise the Union code path + # (distinct from the types.UnionType path used by `str | None`) + ns: dict = {} + exec("import typing; t = typing.Union[str, None]", ns) + union_type = ns["t"] + inner, is_opt = _unwrap_optional(union_type) + assert inner is str + assert is_opt is True + + # Also test non-optional passes through + inner2, is_opt2 = _unwrap_optional(str) + assert inner2 is str + assert is_opt2 is False + + def test_namespace_filtering_directly(self): + """Directly test that internal namespace keys are filtered.""" + import argparse as ap + + from cmd2 import constants + + ns = ap.Namespace(msg="hello", cmd2_statement="x", **{constants.NS_ATTR_SUBCMD_HANDLER: None}) + func_kwargs = {} + for key, value in vars(ns).items(): + if key.startswith('cmd2_') or key == constants.NS_ATTR_SUBCMD_HANDLER: + continue + func_kwargs[key] = value + assert func_kwargs == {"msg": "hello"} + + +# --------------------------------------------------------------------------- +# CommandSet integration +# --------------------------------------------------------------------------- + + +class AnnotatedCommandSet(cmd2.CommandSet): + def __init__(self) -> None: + super().__init__() + self._sports = ["football", "baseball"] + + def sport_choices(self) -> Choices: + return Choices.from_values(self._sports) + + @cmd2.with_annotated + def do_play( + self, + sport: Annotated[str, Argument(choices_provider=sport_choices)], + ) -> None: + """Play a sport.""" + 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): + out, _err = run_cmd(cmdset_app, "play football") + assert out == ["Playing football"] + + def test_command_set_completion(self, cmdset_app): + text = "" + line = "play " + endidx = len(line) + begidx = endidx - len(text) + completions = cmdset_app.complete(text, line, begidx, endidx) + assert sorted(completions.to_strings()) == ["baseball", "football"] From 71c57786c8a1ebd2fb6cf4d8514abd870280b616 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Mon, 23 Mar 2026 18:28:07 +0000 Subject: [PATCH 3/7] chore: more update --- cmd2/annotated.py | 144 ++++++++++++++++++++++++--- cmd2/argparse_completer.py | 4 + cmd2/decorators.py | 8 ++ docs/features/argument_processing.md | 36 ++++--- tests/test_annotated.py | 117 +++++++++++++++++++++- 5 files changed, 275 insertions(+), 34 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 8382fa82d..c5c1a6dbc 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -41,9 +41,12 @@ def do_paint( - ``int``, ``float`` -- sets ``type=`` for argparse - ``bool`` with default ``False`` -- ``--flag`` with ``store_true`` - ``bool`` with default ``True`` -- ``--no-flag`` with ``store_false`` +- 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 -- ``list[T]`` -- ``nargs='+'`` (or ``'*'`` if has a default) +- ``decimal.Decimal`` -- sets ``type=Decimal`` +- ``Literal[...]`` -- sets ``type=converter`` and ``choices`` from literal values +- ``Collection[T]`` / ``list[T]`` / ``set[T]`` / ``tuple[T, ...]`` -- ``nargs='+'`` (or ``'*'`` if has a default) - ``T | None`` -- unwrapped to ``T``, treated as optional Note: ``Path`` and ``Enum`` types also get automatic tab completion via @@ -52,14 +55,19 @@ def do_paint( """ import argparse +import decimal import enum import inspect import pathlib import types -from collections.abc import Callable +from collections.abc import ( + Callable, + Collection, +) from typing import ( Annotated, Any, + Literal, Union, get_args, get_origin, @@ -144,6 +152,63 @@ def __init__( _NoneType = type(None) +_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 = {str(value): value for value in literal_values} + + 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 + + +class _CollectionStoreAction(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) + def _make_enum_type(enum_class: type[enum.Enum]) -> Callable[[str], enum.Enum]: """Create an argparse *type* converter for an Enum class. @@ -165,6 +230,8 @@ def _convert(value: str) -> enum.Enum: raise argparse.ArgumentTypeError(f"invalid choice: {value!r} (choose from {valid})") from err _convert.__name__ = enum_class.__name__ + # Preserve the enum class for downstream consumers like tab completion. + _convert._cmd2_enum_class = enum_class return _convert @@ -194,13 +261,42 @@ def _unwrap_optional(tp: Any) -> tuple[Any, bool]: return tp, False -def _unwrap_list(tp: Any) -> tuple[Any, bool]: - """Strip ``list[T]`` and return ``(inner_type, is_list)``.""" - if get_origin(tp) is list: +def _unwrap_collection(tp: Any) -> tuple[Any, str | None]: + """Strip collection[T] and return ``(inner_type, collection_kind)``.""" + origin = get_origin(tp) + if origin is list: args = get_args(tp) if args: - return args[0], True - return tp, False + return args[0], 'list' + + if origin is set: + args = get_args(tp) + if args: + return args[0], 'set' + + if origin is Collection: + args = get_args(tp) + if args: + return args[0], 'collection' + + if origin is tuple: + args = get_args(tp) + if len(args) == 2 and args[1] is Ellipsis: + return args[0], 'tuple' + return tp, None + + +def _unwrap_literal(tp: Any) -> tuple[Any, list[Any] | None]: + """Strip ``Literal[...]`` and return ``(base_type, literal_values)``.""" + if get_origin(tp) is Literal: + literal_values = list(get_args(tp)) + if not literal_values: + return Any, [] + first_type = type(literal_values[0]) + if all(type(v) is first_type for v in literal_values): + return first_type, literal_values + return Any, literal_values + return tp, None # --------------------------------------------------------------------------- @@ -243,12 +339,16 @@ def build_parser_from_function(func: Callable[..., Any]) -> argparse.ArgumentPar # 2. Unwrap Optional[T] / T | None base_type, is_optional = _unwrap_optional(base_type) - # 3. Unwrap list[T] - inner_type, is_list = _unwrap_list(base_type) - if is_list: + # 3. Unwrap collection[T] + inner_type, collection_kind = _unwrap_collection(base_type) + is_collection = collection_kind is not None + if is_collection: base_type = inner_type - # 4. Determine positional vs option + # 4. Unwrap Literal[...] + base_type, literal_choices = _unwrap_literal(base_type) + + # 5. Determine positional vs option if isinstance(metadata, Argument): is_positional = True elif isinstance(metadata, Option): @@ -258,7 +358,7 @@ def build_parser_from_function(func: Callable[..., Any]) -> argparse.ArgumentPar else: is_positional = False - # 5. Build add_argument kwargs + # 6. Build add_argument kwargs kwargs: dict[str, Any] = {} # Help text @@ -275,12 +375,18 @@ def build_parser_from_function(func: Callable[..., Any]) -> argparse.ArgumentPar explicit_nargs = metadata.nargs if metadata else None if explicit_nargs is not None: kwargs['nargs'] = explicit_nargs - elif is_list: + elif is_collection: kwargs['nargs'] = '*' if has_default else '+' + if collection_kind in ('set', 'tuple'): + kwargs['action'] = _CollectionStoreAction + kwargs['container_factory'] = set if collection_kind == 'set' else tuple # Type-specific handling is_bool_flag = False - if base_type is bool and not is_list and not is_positional: + if literal_choices is not None: + kwargs['type'] = _make_literal_type(literal_choices) + kwargs['choices'] = literal_choices + elif base_type is bool and not is_collection and not is_positional: is_bool_flag = True action_str = getattr(metadata, 'action', None) if metadata else None if action_str: @@ -289,11 +395,17 @@ def build_parser_from_function(func: Callable[..., Any]) -> argparse.ArgumentPar kwargs['action'] = 'store_false' else: kwargs['action'] = 'store_true' + elif base_type is bool: + kwargs['type'] = _parse_bool elif isinstance(base_type, type) and issubclass(base_type, enum.Enum): + # Keep validation in the converter to support any Enum subclass, + # including enums whose members are not directly comparable to raw + # argparse input strings. kwargs['type'] = _make_enum_type(base_type) - kwargs['choices'] = [m.value for m in base_type] elif base_type is pathlib.Path or (isinstance(base_type, type) and issubclass(base_type, pathlib.Path)): kwargs['type'] = pathlib.Path + elif base_type is decimal.Decimal: + kwargs['type'] = decimal.Decimal elif base_type in (int, float, str): if base_type is not str: kwargs['type'] = base_type @@ -321,7 +433,7 @@ def build_parser_from_function(func: Callable[..., Any]) -> argparse.ArgumentPar if suppress_tab_hint: kwargs['suppress_tab_hint'] = suppress_tab_hint - # 6. Call add_argument + # 7. Call add_argument if is_positional: parser.add_argument(name, **kwargs) else: diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index bbfb920e4..a21cb1f11 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -746,6 +746,10 @@ def _get_raw_choices(self, arg_state: _ArgumentState) -> list[CompletionItem] | 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] + return None def _prepare_callable_params( diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 14289401e..97b18c201 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -2,6 +2,7 @@ import argparse import functools +import inspect from collections.abc import ( Callable, Sequence, @@ -368,6 +369,13 @@ def do_raw(self, text: str): ... from .annotated import build_parser_from_function 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') + command_name = fn.__name__[len(constants.COMMAND_FUNC_PREFIX) :] @functools.wraps(fn) diff --git a/docs/features/argument_processing.md b/docs/features/argument_processing.md index 034108df2..80d97215a 100644 --- a/docs/features/argument_processing.md +++ b/docs/features/argument_processing.md @@ -19,7 +19,7 @@ following for you: These features are provided by two decorators: - [@with_argparser][cmd2.with_argparser] -- build parsers manually with `add_argument()` calls -- [@with_annotated][cmd2.annotated.with_annotated] -- build parsers automatically from type hints +- [@with_annotated][cmd2.decorators.with_annotated] -- build parsers automatically from type hints See the [argparse_completion](https://github.com/python-cmd2/cmd2/blob/main/examples/argparse_completion.py) @@ -30,7 +30,7 @@ examples to compare the two styles side by side. arguments passed to commands: - [cmd2.decorators.with_argparser][] -- [cmd2.annotated.with_annotated][] +- [cmd2.decorators.with_annotated][] - [cmd2.decorators.with_argument_list][] All of these decorators accept an optional **preserve_quotes** argument which defaults to `False`. @@ -57,7 +57,7 @@ stores internally. A consequence is that parsers don't need to be unique across ## with_annotated decorator -The [@with_annotated][cmd2.annotated.with_annotated] decorator builds an argparse parser +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. @@ -85,16 +85,26 @@ them as keyword arguments. The decorator converts Python type annotations into `add_argument()` calls: -| Type annotation | Generated argparse setting | -| ------------------------ | ---------------------------------------------- | -| `str` | default (no `type=` needed) | -| `int`, `float` | `type=int` or `type=float` | -| `bool` (default `False`) | `--flag` with `action='store_true'` | -| `bool` (default `True`) | `--no-flag` with `action='store_false'` | -| `Path` | `type=Path` | -| `Enum` subclass | `type=converter`, `choices` from member values | -| `list[T]` | `nargs='+'` (or `'*'` if it has a default) | -| `T \| None` | unwrapped to `T`, treated as optional | +| Type annotation | Generated argparse setting | +| -------------------------------------------------------- | --------------------------------------------------- | +| `str` | default (no `type=` needed) | +| `int`, `float` | `type=int` or `type=float` | +| `bool` (default `False`) | `--flag` with `action='store_true'` | +| `bool` (default `True`) | `--no-flag` with `action='store_false'` | +| 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) | +| `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` ### Annotated metadata diff --git a/tests/test_annotated.py b/tests/test_annotated.py index cdcd886be..d9952e3ed 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -1,9 +1,14 @@ """Tests for the @with_annotated decorator and type inference in ArgparseCompleter.""" import argparse +import decimal import enum +from collections.abc import Collection from pathlib import Path -from typing import Annotated +from typing import ( + Annotated, + Literal, +) import pytest @@ -57,6 +62,12 @@ def _func_float_option(self, rate: float = 1.0) -> None: ... def _func_positional_bool(self, flag: bool) -> None: ... def _func_enum_with_default(self, color: _Color = _Color.blue) -> None: ... def _func_positional_path(self, path: Path) -> None: ... +def _func_decimal(self, amount: decimal.Decimal = decimal.Decimal("1.25")) -> None: ... +def _func_collection(self, ids: Collection[int]) -> None: ... +def _func_set_collection(self, tags: set[str]) -> None: ... +def _func_tuple_collection(self, values: tuple[int, ...]) -> None: ... +def _func_literal_option(self, mode: Literal["fast", "slow"] = "fast") -> None: ... +def _func_literal_positional_int(self, level: Literal[1, 2, 3]) -> None: ... FOOD_ITEMS = ['Pizza', 'Ham', 'Potato'] @@ -74,6 +85,12 @@ class _IntColor(enum.IntEnum): blue = 3 +class _PlainColor(enum.Enum): + RED = "red" + GREEN = "green" + BLUE = "blue" + + def _func_int_enum(self, color: _IntColor) -> None: ... @@ -120,7 +137,7 @@ class TestBuildParserParams: pytest.param( _func_enum, "color", - {"option_strings": [], "choices": ["red", "green", "blue"]}, + {"option_strings": []}, id="enum_choices", ), pytest.param( @@ -150,13 +167,13 @@ class TestBuildParserParams: pytest.param( _func_positional_bool, "flag", - {"option_strings": [], "type": None}, - id="positional_bool_no_action", + {"option_strings": []}, + id="positional_bool_parse_rule", ), pytest.param( _func_enum_with_default, "color", - {"option_strings": ["--color"], "choices": ["red", "green", "blue"]}, + {"option_strings": ["--color"]}, id="enum_with_default_becomes_option", ), pytest.param( @@ -165,6 +182,42 @@ class TestBuildParserParams: {"option_strings": [], "type": Path}, id="positional_path_no_default", ), + pytest.param( + _func_decimal, + "amount", + {"option_strings": ["--amount"], "type": decimal.Decimal, "default": decimal.Decimal("1.25")}, + id="decimal_option", + ), + pytest.param( + _func_collection, + "ids", + {"option_strings": [], "nargs": "+", "type": int}, + id="collection_positional", + ), + pytest.param( + _func_set_collection, + "tags", + {"option_strings": [], "nargs": "+"}, + id="set_collection_positional", + ), + pytest.param( + _func_tuple_collection, + "values", + {"option_strings": [], "nargs": "+", "type": int}, + id="tuple_collection_positional", + ), + pytest.param( + _func_literal_option, + "mode", + {"option_strings": ["--mode"], "choices": ["fast", "slow"], "default": "fast"}, + id="literal_option", + ), + pytest.param( + _func_literal_positional_int, + "level", + {"option_strings": [], "choices": [1, 2, 3]}, + id="literal_positional_int", + ), pytest.param( _func_static_choices, "food", @@ -285,6 +338,52 @@ def test_explicit_action_in_metadata(self): # 'count' action from metadata assert isinstance(action, argparse._CountAction) + def test_positional_bool_parse_rule(self): + parser = build_parser_from_function(_func_positional_bool) + assert parser.parse_args(["true"]).flag is True + assert parser.parse_args(["0"]).flag is False + + with pytest.raises(SystemExit): + parser.parse_args(["definitely"]) + + def test_literal_int_parses_as_int(self): + parser = build_parser_from_function(_func_literal_positional_int) + assert parser.parse_args(["2"]).level == 2 + + with pytest.raises(SystemExit): + parser.parse_args(["7"]) + + def test_set_collection_cast(self): + parser = build_parser_from_function(_func_set_collection) + parsed = parser.parse_args(["a", "b", "a"]) + assert isinstance(parsed.tags, set) + assert parsed.tags == {"a", "b"} + + def test_tuple_collection_cast(self): + parser = build_parser_from_function(_func_tuple_collection) + parsed = parser.parse_args(["1", "2", "3"]) + assert isinstance(parsed.values, tuple) + assert parsed.values == (1, 2, 3) + + def test_collection_cast_uses_store_action(self): + from cmd2.annotated import _CollectionStoreAction + + set_parser = build_parser_from_function(_func_set_collection) + set_action = _find_action(set_parser, "tags") + assert isinstance(set_action, _CollectionStoreAction) + + tuple_parser = build_parser_from_function(_func_tuple_collection) + tuple_action = _find_action(tuple_parser, "values") + assert isinstance(tuple_action, _CollectionStoreAction) + + def test_plain_enum_parses_by_value_and_name(self): + def _func_plain_enum(self, color: _PlainColor) -> None: ... + + parser = build_parser_from_function(_func_plain_enum) + assert parser.parse_args(["red"]).color is _PlainColor.RED + assert parser.parse_args(["green"]).color is _PlainColor.GREEN + assert parser.parse_args(["BLUE"]).color is _PlainColor.BLUE + class TestAnnotatedMetadata: @pytest.mark.parametrize( @@ -565,6 +664,14 @@ def test_with_unknown_args(self, unknown_app): assert out[0] == "name=Alice" assert "unknown=" in out[1] + def test_with_unknown_args_requires_unknown_parameter(self): + with pytest.raises(TypeError, match="requires a parameter named _unknown"): + + class _BadUnknownArgsApp(cmd2.Cmd): + @cmd2.with_annotated(with_unknown_args=True) + def do_bad(self, name: str) -> None: + self.poutput(name) + # --------------------------------------------------------------------------- # Argparse error test From 9c4212d04736f90f64eb0669ed41ea91bf393aea Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Mon, 23 Mar 2026 18:41:22 +0000 Subject: [PATCH 4/7] chore: better example --- examples/annotated_example.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/examples/annotated_example.py b/examples/annotated_example.py index 9966c2804..61276cfd3 100755 --- a/examples/annotated_example.py +++ b/examples/annotated_example.py @@ -42,6 +42,9 @@ class LogLevel(str, Enum): error = "error" +ANNOTATED_CATEGORY = "Annotated Commands" + + class AnnotatedExample(Cmd): """Demonstrates @with_annotated strengths over @with_argparser.""" @@ -57,6 +60,7 @@ def __init__(self) -> None: # 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. @@ -75,6 +79,7 @@ def do_add(self, a: int, b: int = 0, verbose: bool = False) -> None: # Here the Enum type provides choices and validation automatically. @cmd2.with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) def do_paint( self, item: str, @@ -94,6 +99,7 @@ def do_paint( # 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. @@ -109,6 +115,7 @@ def do_copy(self, src: Path, dst: Path) -> None: # True default -> --no-flag (store_false) @cmd2.with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) def do_build( self, target: str, @@ -135,6 +142,7 @@ def do_build( # 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. @@ -148,6 +156,7 @@ def do_sum(self, numbers: list[float]) -> None: # 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. @@ -175,6 +184,7 @@ def context_choices(self, arg_tokens: dict[str, list[str]]) -> Choices: return Choices.from_values(["play"]) @cmd2.with_annotated + @cmd2.with_category(ANNOTATED_CATEGORY) def do_score( self, sport: Annotated[ @@ -205,6 +215,7 @@ def do_score( # -- 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. From 90348202845c5679ecb5d3c59cf0b647405d9b1c Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Fri, 27 Mar 2026 21:15:23 +0000 Subject: [PATCH 5/7] chore: recover --- cmd2/annotated.py | 824 +++++++++++++++++------- cmd2/argparse_completer.py | 8 +- cmd2/decorators.py | 78 ++- tests/test_annotated.py | 1205 ++++++++++++++++++++---------------- 4 files changed, 1350 insertions(+), 765 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 1e788bc84..a5d1bccf9 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -1,5 +1,9 @@ """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 @@ -7,7 +11,10 @@ finer control is needed. Basic usage -- parameters without defaults become positional arguments, -parameters with defaults become ``--option`` flags:: +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 @@ -39,31 +46,45 @@ def do_paint( - ``str`` -- default string argument - ``int``, ``float`` -- sets ``type=`` for argparse -- ``bool`` with default ``False`` -- ``--flag`` with ``store_true`` -- ``bool`` with default ``True`` -- ``--no-flag`` with ``store_false`` +- ``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, - Collection, -) +from collections.abc import Callable, Container from typing import ( Annotated, Any, + ClassVar, Literal, Union, get_args, @@ -81,6 +102,16 @@ def do_paint( 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, *, @@ -91,7 +122,7 @@ def __init__( choices_provider: ChoicesProviderUnbound[CmdOrSet] | None = None, completer: CompleterUnbound[CmdOrSet] | None = None, table_columns: tuple[str, ...] | None = None, - suppress_tab_hint: bool = False, + suppress_tab_hint: bool | None = None, ) -> None: """Initialise shared metadata fields.""" self.help_text = help_text @@ -103,6 +134,10 @@ def __init__( 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. @@ -143,11 +178,23 @@ def __init__( 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 helpers +# 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. # --------------------------------------------------------------------------- - -_NoneType = type(None) _BOOL_TRUE_VALUES = {'1', 'true', 't', 'yes', 'y', 'on'} _BOOL_FALSE_VALUES = {'0', 'false', 'f', 'no', 'n', 'off'} @@ -165,7 +212,15 @@ def _parse_bool(value: str) -> bool: def _make_literal_type(literal_values: list[Any]) -> Callable[[str], Any]: """Create an argparse converter for a Literal's exact values.""" - value_map = {str(value): value for value in literal_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: @@ -187,7 +242,29 @@ def _convert(value: str) -> Any: return _convert -class _CollectionStoreAction(argparse._StoreAction): +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: @@ -207,92 +284,283 @@ def __call__( setattr(namespace, self.dest, result) -def _make_enum_type(enum_class: type[enum.Enum]) -> Callable[[str], enum.Enum]: - """Create an argparse *type* converter for an Enum class. +# -- 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} - Accepts both member *values* and member *names*. + 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: + return {} # pragma: no cover + 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} + + return {} # pragma: no cover + + +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)``. """ - _value_map = {str(m.value): m for m in enum_class} + 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, + } - def _convert(value: str) -> enum.Enum: - member = _value_map.get(value) - if member is not None: - return member - # Fallback to name lookup - 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 + resolver = _TYPE_RESOLVERS.get(get_origin(tp)) or _TYPE_RESOLVERS.get(tp) - _convert.__name__ = enum_class.__name__ - # Preserve the enum class for downstream consumers like tab completion. - _convert._cmd2_enum_class = enum_class # type: ignore[attr-defined] - return _convert + # 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 = {} -def _unwrap_type(annotation: Any) -> tuple[Any, Argument | Option | None]: - """Unwrap ``Annotated[T, metadata]`` and return ``(base_type, metadata)``. + if metadata: + kwargs.update(metadata.to_kwargs()) + if metadata.nargs is not None: + kwargs['nargs'] = metadata.nargs - Returns ``(annotation, None)`` when there is no ``Annotated`` wrapper or - no ``Argument``/``Option`` metadata inside it. - """ - if get_origin(annotation) is Annotated: - args = get_args(annotation) - base_type = args[0] - for meta in args[1:]: - if isinstance(meta, (Argument, Option)): - return base_type, meta - return base_type, None - return annotation, None + 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: Any) -> tuple[Any, bool]: - """Strip ``Optional[T]`` / ``T | None`` and return ``(inner_type, is_optional)``.""" + +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: - args = [a for a in get_args(tp) if a is not _NoneType] - if len(args) == 1: - return args[0], True + 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 + # Single-element union without None shouldn't happen, pass through + return non_none[0], False # pragma: no cover + 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 _unwrap_collection(tp: Any) -> tuple[Any, str | None]: - """Strip collection[T] and return ``(inner_type, collection_kind)``.""" - origin = get_origin(tp) - if origin is list: - args = get_args(tp) - if args: - return args[0], 'list' +def _normalize_annotation(annotation: type) -> _NormalizedAnnotation: + """Normalize an annotation into its inner type, metadata, and optionality.""" + tp = annotation + metadata: ArgMetadata = None + is_optional = False - if origin is set: - args = get_args(tp) - if args: - return args[0], 'set' + 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 origin is Collection: + if get_origin(tp) is Annotated: # type: ignore[comparison-overlap] args = get_args(tp) - if args: - return args[0], 'collection' + tp = args[0] + for meta in args[1:]: + if isinstance(meta, (Argument, Option)): + metadata = meta + break - if origin is tuple: - args = get_args(tp) - if len(args) == 2 and args[1] is Ellipsis: - return args[0], 'tuple' - return tp, None + 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) -def _unwrap_literal(tp: Any) -> tuple[Any, list[Any] | None]: - """Strip ``Literal[...]`` and return ``(base_type, literal_values)``.""" - if get_origin(tp) is Literal: - literal_values = list(get_args(tp)) - if not literal_values: - return Any, [] - first_type = type(literal_values[0]) - if all(type(v) is first_type for v in literal_values): - return first_type, literal_values - return Any, literal_values - return tp, 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'}) # --------------------------------------------------------------------------- @@ -300,7 +568,199 @@ def _unwrap_literal(tp: Any) -> tuple[Any, list[Any] | None]: # --------------------------------------------------------------------------- -def build_parser_from_function(func: Callable[..., Any]) -> argparse.ArgumentParser: +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. +_SKIP_PARAMS = frozenset({'self', '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] = [] + + for name, param in sig.parameters.items(): + 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. @@ -309,145 +769,93 @@ def build_parser_from_function(func: Callable[..., Any]) -> argparse.ArgumentPar overrides the default behaviour. :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() - sig = inspect.signature(func) - try: - hints = get_type_hints(func, include_extras=True) - except (NameError, AttributeError, TypeError): - hints = {} + 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) - for name, param in sig.parameters.items(): - if name == 'self': - continue + return parser - annotation = hints.get(name, param.annotation) - has_default = param.default is not inspect.Parameter.empty - default = param.default if has_default else None - # 1. Unwrap Annotated[T, metadata] - base_type, metadata = _unwrap_type(annotation) +def _derive_subcommand_name(func: Callable[..., Any], subcommand_to: str) -> str: + """Derive the subcommand name from the function name and validate the naming convention. - # 2. Unwrap Optional[T] / T | None - base_type, is_optional = _unwrap_optional(base_type) + ``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) - # 3. Unwrap collection[T] - inner_type, collection_kind = _unwrap_collection(base_type) - is_collection = collection_kind is not None - if is_collection: - base_type = inner_type + if base_command: + _validate_base_command_params(func) - # 4. Unwrap Literal[...] - base_type, literal_choices = _unwrap_literal(base_type) + _accepted = set(inspect.signature(func).parameters.keys()) - {'self'} - # 5. Determine positional vs option - if isinstance(metadata, Argument): - is_positional = True - elif isinstance(metadata, Option): - is_positional = False - elif not has_default and not is_optional: - is_positional = True - else: - is_positional = False - - # 6. Build add_argument kwargs - kwargs: dict[str, Any] = {} - - # Help text - help_text = metadata.help_text if metadata else None - if help_text: - kwargs['help'] = help_text - - # Metavar - metavar = metadata.metavar if metadata else None - if metavar: - kwargs['metavar'] = metavar - - # Nargs from metadata - explicit_nargs = metadata.nargs if metadata else None - if explicit_nargs is not None: - kwargs['nargs'] = explicit_nargs - elif is_collection: - kwargs['nargs'] = '*' if has_default else '+' - if collection_kind in ('set', 'tuple'): - kwargs['action'] = _CollectionStoreAction - kwargs['container_factory'] = set if collection_kind == 'set' else tuple - - # Type-specific handling - is_bool_flag = False - if literal_choices is not None: - kwargs['type'] = _make_literal_type(literal_choices) - kwargs['choices'] = literal_choices - elif base_type is bool and not is_collection and not is_positional: - is_bool_flag = True - action_str = getattr(metadata, 'action', None) if metadata else None - if action_str: - kwargs['action'] = action_str - elif has_default and default is True: - kwargs['action'] = 'store_false' - else: - kwargs['action'] = 'store_true' - elif base_type is bool: - kwargs['type'] = _parse_bool - elif isinstance(base_type, type) and issubclass(base_type, enum.Enum): - # Keep validation in the converter to support any Enum subclass, - # including enums whose members are not directly comparable to raw - # argparse input strings. - kwargs['type'] = _make_enum_type(base_type) - elif base_type is pathlib.Path or (isinstance(base_type, type) and issubclass(base_type, pathlib.Path)): - kwargs['type'] = pathlib.Path - elif base_type is decimal.Decimal: - kwargs['type'] = decimal.Decimal - elif base_type in (int, float, str): - if base_type is not str: - kwargs['type'] = base_type - - if has_default: - kwargs['default'] = default - - # Static choices from metadata (unless already set by enum inference) - explicit_choices = getattr(metadata, 'choices', None) - if explicit_choices is not None and 'choices' not in kwargs: - kwargs['choices'] = explicit_choices - - # cmd2-specific fields from metadata - choices_provider = getattr(metadata, 'choices_provider', None) - completer_func = getattr(metadata, 'completer', None) - table_columns = getattr(metadata, 'table_columns', None) - suppress_tab_hint = getattr(metadata, 'suppress_tab_hint', False) - - if choices_provider: - kwargs['choices_provider'] = choices_provider - if completer_func: - kwargs['completer'] = completer_func - if table_columns: - kwargs['table_columns'] = table_columns - if suppress_tab_hint: - kwargs['suppress_tab_hint'] = suppress_tab_hint - - # 7. Call add_argument - if is_positional: - parser.add_argument(name, **kwargs) - else: - # Option - option_metadata = metadata if isinstance(metadata, Option) else None - if option_metadata and option_metadata.names: - flag_names = list(option_metadata.names) - else: - flag_names = [f'--{name}'] - if is_bool_flag and has_default and default is True: - flag_names = [f'--no-{name}'] - - if option_metadata and option_metadata.required: - kwargs['required'] = True - - # Set dest explicitly so it matches the parameter name - kwargs['dest'] = name + @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) - parser.add_argument(*flag_names, **kwargs) + 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 parser + return handler, subcmd_name, parser_builder diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index a21cb1f11..463a4e8c9 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -750,6 +750,9 @@ def _get_raw_choices(self, arg_state: _ArgumentState) -> list[CompletionItem] | 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 action_type.__name__ == '_parse_bool': + return [CompletionItem(v) for v in ['true', 'false', 'yes', 'no', 'on', 'off', '1', '0']] + return None def _prepare_callable_params( @@ -813,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 1dff9e088..469cfe38e 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -349,8 +349,13 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: def with_annotated( func: Callable[..., Any] | None = None, *, + ns_provider: Callable[..., argparse.Namespace] | None = None, preserve_quotes: bool = False, with_unknown_args: bool = False, + subcommand_to: str | None = None, + base_command: bool = False, + help: str | None = None, # noqa: A002 + aliases: Sequence[str] | None = None, ) -> Any: """Decorate a ``do_*`` method to build its argparse parser from type annotations. @@ -363,12 +368,22 @@ def do_greet(self, name: str, count: int = 1): ... def do_raw(self, text: str): ... :param func: the command function (when used without parentheses) + :param ns_provider: optional namespace provider, mirroring ``with_argparser`` :param preserve_quotes: if True, preserve quotes in arguments :param with_unknown_args: if True, capture unknown args (passed as extra kwarg ``_unknown``) """ - from .annotated import build_parser_from_function + from .annotated import ( + _filtered_namespace_kwargs, + _validate_base_command_params, + build_parser_from_function, + build_subcommand_handler, + ) + from .argparse_custom import Cmd2AttributeWrapper def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: + if subcommand_to is None and (help is not None or aliases): + raise TypeError("with_annotated(help=..., aliases=...) requires subcommand_to=...") + if with_unknown_args: unknown_param = inspect.signature(fn).parameters.get('_unknown') if unknown_param is None: @@ -376,13 +391,40 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: if unknown_param.kind is inspect.Parameter.POSITIONAL_ONLY: raise TypeError('Parameter _unknown must be keyword-compatible when with_unknown_args=True') + if subcommand_to is not None: + handler, subcmd_name, subcmd_parser_builder = build_subcommand_handler( + fn, + subcommand_to, + base_command=base_command, + ) + setattr(handler, constants.SUBCMD_ATTR_COMMAND, subcommand_to) + setattr(handler, constants.CMD_ATTR_ARGPARSER, subcmd_parser_builder) + setattr(handler, constants.SUBCMD_ATTR_NAME, subcmd_name) + 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) :] + if base_command: + _validate_base_command_params(fn) + + accepted = set(inspect.signature(fn).parameters.keys()) - {'self'} + + def parser_builder() -> argparse.ArgumentParser: + parser = build_parser_from_function(fn) + 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: + 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( + statement, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list( command_name, statement_arg, preserve_quotes ) @@ -390,31 +432,39 @@ def cmd_wrapper(*args: Any, **_kwargs: Any) -> bool | None: 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) + ns, unknown = arg_parser.parse_known_args(parsed_arglist, namespace) else: - ns = arg_parser.parse_args(parsed_arglist) + ns = arg_parser.parse_args(parsed_arglist, namespace) unknown = None except SystemExit as exc: raise Cmd2ArgparseError from exc - # Unpack Namespace into function kwargs - func_kwargs: dict[str, Any] = {} - for key, value in vars(ns).items(): - if key.startswith('cmd2_') or key == constants.NS_ATTR_SUBCMD_HANDLER: - continue - func_kwargs[key] = value + 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 - # Store a parser-builder callable — _CommandParsers._build_parser() - # already handles callables by calling them with no arguments. - setattr(cmd_wrapper, constants.CMD_ATTR_ARGPARSER, lambda: build_parser_from_function(fn)) + setattr(cmd_wrapper, constants.CMD_ATTR_ARGPARSER, parser_builder) setattr(cmd_wrapper, constants.CMD_ATTR_PRESERVE_QUOTES, preserve_quotes) return cmd_wrapper diff --git a/tests/test_annotated.py b/tests/test_annotated.py index d9952e3ed..2bc9b2bfd 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -1,9 +1,13 @@ -"""Tests for the @with_annotated decorator and type inference in ArgparseCompleter.""" +"""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 collections.abc import Collection from pathlib import Path from typing import ( Annotated, @@ -13,405 +17,500 @@ import pytest import cmd2 -from cmd2 import ( - Choices, - Cmd2ArgumentParser, -) +from cmd2 import Cmd2ArgumentParser from cmd2.annotated import ( Argument, Option, + _CollectionCastingAction, + _make_enum_type, + _make_literal_type, + _parse_bool, + _resolve_annotation, build_parser_from_function, ) from .conftest import run_cmd # --------------------------------------------------------------------------- -# Test functions for build_parser_from_function (not on any class) +# Test enums # --------------------------------------------------------------------------- -def _func_positional_str(self, name: str) -> None: ... -def _func_option_with_default(self, count: int = 1) -> None: ... -def _func_bool_false(self, verbose: bool = False) -> None: ... -def _func_bool_true(self, debug: bool = True) -> None: ... - - 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_path(self, path: Path = Path(".")) -> None: ... -def _func_list(self, files: list[str]) -> 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_metavar(self, name: Annotated[str, Argument(metavar="NAME")]) -> None: ... -def _func_explicit_nargs(self, names: Annotated[str, Argument(nargs=2)]) -> None: ... -def _func_explicit_action(self, verbose: Annotated[bool, Option(action="count")] = False) -> None: ... -def _func_unknown_type(self, data: dict | None = None) -> None: ... -def _func_completer(self, path: Annotated[str, Argument(completer=cmd2.Cmd.path_complete)]) -> None: ... -def _func_table_columns(self, item: Annotated[str, Argument(table_columns=("ID", "Name"))]) -> None: ... -def _func_suppress_hint(self, item: Annotated[str, Argument(suppress_tab_hint=True)]) -> None: ... -def _func_required_option(self, name: Annotated[str, Option("--name", required=True)]) -> None: ... -def _func_annotated_no_metadata(self, name: Annotated[str, "some doc"]) -> None: ... -def _func_list_with_default(self, items: list[str] | None = None) -> None: ... -def _func_float_option(self, rate: float = 1.0) -> None: ... -def _func_positional_bool(self, flag: bool) -> None: ... -def _func_enum_with_default(self, color: _Color = _Color.blue) -> None: ... -def _func_positional_path(self, path: Path) -> None: ... -def _func_decimal(self, amount: decimal.Decimal = decimal.Decimal("1.25")) -> None: ... -def _func_collection(self, ids: Collection[int]) -> None: ... -def _func_set_collection(self, tags: set[str]) -> None: ... -def _func_tuple_collection(self, values: tuple[int, ...]) -> None: ... -def _func_literal_option(self, mode: Literal["fast", "slow"] = "fast") -> None: ... -def _func_literal_positional_int(self, level: Literal[1, 2, 3]) -> 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: ... -FOOD_ITEMS = ['Pizza', 'Ham', 'Potato'] +def _provider(cmd: cmd2.Cmd): + return [] -def _func_static_choices(self, food: Annotated[str, Argument(choices=FOOD_ITEMS)]) -> None: ... +def _func_choices_provider_on_enum( + self, + color: Annotated[_Color, Argument(choices_provider=_provider)], +) -> None: ... -def _func_option_choices(self, food: Annotated[str, Option("--food", choices=FOOD_ITEMS)] = "Pizza") -> None: ... +def _func_completer_on_path( + self, + file: Annotated[Path, Argument(completer=cmd2.Cmd.path_complete)], +) -> None: ... -class _IntColor(enum.IntEnum): - red = 1 - green = 2 - blue = 3 +# Multi-parameter functions +def _func_multi(self, a: str, b: int, c: int = 1) -> None: ... -class _PlainColor(enum.Enum): - RED = "red" - GREEN = "green" - BLUE = "blue" +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- -def _func_int_enum(self, color: _IntColor) -> None: ... +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()) # --------------------------------------------------------------------------- -# Parametrized parser construction tests +# Core: build_parser_from_function produces correct action attributes # --------------------------------------------------------------------------- -def _find_action(parser: argparse.ArgumentParser, dest: str) -> argparse.Action: - for action in parser._actions: - if action.dest == dest: - return action - raise ValueError(f"No action with dest={dest!r}") - +class TestBuildParser: + """Verify action attributes produced by build_parser_from_function.""" -class TestBuildParserParams: @pytest.mark.parametrize( - ("func", "param_name", "expected"), + ("func", "expected"), [ - pytest.param( - _func_positional_str, - "name", - {"option_strings": [], "type": None}, - id="positional_str", - ), - pytest.param( - _func_option_with_default, - "count", - {"option_strings": ["--count"], "type": int, "default": 1}, - id="option_with_default", - ), - pytest.param( - _func_bool_false, - "verbose", - {"option_strings": ["--verbose"]}, - id="bool_flag_false", - ), + # --- 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_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, - "debug", - {"option_strings": ["--no-debug"]}, - id="bool_flag_true", - ), - pytest.param( - _func_enum, - "color", - {"option_strings": []}, - id="enum_choices", - ), - pytest.param( - _func_path, - "path", - {"option_strings": ["--path"], "type": Path}, - id="path_type", - ), - pytest.param( - _func_list, - "files", - {"option_strings": [], "nargs": "+"}, - id="list_nargs", - ), - pytest.param( - _func_optional, - "name", - {"option_strings": ["--name"], "default": None}, - id="optional_type", - ), - pytest.param( - _func_float_option, - "rate", - {"option_strings": ["--rate"], "type": float, "default": 1.0}, - id="float_option", - ), - pytest.param( - _func_positional_bool, - "flag", - {"option_strings": []}, - id="positional_bool_parse_rule", - ), - pytest.param( - _func_enum_with_default, - "color", - {"option_strings": ["--color"]}, - id="enum_with_default_becomes_option", + {"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_positional_path, - "path", - {"option_strings": [], "type": Path}, - id="positional_path_no_default", + _func_enum_option, + {"option_strings": ["--color"], "choices": ["red", "green", "blue"], "default": _Color.blue}, + id="enum_option", ), pytest.param( - _func_decimal, - "amount", - {"option_strings": ["--amount"], "type": decimal.Decimal, "default": decimal.Decimal("1.25")}, - id="decimal_option", + _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_collection, - "ids", - {"option_strings": [], "nargs": "+", "type": int}, - id="collection_positional", + _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_set_collection, - "tags", - {"option_strings": [], "nargs": "+"}, - id="set_collection_positional", + _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_tuple_collection, - "values", - {"option_strings": [], "nargs": "+", "type": int}, - id="tuple_collection_positional", + _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_literal_option, - "mode", - {"option_strings": ["--mode"], "choices": ["fast", "slow"], "default": "fast"}, - id="literal_option", - ), - pytest.param( - _func_literal_positional_int, - "level", - {"option_strings": [], "choices": [1, 2, 3]}, - id="literal_positional_int", - ), - pytest.param( - _func_static_choices, - "food", - {"option_strings": [], "choices": FOOD_ITEMS}, - id="static_choices_positional", - ), - pytest.param( - _func_option_choices, - "food", - {"option_strings": ["--food"], "choices": FOOD_ITEMS, "default": "Pizza"}, - id="static_choices_option", + _func_optional_annotated_inside, + {"option_strings": ["--name"], "default": None}, + id="optional_annotated_inside", ), ], ) - def test_build_parser_params(self, func, param_name, expected): - parser = build_parser_from_function(func) - action = _find_action(parser, param_name) + 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) -class TestBuildParserEdgeCases: @pytest.mark.parametrize( - ("func", "param_name", "expected"), + "func", [ - pytest.param( - _func_metavar, - "name", - {"metavar": "NAME"}, - id="metavar", - ), - pytest.param( - _func_explicit_nargs, - "names", - {"nargs": 2}, - id="explicit_nargs", - ), - pytest.param( - _func_unknown_type, - "data", - {"default": None, "option_strings": ["--data"]}, - id="unknown_type_with_default", - ), - pytest.param( - _func_required_option, - "name", - {"required": True, "option_strings": ["--name"]}, - id="required_option", - ), - pytest.param( - _func_annotated_no_metadata, - "name", - {"option_strings": []}, - id="annotated_no_arg_option_metadata", - ), - pytest.param( - _func_list_with_default, - "items", - {"nargs": "*", "option_strings": ["--items"]}, - id="list_with_default_star_nargs", - ), + pytest.param(_func_set, id="set"), + pytest.param(_func_tuple_ellipsis, id="tuple"), ], ) - def test_edge_cases(self, func, param_name, expected): - parser = build_parser_from_function(func) - action = _find_action(parser, param_name) - for key, value in expected.items(): - assert getattr(action, key) == value, f"{key}: expected {value!r}, got {getattr(action, key)!r}" - - def test_completer_wired(self): - parser = build_parser_from_function(_func_completer) - action = _find_action(parser, "path") - cc = action.get_choices_callable() + 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_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_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_table_columns_wired(self): - parser = build_parser_from_function(_func_table_columns) - action = _find_action(parser, "item") - assert action.get_table_columns() == ("ID", "Name") + def test_dest_param_raises(self) -> None: + with pytest.raises(ValueError, match="dest"): + build_parser_from_function(_func_dest_param) - def test_suppress_tab_hint_wired(self): - parser = build_parser_from_function(_func_suppress_hint) - action = _find_action(parser, "item") - assert action.get_suppress_tab_hint() is True + def test_optional_annotated_outside_raises(self) -> None: + with pytest.raises(TypeError, match="Annotated"): + build_parser_from_function(_func_optional_annotated_outside) - def test_enum_by_value(self): - """Test that enum type converter accepts member values.""" - from cmd2.annotated import _make_enum_type + 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")]) - converter = _make_enum_type(_Color) - assert converter("red") == _Color.red - assert converter("green") == _Color.green + 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_enum_by_name_fallback(self): - """Test enum lookup by name when value doesn't match. + 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 - _IntColor has int values (1, 2, 3) so string "red" won't match - any value — falls through to name lookup. - """ - from cmd2.annotated import _make_enum_type - converter = _make_enum_type(_IntColor) - assert converter("red") == _IntColor.red - assert converter("blue") == _IntColor.blue +# --------------------------------------------------------------------------- +# _resolve_annotation: positional vs option classification + bool flag +# --------------------------------------------------------------------------- - def test_enum_invalid_value(self): - """Test enum converter raises on invalid value.""" - from cmd2.annotated import _make_enum_type +_ARG_META = Argument(help_text="Name") +_OPT_META = Option("--color", "-c", help_text="Pick") - converter = _make_enum_type(_Color) - with pytest.raises(argparse.ArgumentTypeError, match="invalid choice"): - converter("purple") - def test_explicit_action_in_metadata(self): - parser = build_parser_from_function(_func_explicit_action) - action = _find_action(parser, "verbose") - # 'count' action from metadata - assert isinstance(action, argparse._CountAction) +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_positional_bool_parse_rule(self): - parser = build_parser_from_function(_func_positional_bool) - assert parser.parse_args(["true"]).flag is True - assert parser.parse_args(["0"]).flag 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" - with pytest.raises(SystemExit): - parser.parse_args(["definitely"]) - def test_literal_int_parses_as_int(self): - parser = build_parser_from_function(_func_literal_positional_int) - assert parser.parse_args(["2"]).level == 2 +# --------------------------------------------------------------------------- +# Error paths +# --------------------------------------------------------------------------- - with pytest.raises(SystemExit): - parser.parse_args(["7"]) - def test_set_collection_cast(self): - parser = build_parser_from_function(_func_set_collection) - parsed = parser.parse_args(["a", "b", "a"]) - assert isinstance(parsed.tags, set) - assert parsed.tags == {"a", "b"} +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_collection_cast(self): - parser = build_parser_from_function(_func_tuple_collection) - parsed = parser.parse_args(["1", "2", "3"]) - assert isinstance(parsed.values, tuple) - assert parsed.values == (1, 2, 3) + def test_tuple_mixed_raises(self) -> None: + with pytest.raises(TypeError, match="mixed element types"): + _resolve_annotation(tuple[int, str, float]) - def test_collection_cast_uses_store_action(self): - from cmd2.annotated import _CollectionStoreAction + @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) - set_parser = build_parser_from_function(_func_set_collection) - set_action = _find_action(set_parser, "tags") - assert isinstance(set_action, _CollectionStoreAction) + @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 - tuple_parser = build_parser_from_function(_func_tuple_collection) - tuple_action = _find_action(tuple_parser, "values") - assert isinstance(tuple_action, _CollectionStoreAction) - def test_plain_enum_parses_by_value_and_name(self): - def _func_plain_enum(self, color: _PlainColor) -> None: ... +# --------------------------------------------------------------------------- +# 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 - parser = build_parser_from_function(_func_plain_enum) - assert parser.parse_args(["red"]).color is _PlainColor.RED - assert parser.parse_args(["green"]).color is _PlainColor.GREEN - assert parser.parse_args(["BLUE"]).color is _PlainColor.BLUE + def test_invalid(self) -> None: + with pytest.raises(argparse.ArgumentTypeError, match="invalid boolean"): + _parse_bool("maybe") -class TestAnnotatedMetadata: +class TestEnumConverter: @pytest.mark.parametrize( - ("func", "param_name", "expected"), + ("enum_cls", "input_val", "expected"), [ - pytest.param( - _func_annotated_arg, - "name", - {"option_strings": [], "help": "Your name"}, - id="annotated_argument_help", - ), - pytest.param( - _func_annotated_option, - "color", - {"option_strings": ["--color", "-c"], "help": "Pick"}, - id="annotated_option_custom_names", - ), + 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_annotated_metadata(self, func, param_name, expected): - parser = build_parser_from_function(func) - action = _find_action(parser, param_name) - for key, value in expected.items(): - assert getattr(action, key) == value, f"{key}: expected {value!r}, got {getattr(action, key)!r}" + 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" # --------------------------------------------------------------------------- -# Integration test app +# 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"), + ], + ) + def test_to_kwargs(self, meta_kwargs, expected) -> None: + assert Argument(**meta_kwargs).to_kwargs() == expected + + 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" + + +# --------------------------------------------------------------------------- +# Extracted runtime coverage # --------------------------------------------------------------------------- @@ -421,23 +520,21 @@ class _Sport(str, enum.Enum): tennis = "tennis" -class AnnotatedApp(cmd2.Cmd): - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) +class _RuntimeAnnotatedApp(cmd2.Cmd): + def __init__(self) -> None: + super().__init__() self._items = ["apple", "banana", "cherry"] - def item_choices(self) -> Choices: - return Choices.from_values(self._items) + def item_choices(self) -> list[str]: + return self._items @cmd2.with_annotated def do_greet(self, name: str, count: int = 1) -> None: - """Greet someone.""" for _ in range(count): self.poutput(f"Hello {name}") @cmd2.with_annotated def do_add(self, a: int, b: int = 0) -> None: - """Add two numbers.""" self.poutput(str(a + b)) @cmd2.with_annotated @@ -447,49 +544,36 @@ def do_paint( color: Annotated[_Color, Option("--color", "-c", help_text="Color")] = _Color.blue, verbose: bool = False, ) -> None: - """Paint an item.""" 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: - """Pick an item with completion.""" + 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: - """Open a file.""" self.poutput(f"Opening: {path}") @cmd2.with_annotated def do_sport(self, sport: _Sport) -> None: - """Pick a sport.""" self.poutput(f"Playing: {sport.value}") @cmd2.with_annotated(preserve_quotes=True) def do_raw(self, text: str) -> None: - """Echo raw text.""" self.poutput(f"raw: {text}") @pytest.fixture -def ann_app() -> AnnotatedApp: - app = AnnotatedApp() +def runtime_app() -> _RuntimeAnnotatedApp: + app = _RuntimeAnnotatedApp() app.stdout = cmd2.utils.StdSim(app.stdout) return app -# --------------------------------------------------------------------------- -# Integration: command execution -# --------------------------------------------------------------------------- - - -class TestCommandExecution: +class TestExtractedRuntimeExecution: @pytest.mark.parametrize( ("command", "expected"), [ @@ -503,55 +587,32 @@ class TestCommandExecution: pytest.param("sport football", ["Playing: football"], id="sport_enum"), ], ) - def test_command_execution(self, ann_app, command, expected): - out, _err = run_cmd(ann_app, command) + 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() -# --------------------------------------------------------------------------- -# Integration: tab completion -# --------------------------------------------------------------------------- + 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 TestTabCompletion: - def test_enum_completion(self, ann_app): - text = "" - line = "paint wall --color " - endidx = len(line) - begidx = endidx - len(text) - completions = ann_app.complete(text, line, begidx, endidx) - values = sorted(completions.to_strings()) - assert values == ["blue", "green", "red"] - - def test_enum_completion_partial(self, ann_app): - text = "r" - line = f"paint wall --color {text}" - endidx = len(line) - begidx = endidx - len(text) - completions = ann_app.complete(text, line, begidx, endidx) - assert list(completions.to_strings()) == ["red"] - - def test_choices_provider_completion(self, ann_app): - text = "" - line = "pick " - endidx = len(line) - begidx = endidx - len(text) - completions = ann_app.complete(text, line, begidx, endidx) - values = sorted(completions.to_strings()) - assert values == ["apple", "banana", "cherry"] - - def test_positional_enum_completion(self, ann_app): - text = "foot" - line = f"sport {text}" - endidx = len(line) - begidx = endidx - len(text) - completions = ann_app.complete(text, line, begidx, endidx) - assert list(completions.to_strings()) == ["football"] +class TestExtractedRuntimeCompletion: + 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"] -# --------------------------------------------------------------------------- -# Type inference tests (benefits @with_argparser users too) -# --------------------------------------------------------------------------- + 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): @@ -559,18 +620,16 @@ class _InferColor(str, enum.Enum): green = "green" -class TypeInferenceApp(cmd2.Cmd): - """App using manual @with_argparser to test type inference.""" - +class _RuntimeTypeInferenceApp(cmd2.Cmd): path_parser = Cmd2ArgumentParser() - path_parser.add_argument('filepath', type=Path) + 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) + enum_parser.add_argument("color", type=_InferColor) @cmd2.with_argparser(enum_parser) def do_pick_color(self, args: argparse.Namespace) -> None: @@ -578,251 +637,313 @@ def do_pick_color(self, args: argparse.Namespace) -> None: @pytest.fixture -def infer_app() -> TypeInferenceApp: - app = TypeInferenceApp() +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): - text = "" - line = "pick_color " - endidx = len(line) - begidx = endidx - len(text) - completions = infer_app.complete(text, line, begidx, endidx) - assert sorted(completions.to_strings()) == ["green", "red"] +class TestExtractedTypeInference: + 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): - """type=Path on a manual parser triggers path_complete via type inference.""" - # Create a file so path completion has something to find + def test_path_type_inference(self, infer_app, tmp_path) -> None: test_file = tmp_path / "testfile.txt" test_file.touch() - text = str(tmp_path) + "/" - line = f"read {text}" - endidx = len(line) - begidx = endidx - len(text) - completions = infer_app.complete(text, line, begidx, endidx) - assert len(completions) > 0 - result_strings = list(completions.to_strings()) - assert any("testfile.txt" in s for s in result_strings) + 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) -# --------------------------------------------------------------------------- -# Help output test -# --------------------------------------------------------------------------- +class _AnnotatedCommandSet(cmd2.CommandSet): + def __init__(self) -> None: + super().__init__() + self._sports = ["football", "baseball"] + def sport_choices(self) -> list[str]: + return self._sports -class TestHelpOutput: - def test_help_shows_arguments(self, ann_app): - out, _ = run_cmd(ann_app, "help greet") - help_text = "\n".join(out) - assert "name" in help_text.lower() + @cmd2.with_annotated + def do_play(self, sport: Annotated[str, Argument(choices_provider=sport_choices)]) -> None: + self._cmd.poutput(f"Playing {sport}") - def test_help_shows_option_help(self, ann_app): - out, _ = run_cmd(ann_app, "help paint") - help_text = "\n".join(out) - assert "Color" in help_text or "color" in help_text +@pytest.fixture +def cmdset_app() -> cmd2.Cmd: + cmdset = _AnnotatedCommandSet() + app = cmd2.Cmd(command_sets=[cmdset]) + app.stdout = cmd2.utils.StdSim(app.stdout) + return app -# --------------------------------------------------------------------------- -# Preserve quotes test -# --------------------------------------------------------------------------- +class TestExtractedCommandSet: + def test_command_set_execution(self, cmdset_app) -> None: + out, _err = run_cmd(cmdset_app, "play football") + assert out == ["Playing football"] -class TestPreserveQuotes: - def test_preserve_quotes(self, ann_app): - out, _ = run_cmd(ann_app, 'raw "hello world"') - assert out == ['raw: "hello world"'] + def test_command_set_completion(self, cmdset_app) -> None: + assert sorted(_complete_cmd(cmdset_app, "play ", "")) == ["baseball", "football"] # --------------------------------------------------------------------------- -# with_unknown_args test +# Integration: with_annotated decorator runs commands through cmd2 # --------------------------------------------------------------------------- -class UnknownArgsApp(cmd2.Cmd): +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: - """Command that accepts unknown args.""" 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") + @pytest.fixture -def unknown_app() -> UnknownArgsApp: - app = UnknownArgsApp() - app.stdout = cmd2.utils.StdSim(app.stdout) - return app +def app() -> _IntegrationApp: + return _IntegrationApp() -class TestUnknownArgs: - def test_with_unknown_args(self, unknown_app): - out, _ = run_cmd(unknown_app, "flex Alice --extra stuff") +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: + from .conftest import run_cmd + + out, _err = run_cmd(app, command) + assert out == expected + + def test_with_unknown_args(self, app) -> None: + from .conftest import run_cmd + + out, _err = run_cmd(app, "flex Alice --extra stuff") assert out[0] == "name=Alice" assert "unknown=" in out[1] - def test_with_unknown_args_requires_unknown_parameter(self): - with pytest.raises(TypeError, match="requires a parameter named _unknown"): - - class _BadUnknownArgsApp(cmd2.Cmd): - @cmd2.with_annotated(with_unknown_args=True) - def do_bad(self, name: str) -> None: - self.poutput(name) + def test_preserve_quotes(self, app) -> None: + from .conftest import run_cmd + out, _err = run_cmd(app, 'raw "hello world"') + assert out == ['raw: "hello world"'] -# --------------------------------------------------------------------------- -# Argparse error test -# --------------------------------------------------------------------------- + def test_error_produces_stderr(self, app) -> None: + from .conftest import run_cmd + _out, err = run_cmd(app, "greet") + assert any('error' in line.lower() or 'usage' in line.lower() for line in err) -class TestArgparseError: - def test_invalid_args_raise_error(self, ann_app): - """Missing required positional arg should not crash.""" - _out, err = run_cmd(ann_app, "add") - # argparse prints usage/error to stderr - err_text = "\n".join(err) - assert "required" in err_text.lower() or "error" in err_text.lower() or "usage" in err_text.lower() + 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"): -# --------------------------------------------------------------------------- -# get_type_hints failure fallback -# --------------------------------------------------------------------------- + @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"): -class TestGetTypeHintsFailure: - def test_bad_annotation_falls_back(self): - """When get_type_hints raises, build_parser_from_function still works using raw annotations.""" - # Create a function with a forward reference that can't be resolved - exec_globals: dict = {} - exec( - "from cmd2.annotated import build_parser_from_function\n" - "def func(self, name: 'NonExistentType' = 'default'): ...\n" - "result = build_parser_from_function(func)\n", - exec_globals, - ) - parser = exec_globals["result"] - # Should still produce a parser (falls back to raw signature) - assert parser is not None + @cmd2.with_annotated(with_unknown_args=True) + def do_broken(self, _unknown: list[str], /) -> None: + pass + def test_ns_provider(self, app) -> None: + from .conftest import run_cmd -# --------------------------------------------------------------------------- -# _parse_positionals error path -# --------------------------------------------------------------------------- + out, _err = run_cmd(app, "ns_test") + assert out == ["ok"] + assert app.ns_calls == 1 + def test_kwargs_passthrough(self, app) -> None: + app.stdout = cmd2.utils.StdSim(app.stdout) + app.do_greet("Alice", keyword_arg="kwarg_value") + assert "kwarg_value" in app.stdout.getvalue() -class TestParsePositionalsError: - def test_raises_on_bad_args(self): - """_parse_positionals raises TypeError when no Cmd/CommandSet is found.""" - from cmd2.decorators import _parse_positionals + def test_missing_parser_raises(self, app) -> None: + from unittest.mock import patch - with pytest.raises(TypeError, match="Expected arguments"): - _parse_positionals(("not_a_cmd", "not_a_statement")) + with ( + patch.object(app._command_parsers, 'get', return_value=None), + pytest.raises(ValueError, match="No argument parser found"), + ): + app.do_greet("Alice") # --------------------------------------------------------------------------- -# NS_ATTR_SUBCMD_HANDLER filtering +# Subcommands: @with_annotated(base_command=True) + @with_annotated(subcommand_to=...) # --------------------------------------------------------------------------- -class SubcmdApp(cmd2.Cmd): - @cmd2.with_annotated - def do_echo(self, msg: str) -> None: - """Echo a message.""" - self.poutput(msg) +class _SubcommandApp(cmd2.Cmd): + # Level 1: base command + @cmd2.with_annotated(base_command=True) + def do_manage(self, verbose: bool = False, *, cmd2_handler) -> 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') + 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() -> SubcmdApp: - app = SubcmdApp() - app.stdout = cmd2.utils.StdSim(app.stdout) - return app +def subcmd_app() -> _SubcommandApp: + return _SubcommandApp() -class TestNamespaceFiltering: - def test_subcmd_handler_filtered(self, subcmd_app): - """Verify __subcmd_handler__ is filtered from kwargs passed to the function. +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 member add Alice", ["member added: Alice"], id="nested_3_levels"), + ], + ) + def test_subcommand_executes(self, subcmd_app, command, expected) -> None: + from .conftest import run_cmd - We can't easily inject __subcmd_handler__ through argparse, but we can verify - the command works correctly which exercises the filtering loop. - """ - out, _ = run_cmd(subcmd_app, "echo hello") - assert out == ["hello"] + out, _err = run_cmd(subcmd_app, command) + assert out == expected - def test_typing_union_optional(self): - """typing.Union[str, None] should be treated the same as str | None.""" - from cmd2.annotated import _unwrap_optional + @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: + from .conftest import run_cmd - # Build a typing.Union type dynamically to exercise the Union code path - # (distinct from the types.UnionType path used by `str | None`) - ns: dict = {} - exec("import typing; t = typing.Union[str, None]", ns) - union_type = ns["t"] - inner, is_opt = _unwrap_optional(union_type) - assert inner is str - assert is_opt is True + _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) - # Also test non-optional passes through - inner2, is_opt2 = _unwrap_optional(str) - assert inner2 is str - assert is_opt2 is False + def test_subcommand_help(self, subcmd_app) -> None: + from .conftest import run_cmd - def test_namespace_filtering_directly(self): - """Directly test that internal namespace keys are filtered.""" - import argparse as ap + 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 - from cmd2 import constants - ns = ap.Namespace(msg="hello", cmd2_statement="x", **{constants.NS_ATTR_SUBCMD_HANDLER: None}) - func_kwargs = {} - for key, value in vars(ns).items(): - if key.startswith('cmd2_') or key == constants.NS_ATTR_SUBCMD_HANDLER: - continue - func_kwargs[key] = value - assert func_kwargs == {"msg": "hello"} +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 -# --------------------------------------------------------------------------- -# CommandSet integration -# --------------------------------------------------------------------------- + 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 -class AnnotatedCommandSet(cmd2.CommandSet): - def __init__(self) -> None: - super().__init__() - self._sports = ["football", "baseball"] + def test_base_command_missing_handler_raises(self) -> None: + with pytest.raises(TypeError, match="cmd2_handler"): - def sport_choices(self) -> Choices: - return Choices.from_values(self._sports) + @cmd2.with_annotated(base_command=True) + def do_bad(self, verbose: bool = False) -> None: + pass - @cmd2.with_annotated - def do_play( - self, - sport: Annotated[str, Argument(choices_provider=sport_choices)], - ) -> None: - """Play a sport.""" - self._cmd.poutput(f"Playing {sport}") + def test_help_without_subcommand_to_raises(self) -> None: + with pytest.raises(TypeError, match="subcommand_to"): + @cmd2.with_annotated(help="not allowed") + def do_bad(self, name: str) -> None: + pass -@pytest.fixture -def cmdset_app() -> cmd2.Cmd: - cmdset = AnnotatedCommandSet() - app = cmd2.Cmd(command_sets=[cmdset]) - app.stdout = cmd2.utils.StdSim(app.stdout) - return app + @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 -class TestCommandSet: - def test_command_set_execution(self, cmdset_app): - out, _err = run_cmd(cmdset_app, "play football") - assert out == ["Playing football"] + @cmd2.with_annotated(subcommand_to='team', help='create', aliases=['c']) + def team_create(self, name: str) -> None: ... - def test_command_set_completion(self, cmdset_app): - text = "" - line = "play " - endidx = len(line) - begidx = endidx - len(text) - completions = cmdset_app.complete(text, line, begidx, endidx) - assert sorted(completions.to_strings()) == ["baseball", "football"] + 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 builder is callable + parser = getattr(team_create, constants.CMD_ATTR_ARGPARSER)() + assert isinstance(parser, argparse.ArgumentParser) From cbf3a3194d2de7406479a89a298d555b671ae065 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Mon, 30 Mar 2026 11:14:36 +0100 Subject: [PATCH 6/7] chore: more clean up --- cmd2/argparse_completer.py | 2 +- docs/features/argument_processing.md | 79 +++++++++++++++++++-- examples/annotated_example.py | 100 +++++++++++++++++++++++++-- tests/test_annotated.py | 82 ++++++++++++++++++++-- tests/test_argparse_completer.py | 32 +++++++++ 5 files changed, 276 insertions(+), 19 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 463a4e8c9..f855ae454 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -750,7 +750,7 @@ def _get_raw_choices(self, arg_state: _ArgumentState) -> list[CompletionItem] | 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 action_type.__name__ == '_parse_bool': + if getattr(action_type, '__name__', None) == '_parse_bool': return [CompletionItem(v) for v in ['true', 'false', 'yes', 'no', 'on', 'off', '1', '0']] return None diff --git a/docs/features/argument_processing.md b/docs/features/argument_processing.md index 80d97215a..6e9deb434 100644 --- a/docs/features/argument_processing.md +++ b/docs/features/argument_processing.md @@ -64,7 +64,9 @@ required. ### Basic usage Parameters without defaults become positional arguments. Parameters with defaults become `--option` -flags. The function receives typed keyword arguments directly instead of an `argparse.Namespace`. +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 @@ -89,14 +91,14 @@ The decorator converts Python type annotations into `add_argument()` calls: | -------------------------------------------------------- | --------------------------------------------------- | | `str` | default (no `type=` needed) | | `int`, `float` | `type=int` or `type=float` | -| `bool` (default `False`) | `--flag` with `action='store_true'` | -| `bool` (default `True`) | `--no-flag` with `action='store_false'` | +| `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 @@ -106,6 +108,15 @@ function as: - `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 @@ -171,15 +182,69 @@ def do_greet(self, name: str, count: int = 1, loud: bool = False): self.poutput(msg.upper() if loud else msg) ``` -The annotated version is more concise and gives you typed parameters. The argparse version gives you -more control (e.g. `ns_provider`, subcommand handlers via `cmd2_handler`). +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` accepts the same keyword arguments as `@with_argparser`: +`@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) @@ -211,7 +276,7 @@ def do_load(self, args): ``` With `@with_annotated`, the same inference happens because `Path` and `Enum` annotations generate -`type=Path` and `type=converter` in the underlying parser. +the equivalent parser configuration automatically. ## Argument Parsing diff --git a/examples/annotated_example.py b/examples/annotated_example.py index 61276cfd3..512bb6bc9 100755 --- a/examples/annotated_example.py +++ b/examples/annotated_example.py @@ -17,9 +17,14 @@ """ import sys +from argparse import Namespace +from decimal import Decimal from enum import Enum from pathlib import Path -from typing import Annotated +from typing import ( + Annotated, + Literal, +) import cmd2 from cmd2 import ( @@ -54,6 +59,7 @@ class AnnotatedExample(Cmd): 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'. @@ -109,10 +115,8 @@ def do_copy(self, src: Path, dst: Path) -> None: self.poutput(f"Copying {src} -> {dst}") # -- Bool flags ---------------------------------------------------------- - # With @with_argparser you'd set action='store_true' or 'store_false'. - # Here bool defaults drive the flag style automatically. - # False default -> --flag (store_true) - # True default -> --no-flag (store_false) + # 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) @@ -124,8 +128,8 @@ def do_build( ) -> None: """Build a target. Bool flags are inferred from defaults. - ``verbose: bool = False`` becomes ``--verbose`` (store_true). - ``color: bool = True`` becomes ``--no-color`` (store_false). + ``verbose: bool = False`` becomes a boolean optional flag. + ``color: bool = True`` becomes a ``--color`` / ``--no-color`` style option. Try: build app --verbose --no-color @@ -151,6 +155,25 @@ def do_sum(self, numbers: list[float]) -> None: """ 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. @@ -212,6 +235,69 @@ def do_score( """ 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) diff --git a/tests/test_annotated.py b/tests/test_annotated.py index 2bc9b2bfd..27f30a412 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -98,6 +98,15 @@ def _func_default_type_mismatch(self, count: int = "1") -> None: ... # type: ig 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_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): @@ -116,10 +125,6 @@ def _func_completer_on_path( ) -> None: ... -# Multi-parameter functions -def _func_multi(self, a: str, b: int, c: int = 1) -> None: ... - - # --------------------------------------------------------------------------- # Helper # --------------------------------------------------------------------------- @@ -294,6 +299,38 @@ def test_multi_param_order_and_presence(self) -> None: dests = {a.dest for a in parser._actions} assert 'c' in dests + 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"),), + ) + # --------------------------------------------------------------------------- # _resolve_annotation: positional vs option classification + bool flag @@ -726,11 +763,30 @@ def do_ns_test(self, cmd2_statement=None) -> None: self.poutput("ok") +class _GroupedParserApp(cmd2.Cmd): + transfer_parser = build_parser_from_function( + _func_grouped, + groups=(("local", "remote"), ("force", "dry_run")), + mutually_exclusive_groups=(("local", "remote"), ("force", "dry_run")), + ) + + @cmd2.with_argparser(transfer_parser) + def do_transfer(self, args: argparse.Namespace) -> None: + target = args.local if args.local is not None else args.remote + mode = "force" if args.force else "dry-run" if args.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.""" @@ -809,6 +865,24 @@ def test_missing_parser_raises(self, app) -> None: 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=...) # --------------------------------------------------------------------------- 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'), [ From b9db781755eb1e2bd7f815e7182684af8d23ab35 Mon Sep 17 00:00:00 2001 From: Kelvin Chung Date: Mon, 30 Mar 2026 12:46:41 +0100 Subject: [PATCH 7/7] chore: more recovery --- cmd2/annotated.py | 32 ++- cmd2/decorators.py | 81 ++++++-- tests/test_annotated.py | 431 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 480 insertions(+), 64 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index a5d1bccf9..c3b772d35 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -336,7 +336,10 @@ def _resolve(_tp: Any, args: tuple[Any, ...], *, has_default: bool = False, **_c 'container_factory': collection_type, } if len(args) != 1: - return {} # pragma: no cover + 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, @@ -375,7 +378,10 @@ def _resolve_tuple(_tp: Any, args: tuple[Any, ...], *, has_default: bool = False _, inner = _resolve_element(first) return {**inner, 'is_collection': True, 'nargs': len(args), 'base_type': first, **cast_kwargs} - return {} # pragma: no cover + 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]: @@ -478,8 +484,10 @@ def _unwrap_optional(tp: type) -> tuple[type, bool]: if len(non_none) == 1: if has_none: return non_none[0], True - # Single-element union without None shouldn't happen, pass through - return non_none[0], False # pragma: no cover + 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 @@ -597,8 +605,9 @@ def _validate_base_command_params( # Parameters that are handled specially by the decorator and should not -# be added to the argparse parser. -_SKIP_PARAMS = frozenset({'self', 'cmd2_handler', 'cmd2_statement'}) +# 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( @@ -617,7 +626,12 @@ def _resolve_parameters( resolved: list[_ResolvedParam] = [] - for name, param in sig.parameters.items(): + # 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 @@ -766,7 +780,7 @@ def build_parser_from_function( Parameters without defaults become positional arguments. Parameters with defaults become ``--option`` flags. ``Annotated[T, Argument(...)]`` or ``Annotated[T, Option(...)]`` - overrides the default behaviour. + overrides the default behavior. :param func: the command function to inspect :param skip_params: parameter names to exclude from the parser @@ -844,7 +858,7 @@ def build_subcommand_handler( if base_command: _validate_base_command_params(func) - _accepted = set(inspect.signature(func).parameters.keys()) - {'self'} + _accepted = set(list(inspect.signature(func).parameters.keys())[1:]) @functools.wraps(func) def handler(self_arg: Any, ns: Any) -> Any: diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 469cfe38e..2882e9173 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -352,27 +352,46 @@ def with_annotated( ns_provider: Callable[..., argparse.Namespace] | None = None, preserve_quotes: bool = False, with_unknown_args: bool = False, - subcommand_to: str | None = None, 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. - Can be used bare or with keyword arguments:: - - @with_annotated - def do_greet(self, name: str, count: int = 1): ... - - @with_annotated(preserve_quotes=True) - def do_raw(self, text: str): ... - :param func: the command function (when used without parentheses) - :param ns_provider: optional namespace provider, mirroring ``with_argparser`` - :param preserve_quotes: if True, preserve quotes in arguments - :param with_unknown_args: if True, capture unknown args (passed as extra kwarg ``_unknown``) + :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, @@ -380,10 +399,24 @@ def do_raw(self, text: str): ... ) from .argparse_custom import Cmd2AttributeWrapper - def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: - if subcommand_to is None and (help is not None or aliases): - raise TypeError("with_annotated(help=..., aliases=...) requires subcommand_to=...") + 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: @@ -396,10 +429,12 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: 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.CMD_ATTR_ARGPARSER, subcmd_parser_builder) 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 @@ -409,13 +444,21 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: 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) + _validate_base_command_params(fn, skip_params=skip_params) - accepted = set(inspect.signature(fn).parameters.keys()) - {'self'} + # 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) + 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 diff --git a/tests/test_annotated.py b/tests/test_annotated.py index 27f30a412..17828e3f0 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -21,11 +21,14 @@ 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, ) @@ -98,6 +101,11 @@ def _func_default_type_mismatch(self, count: int = "1") -> None: ... # type: ig 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, @@ -170,6 +178,13 @@ class TestBuildParser: 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"), @@ -253,6 +268,15 @@ def test_self_skipped(self) -> None: 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 @@ -260,6 +284,16 @@ def do_broken(self, name: 'NonExistentType'): # noqa: F821 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 @@ -275,6 +309,12 @@ 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) @@ -299,6 +339,13 @@ def test_multi_param_order_and_presence(self) -> None: 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, @@ -331,6 +378,146 @@ def test_mutex_group_spanning_different_argument_groups_raises(self) -> None: 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 @@ -418,6 +605,38 @@ def test_unsupported_collection_no_nargs(self, annotation) -> None: assert 'nargs' not in kwargs assert 'action' not in kwargs + @pytest.mark.parametrize( + "annotation", + [ + pytest.param(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 @@ -480,6 +699,10 @@ def test_invalid(self) -> None: 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 @@ -494,11 +717,25 @@ class TestMetadata: 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() @@ -547,7 +784,59 @@ def test_non_list_passthrough(self) -> None: # --------------------------------------------------------------------------- -# Extracted runtime coverage +# _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 # --------------------------------------------------------------------------- @@ -610,7 +899,7 @@ def runtime_app() -> _RuntimeAnnotatedApp: return app -class TestExtractedRuntimeExecution: +class TestRuntimeExecution: @pytest.mark.parametrize( ("command", "expected"), [ @@ -638,7 +927,7 @@ def test_help_shows_option_help(self, runtime_app) -> None: assert "Color" in help_text or "color" in help_text -class TestExtractedRuntimeCompletion: +class TestRuntimeCompletion: def test_enum_completion(self, runtime_app) -> None: assert sorted(_complete_cmd(runtime_app, "paint wall --color ", "")) == ["blue", "green", "red"] @@ -680,7 +969,7 @@ def infer_app() -> _RuntimeTypeInferenceApp: return app -class TestExtractedTypeInference: +class TestTypeInference: def test_enum_type_inference(self, infer_app) -> None: assert sorted(_complete_cmd(infer_app, "pick_color ", "")) == ["green", "red"] @@ -714,7 +1003,7 @@ def cmdset_app() -> cmd2.Cmd: return app -class TestExtractedCommandSet: +class TestCommandSet: def test_command_set_execution(self, cmdset_app) -> None: out, _err = run_cmd(cmdset_app, "play football") assert out == ["Playing football"] @@ -762,18 +1051,26 @@ def do_raw(self, text: str) -> None: 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): - transfer_parser = build_parser_from_function( - _func_grouped, + @cmd2.with_annotated( groups=(("local", "remote"), ("force", "dry_run")), mutually_exclusive_groups=(("local", "remote"), ("force", "dry_run")), ) - - @cmd2.with_argparser(transfer_parser) - def do_transfer(self, args: argparse.Namespace) -> None: - target = args.local if args.local is not None else args.remote - mode = "force" if args.force else "dry-run" if args.dry_run else "normal" + 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") @@ -801,27 +1098,19 @@ class TestWithAnnotatedIntegration: ], ) def test_command_execution(self, app, command, expected) -> None: - from .conftest import run_cmd - out, _err = run_cmd(app, command) assert out == expected def test_with_unknown_args(self, app) -> None: - from .conftest import run_cmd - 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: - from .conftest import run_cmd - out, _err = run_cmd(app, 'raw "hello world"') assert out == ['raw: "hello world"'] def test_error_produces_stderr(self, app) -> None: - from .conftest import run_cmd - _out, err = run_cmd(app, "greet") assert any('error' in line.lower() or 'usage' in line.lower() for line in err) @@ -844,16 +1133,27 @@ def do_broken(self, _unknown: list[str], /) -> None: pass def test_ns_provider(self, app) -> None: - from .conftest import run_cmd - 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.stdout = cmd2.utils.StdSim(app.stdout) app.do_greet("Alice", keyword_arg="kwarg_value") - assert "kwarg_value" in app.stdout.getvalue() + + 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 @@ -891,7 +1191,7 @@ def test_grouped_command_help_lists_flags(self, grouped_app) -> None: class _SubcommandApp(cmd2.Cmd): # Level 1: base command @cmd2.with_annotated(base_command=True) - def do_manage(self, verbose: bool = False, *, cmd2_handler) -> None: + def do_manage(self, cmd2_handler, verbose: bool = False) -> None: """Management command with subcommands.""" if verbose: self.poutput("verbose mode") @@ -904,13 +1204,13 @@ def do_manage(self, verbose: bool = False, *, cmd2_handler) -> None: def manage_add(self, value: str) -> None: self.poutput(f"added: {value}") - @cmd2.with_annotated(subcommand_to='manage', help='list things') + @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: + def manage_member(self, cmd2_handler) -> None: handler = cmd2_handler.get() if handler: handler() @@ -932,12 +1232,11 @@ class TestSubcommands: [ 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: - from .conftest import run_cmd - out, _err = run_cmd(subcmd_app, command) assert out == expected @@ -950,14 +1249,10 @@ def test_subcommand_executes(self, subcmd_app, command, expected) -> None: ], ) def test_subcommand_errors(self, subcmd_app, command) -> None: - from .conftest import run_cmd - _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: - from .conftest import run_cmd - out, _err = run_cmd(subcmd_app, 'help manage') help_text = '\n'.join(out) assert 'add' in help_text @@ -989,13 +1284,69 @@ def test_base_command_missing_handler_raises(self) -> None: def do_bad(self, verbose: bool = False) -> None: pass - def test_help_without_subcommand_to_raises(self) -> None: + @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(help="not allowed") + @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"), [ @@ -1018,6 +1369,14 @@ 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 builder is callable 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) == {}