Skip to content

Commit b9db781

Browse files
chore: more recovery
1 parent cbf3a31 commit b9db781

3 files changed

Lines changed: 480 additions & 64 deletions

File tree

cmd2/annotated.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,10 @@ def _resolve(_tp: Any, args: tuple[Any, ...], *, has_default: bool = False, **_c
336336
'container_factory': collection_type,
337337
}
338338
if len(args) != 1:
339-
return {} # pragma: no cover
339+
raise TypeError(
340+
f"{collection_type.__name__}[...] with {len(args)} type arguments is not supported; "
341+
f"use {collection_type.__name__}[T] with a single element type."
342+
)
340343
element_type, inner = _resolve_element(args[0])
341344
return {
342345
**inner,
@@ -375,7 +378,10 @@ def _resolve_tuple(_tp: Any, args: tuple[Any, ...], *, has_default: bool = False
375378
_, inner = _resolve_element(first)
376379
return {**inner, 'is_collection': True, 'nargs': len(args), 'base_type': first, **cast_kwargs}
377380

378-
return {} # pragma: no cover
381+
raise TypeError(
382+
"tuple with Ellipsis in an unexpected position is not supported; "
383+
"use tuple[T, ...] for variable-length or tuple[T, T] for fixed-arity."
384+
)
379385

380386

381387
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]:
478484
if len(non_none) == 1:
479485
if has_none:
480486
return non_none[0], True
481-
# Single-element union without None shouldn't happen, pass through
482-
return non_none[0], False # pragma: no cover
487+
raise TypeError(
488+
f"Unexpected single-element Union without None: Union[{non_none[0]}]. "
489+
f"Use the type directly instead of wrapping in Union."
490+
)
483491
type_names = ' | '.join(a.__name__ if hasattr(a, '__name__') else str(a) for a in non_none)
484492
raise TypeError(f"Union type {type_names} is ambiguous for auto-resolution.")
485493
return tp, False
@@ -597,8 +605,9 @@ def _validate_base_command_params(
597605

598606

599607
# Parameters that are handled specially by the decorator and should not
600-
# be added to the argparse parser.
601-
_SKIP_PARAMS = frozenset({'self', 'cmd2_handler', 'cmd2_statement'})
608+
# be added to the argparse parser. The first positional parameter (self/cls)
609+
# is always skipped by position; these cover additional decorator-managed names.
610+
_SKIP_PARAMS = frozenset({'cmd2_handler', 'cmd2_statement'})
602611

603612

604613
def _resolve_parameters(
@@ -617,7 +626,12 @@ def _resolve_parameters(
617626

618627
resolved: list[_ResolvedParam] = []
619628

620-
for name, param in sig.parameters.items():
629+
# Skip the first parameter by position (self/cls for methods)
630+
params = list(sig.parameters.items())
631+
if params:
632+
params = params[1:]
633+
634+
for name, param in params:
621635
if name in skip_params:
622636
continue
623637

@@ -766,7 +780,7 @@ def build_parser_from_function(
766780
Parameters without defaults become positional arguments.
767781
Parameters with defaults become ``--option`` flags.
768782
``Annotated[T, Argument(...)]`` or ``Annotated[T, Option(...)]``
769-
overrides the default behaviour.
783+
overrides the default behavior.
770784
771785
:param func: the command function to inspect
772786
:param skip_params: parameter names to exclude from the parser
@@ -844,7 +858,7 @@ def build_subcommand_handler(
844858
if base_command:
845859
_validate_base_command_params(func)
846860

847-
_accepted = set(inspect.signature(func).parameters.keys()) - {'self'}
861+
_accepted = set(list(inspect.signature(func).parameters.keys())[1:])
848862

849863
@functools.wraps(func)
850864
def handler(self_arg: Any, ns: Any) -> Any:

cmd2/decorators.py

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -352,38 +352,71 @@ def with_annotated(
352352
ns_provider: Callable[..., argparse.Namespace] | None = None,
353353
preserve_quotes: bool = False,
354354
with_unknown_args: bool = False,
355-
subcommand_to: str | None = None,
356355
base_command: bool = False,
356+
subcommand_to: str | None = None,
357357
help: str | None = None, # noqa: A002
358358
aliases: Sequence[str] | None = None,
359+
groups: tuple[tuple[str, ...], ...] | None = None,
360+
mutually_exclusive_groups: tuple[tuple[str, ...], ...] | None = None,
359361
) -> Any:
360362
"""Decorate a ``do_*`` method to build its argparse parser from type annotations.
361363
362-
Can be used bare or with keyword arguments::
363-
364-
@with_annotated
365-
def do_greet(self, name: str, count: int = 1): ...
366-
367-
@with_annotated(preserve_quotes=True)
368-
def do_raw(self, text: str): ...
369-
370364
:param func: the command function (when used without parentheses)
371-
:param ns_provider: optional namespace provider, mirroring ``with_argparser``
372-
:param preserve_quotes: if True, preserve quotes in arguments
373-
:param with_unknown_args: if True, capture unknown args (passed as extra kwarg ``_unknown``)
365+
:param ns_provider: optional callable returning a prepopulated argparse.Namespace.
366+
Not supported with ``subcommand_to``.
367+
:param preserve_quotes: if True, preserve quotes in arguments.
368+
Not supported with ``subcommand_to``.
369+
:param with_unknown_args: if True, capture unknown args (passed as extra kwarg ``_unknown``).
370+
Not supported with ``subcommand_to``.
371+
:param base_command: if True, this command has subcommands (adds ``add_subparsers()``).
372+
Requires a ``cmd2_handler`` parameter and no positional arguments.
373+
:param subcommand_to: parent command name (e.g. ``'team'`` or ``'team member'``).
374+
Function must be named ``{parent_underscored}_{subcommand}``.
375+
:param help: help text for the subcommand (only valid with ``subcommand_to``)
376+
:param aliases: alternative names for the subcommand (only valid with ``subcommand_to``)
377+
:param groups: tuples of parameter names to place in argument groups (for help display)
378+
:param mutually_exclusive_groups: tuples of parameter names that are mutually exclusive
379+
380+
Example::
381+
382+
class MyApp(cmd2.Cmd):
383+
@with_annotated
384+
def do_greet(self, name: str, count: int = 1): ...
385+
386+
@with_annotated(base_command=True)
387+
def do_team(self, *, cmd2_handler): ...
388+
389+
@with_annotated(subcommand_to='team', help='create a team')
390+
def team_create(self, name: str): ...
391+
374392
"""
375393
from .annotated import (
394+
_SKIP_PARAMS,
376395
_filtered_namespace_kwargs,
377396
_validate_base_command_params,
378397
build_parser_from_function,
379398
build_subcommand_handler,
380399
)
381400
from .argparse_custom import Cmd2AttributeWrapper
382401

383-
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
384-
if subcommand_to is None and (help is not None or aliases):
385-
raise TypeError("with_annotated(help=..., aliases=...) requires subcommand_to=...")
402+
if (help is not None or aliases is not None) and subcommand_to is None:
403+
raise TypeError("'help' and 'aliases' are only valid with subcommand_to")
404+
if subcommand_to is not None:
405+
unsupported: list[str] = []
406+
if ns_provider is not None:
407+
unsupported.append('ns_provider')
408+
if preserve_quotes:
409+
unsupported.append('preserve_quotes')
410+
if with_unknown_args:
411+
unsupported.append('with_unknown_args')
412+
if unsupported:
413+
names = ', '.join(unsupported)
414+
raise TypeError(
415+
f"{names} {'is' if len(unsupported) == 1 else 'are'} not supported with subcommand_to. "
416+
"Configure these behaviors on the base command instead."
417+
)
386418

419+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
387420
if with_unknown_args:
388421
unknown_param = inspect.signature(fn).parameters.get('_unknown')
389422
if unknown_param is None:
@@ -396,10 +429,12 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
396429
fn,
397430
subcommand_to,
398431
base_command=base_command,
432+
groups=groups,
433+
mutually_exclusive_groups=mutually_exclusive_groups,
399434
)
400435
setattr(handler, constants.SUBCMD_ATTR_COMMAND, subcommand_to)
401-
setattr(handler, constants.CMD_ATTR_ARGPARSER, subcmd_parser_builder)
402436
setattr(handler, constants.SUBCMD_ATTR_NAME, subcmd_name)
437+
setattr(handler, constants.CMD_ATTR_ARGPARSER, subcmd_parser_builder)
403438
add_parser_kwargs: dict[str, Any] = {}
404439
if help is not None:
405440
add_parser_kwargs['help'] = help
@@ -409,13 +444,21 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
409444
return handler
410445

411446
command_name = fn.__name__[len(constants.COMMAND_FUNC_PREFIX) :]
447+
448+
skip_params = _SKIP_PARAMS | ({'_unknown'} if with_unknown_args else frozenset())
412449
if base_command:
413-
_validate_base_command_params(fn)
450+
_validate_base_command_params(fn, skip_params=skip_params)
414451

415-
accepted = set(inspect.signature(fn).parameters.keys()) - {'self'}
452+
# Cache signature introspection at decoration time, not per-invocation
453+
accepted = set(list(inspect.signature(fn).parameters.keys())[1:])
416454

417455
def parser_builder() -> argparse.ArgumentParser:
418-
parser = build_parser_from_function(fn)
456+
parser = build_parser_from_function(
457+
fn,
458+
skip_params=skip_params,
459+
groups=groups,
460+
mutually_exclusive_groups=mutually_exclusive_groups,
461+
)
419462
if base_command:
420463
parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND', required=True)
421464
return parser

0 commit comments

Comments
 (0)