Skip to content

Commit a029288

Browse files
chore: more recovery
1 parent 4d1a0a1 commit a029288

File tree

3 files changed

+480
-64
lines changed

3 files changed

+480
-64
lines changed

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
@@ -355,38 +355,71 @@ def with_annotated(
355355
ns_provider: Callable[..., argparse.Namespace] | None = None,
356356
preserve_quotes: bool = False,
357357
with_unknown_args: bool = False,
358-
subcommand_to: str | None = None,
359358
base_command: bool = False,
359+
subcommand_to: str | None = None,
360360
help: str | None = None, # noqa: A002
361361
aliases: Sequence[str] | None = None,
362+
groups: tuple[tuple[str, ...], ...] | None = None,
363+
mutually_exclusive_groups: tuple[tuple[str, ...], ...] | None = None,
362364
) -> Any:
363365
"""Decorate a ``do_*`` method to build its argparse parser from type annotations.
364366
365-
Can be used bare or with keyword arguments::
366-
367-
@with_annotated
368-
def do_greet(self, name: str, count: int = 1): ...
369-
370-
@with_annotated(preserve_quotes=True)
371-
def do_raw(self, text: str): ...
372-
373367
:param func: the command function (when used without parentheses)
374-
:param ns_provider: optional namespace provider, mirroring ``with_argparser``
375-
:param preserve_quotes: if True, preserve quotes in arguments
376-
:param with_unknown_args: if True, capture unknown args (passed as extra kwarg ``_unknown``)
368+
:param ns_provider: optional callable returning a prepopulated argparse.Namespace.
369+
Not supported with ``subcommand_to``.
370+
:param preserve_quotes: if True, preserve quotes in arguments.
371+
Not supported with ``subcommand_to``.
372+
:param with_unknown_args: if True, capture unknown args (passed as extra kwarg ``_unknown``).
373+
Not supported with ``subcommand_to``.
374+
:param base_command: if True, this command has subcommands (adds ``add_subparsers()``).
375+
Requires a ``cmd2_handler`` parameter and no positional arguments.
376+
:param subcommand_to: parent command name (e.g. ``'team'`` or ``'team member'``).
377+
Function must be named ``{parent_underscored}_{subcommand}``.
378+
:param help: help text for the subcommand (only valid with ``subcommand_to``)
379+
:param aliases: alternative names for the subcommand (only valid with ``subcommand_to``)
380+
:param groups: tuples of parameter names to place in argument groups (for help display)
381+
:param mutually_exclusive_groups: tuples of parameter names that are mutually exclusive
382+
383+
Example::
384+
385+
class MyApp(cmd2.Cmd):
386+
@with_annotated
387+
def do_greet(self, name: str, count: int = 1): ...
388+
389+
@with_annotated(base_command=True)
390+
def do_team(self, *, cmd2_handler): ...
391+
392+
@with_annotated(subcommand_to='team', help='create a team')
393+
def team_create(self, name: str): ...
394+
377395
"""
378396
from .annotated import (
397+
_SKIP_PARAMS,
379398
_filtered_namespace_kwargs,
380399
_validate_base_command_params,
381400
build_parser_from_function,
382401
build_subcommand_handler,
383402
)
384403
from .argparse_custom import Cmd2AttributeWrapper
385404

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

422+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
390423
if with_unknown_args:
391424
unknown_param = inspect.signature(fn).parameters.get('_unknown')
392425
if unknown_param is None:
@@ -399,10 +432,12 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
399432
fn,
400433
subcommand_to,
401434
base_command=base_command,
435+
groups=groups,
436+
mutually_exclusive_groups=mutually_exclusive_groups,
402437
)
403438
setattr(handler, constants.SUBCMD_ATTR_COMMAND, subcommand_to)
404-
setattr(handler, constants.CMD_ATTR_ARGPARSER, subcmd_parser_builder)
405439
setattr(handler, constants.SUBCMD_ATTR_NAME, subcmd_name)
440+
setattr(handler, constants.CMD_ATTR_ARGPARSER, subcmd_parser_builder)
406441
add_parser_kwargs: dict[str, Any] = {}
407442
if help is not None:
408443
add_parser_kwargs['help'] = help
@@ -412,13 +447,21 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
412447
return handler
413448

414449
command_name = fn.__name__[len(constants.COMMAND_FUNC_PREFIX) :]
450+
451+
skip_params = _SKIP_PARAMS | ({'_unknown'} if with_unknown_args else frozenset())
415452
if base_command:
416-
_validate_base_command_params(fn)
453+
_validate_base_command_params(fn, skip_params=skip_params)
417454

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

420458
def parser_builder() -> argparse.ArgumentParser:
421-
parser = build_parser_from_function(fn)
459+
parser = build_parser_from_function(
460+
fn,
461+
skip_params=skip_params,
462+
groups=groups,
463+
mutually_exclusive_groups=mutually_exclusive_groups,
464+
)
422465
if base_command:
423466
parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND', required=True)
424467
return parser

0 commit comments

Comments
 (0)