Skip to content

Commit faa2420

Browse files
feat: better *args and **kwargs handling, with extend test
1 parent f0ad6d7 commit faa2420

3 files changed

Lines changed: 330 additions & 22 deletions

File tree

cmd2/annotated.py

Lines changed: 130 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,14 @@
1313
Basic usage -- parameters without defaults become positional arguments,
1414
parameters with defaults become ``--option`` flags. Keyword-only
1515
parameters (after ``*``) always become options; without a default they
16-
are required. Underscores in parameter names are auto-converted to
16+
are required. A ``*args`` parameter becomes a variadic positional that
17+
accepts zero or more values (``nargs='*'``), collected into a tuple.
18+
Underscores in parameter names are auto-converted to
1719
dashes in the generated flag (``dry_run`` -> ``--dry-run``); pass
1820
explicit names via ``Option("--my_flag")`` to opt out. The parameter
19-
name ``dest`` is reserved and cannot be used::
21+
name ``dest`` is reserved and cannot be used. Positional-only
22+
parameters (before ``/``) and ``**kwargs`` are not supported and raise
23+
``TypeError``::
2024
2125
class MyApp(cmd2.Cmd):
2226
@cmd2.with_annotated
@@ -56,6 +60,8 @@ def do_paint(
5660
- ``Literal[...]`` -- sets ``type=converter`` and ``choices`` from literal values
5761
- ``list[T]`` / ``set[T]`` / ``tuple[T, ...]`` -- ``nargs='+'`` (or ``'*'`` if has a default or is ``| None``)
5862
- ``tuple[T, T]`` (fixed arity, same type) -- ``nargs=N`` with ``type=T``
63+
- ``*args: T`` -- variadic positional with ``nargs='*'`` (zero or more), collected into a tuple.
64+
``T`` is the type of each value (a scalar), not the collected tuple
5965
- ``T | None`` (no default) -- positional with ``nargs='?'`` (accepts 0-or-1 tokens)
6066
- ``T | None = None`` -- ``--flag`` option with ``default=None``
6167
@@ -68,9 +74,19 @@ def do_paint(
6874
6975
Unsupported patterns (raise ``TypeError``):
7076
77+
- A scalar type with no converter (e.g. ``datetime.datetime``, ``uuid.UUID``,
78+
``bytes``, or any custom class). Without a converter the command-line value
79+
would silently arrive as a plain string, so it is rejected. Supported scalar
80+
types are ``str``, ``int``, ``float``, ``bool``, ``decimal.Decimal``,
81+
``pathlib.Path``, ``enum.Enum`` subclasses, and ``Literal[...]`` (or a subclass
82+
of one). ``str``/``Any``/``object`` and unannotated parameters pass through as
83+
raw strings.
7184
- ``str | int`` -- union of multiple non-None types is ambiguous
7285
- ``tuple[int, str, float]`` -- mixed element types are not currently supported
7386
because argparse can only apply a single ``type=`` converter per argument
87+
- ``*args: tuple[T, ...]`` (or ``*args: list[T]`` / any collection element) -- on ``*args``
88+
the annotation is the type of each value, so a collection element would mean a
89+
tuple-of-collections. Annotate the element type instead, e.g. ``*args: str``
7490
- ``Annotated[T, Argument(nargs=N)]`` where ``N`` produces a list (``'*'``, ``'+'``,
7591
or integer ``>= 1``) and ``T`` is not a collection type. Use ``list[T]`` or
7692
``tuple[T, ...]`` to match the runtime shape.
@@ -511,12 +527,26 @@ def _type_name(tp: Any) -> str:
511527
return tp.__name__ if hasattr(tp, "__name__") else str(tp)
512528

513529

530+
#: Scalar annotations that argparse stores as the raw string (no converter needed).
531+
_PASSTHROUGH_TYPES = frozenset({str, object, Any, inspect.Parameter.empty})
532+
533+
534+
def _is_passthrough_type(tp: Any) -> bool:
535+
"""Return ``True`` for types stored as a raw string without a dedicated converter.
536+
537+
Covers ``str`` / ``Any`` / ``object`` / unannotated parameters, and any parametrized
538+
generic we do not specialize (e.g. ``frozenset[T]``, ``dict[K, V]``, ``Sequence[T]``),
539+
which keep their existing scalar-passthrough behavior.
540+
"""
541+
return tp in _PASSTHROUGH_TYPES or get_origin(tp) is not None
542+
543+
514544
def _nargs_yields_list(nargs: Any) -> bool:
515545
"""Return ``True`` when an argparse ``nargs`` value produces a list at parse time.
516546
517547
``nargs=1`` is included: argparse returns ``[value]``, not the bare value.
518548
"""
519-
return nargs in ("*", "+") or (isinstance(nargs, int) and nargs >= 1)
549+
return nargs in ("*", "+", argparse.REMAINDER) or (isinstance(nargs, int) and nargs >= 1)
520550

521551

522552
def _resolve_type(
@@ -558,9 +588,16 @@ def _resolve_type(
558588
if resolver is not None:
559589
kwargs = resolver(tp, args, **ctx)
560590
base_type = kwargs.pop("base_type", tp)
561-
else:
591+
elif _is_passthrough_type(tp):
562592
base_type = tp
563593
kwargs = {}
594+
else:
595+
raise TypeError(
596+
f"Unsupported parameter type {_type_name(tp)!r} for @with_annotated: there is no converter "
597+
f"for it, so command-line values would silently arrive as plain strings. Supported scalar types "
598+
f"are str, int, float, bool, decimal.Decimal, pathlib.Path, enum.Enum subclasses, and Literal[...]; "
599+
f"use one of these (optionally in list/set/tuple) or a subclass of one."
600+
)
564601

565602
resolver_nargs = kwargs.get("nargs")
566603

@@ -674,26 +711,31 @@ def _normalize_annotation(annotation: type) -> _NormalizedAnnotation:
674711

675712

676713
def _resolve_annotation(
677-
annotation: type,
714+
annotation: Any,
678715
*,
679716
has_default: bool = False,
680717
default: Any = None,
681718
is_kw_only: bool = False,
719+
is_variadic: bool = False,
682720
) -> tuple[dict[str, Any], ArgMetadata, bool]:
683721
"""Decompose a type annotation into ``(type_kwargs, metadata, is_positional)``.
684722
685723
Peels ``Annotated`` then ``Optional``. The only supported way to combine
686724
``Annotated`` with ``Optional`` is ``Annotated[T | None, meta]``.
687725
Writing ``Annotated[T, meta] | None`` is ambiguous and raises ``TypeError``.
726+
727+
``is_variadic`` marks a ``*args`` parameter: it is always positional and
728+
accepts zero or more values (``nargs='*'``).
688729
"""
689730
tp, metadata, is_optional = _normalize_annotation(annotation)
690731

691-
is_positional = isinstance(metadata, Argument) or (metadata is None and not has_default and not is_kw_only)
732+
# ``*args`` is always a positional that accepts zero or more values.
733+
is_positional = is_variadic or isinstance(metadata, Argument) or (metadata is None and not has_default and not is_kw_only)
692734

693735
tp, type_kwargs = _resolve_type(
694736
tp,
695737
is_positional=is_positional,
696-
is_optional=is_optional,
738+
is_optional=is_optional or is_variadic,
697739
has_default=has_default,
698740
default=default,
699741
metadata=metadata,
@@ -779,23 +821,51 @@ def _resolve_parameters(
779821
"which is not supported by @with_annotated because parameters are passed as keyword arguments."
780822
)
781823

824+
if param.kind == inspect.Parameter.VAR_KEYWORD:
825+
raise TypeError(
826+
f"Parameter '**{name}' in {func.__qualname__} is variadic keyword (**kwargs), "
827+
"which is not supported by @with_annotated because there is no native way to map "
828+
"command-line arguments onto arbitrary keyword names."
829+
)
830+
782831
if name in _RESERVED_PARAM_NAMES:
783832
raise ValueError(
784833
f"Parameter name {name!r} in {func.__qualname__} is reserved by argparse "
785834
f"and cannot be used as an annotated parameter name."
786835
)
787836

788-
annotation = hints.get(name, param.annotation)
789-
has_default = param.default is not inspect.Parameter.empty
790-
default = param.default if has_default else None
791-
is_kw_only = param.kind == inspect.Parameter.KEYWORD_ONLY
792-
793-
kwargs, metadata, positional = _resolve_annotation(
794-
annotation,
795-
has_default=has_default,
796-
default=default,
797-
is_kw_only=is_kw_only,
798-
)
837+
if param.kind == inspect.Parameter.VAR_POSITIONAL:
838+
# ``*args: T`` is a variadic positional: zero or more values (nargs='*')
839+
# collected into a tuple. The hint gives the element type T (the type of
840+
# each value), so annotating *args with a collection -- e.g.
841+
# ``*args: tuple[str, ...]`` -- would mean each value is itself a tuple
842+
# (a tuple-of-tuples), which cannot be mapped onto a flat command line.
843+
element = hints.get(name, str)
844+
_, element_kwargs = _resolve_type(element, is_positional=True)
845+
if element_kwargs.get("is_collection"):
846+
# Show the parametrized form (e.g. ``tuple[str, ...]``), not the bare origin.
847+
element_display = str(element) if get_origin(element) is not None else _type_name(element)
848+
raise TypeError(
849+
f"Parameter '*{name}' in {func.__qualname__} is annotated with the collection type "
850+
f"'{element_display}'. For *args the annotation is the type of each value, not the "
851+
f"collected tuple, so '*{name}: {element_display}' would mean a tuple of "
852+
f"'{element_display}'. Annotate the element type instead "
853+
f"(e.g. '*{name}: str'); values are always collected into a tuple."
854+
)
855+
variadic_annotation = types.GenericAlias(tuple, (element, ...))
856+
kwargs, metadata, positional = _resolve_annotation(variadic_annotation, is_variadic=True)
857+
else:
858+
annotation = hints.get(name, param.annotation)
859+
has_default = param.default is not inspect.Parameter.empty
860+
default = param.default if has_default else None
861+
is_kw_only = param.kind == inspect.Parameter.KEYWORD_ONLY
862+
863+
kwargs, metadata, positional = _resolve_annotation(
864+
annotation,
865+
has_default=has_default,
866+
default=default,
867+
is_kw_only=is_kw_only,
868+
)
799869

800870
if positional:
801871
flags: list[str] = []
@@ -810,6 +880,40 @@ def _resolve_parameters(
810880
return resolved
811881

812882

883+
def _var_positional_call_plan(func: Callable[..., Any]) -> tuple[list[str], str | None]:
884+
"""Return ``(leading_positional_names, var_positional_name)`` for unpacking ``*args``.
885+
886+
``leading_positional_names`` are the positional-or-keyword parameters that
887+
precede ``*args`` (they must be passed positionally, in order, so ``*args``
888+
can follow). ``var_positional_name`` is the ``*args`` parameter name, or
889+
``None`` when the function has no ``*args``.
890+
"""
891+
params = list(inspect.signature(func).parameters.values())[1:] # skip self/cls
892+
leading: list[str] = []
893+
for param in params:
894+
if param.kind is inspect.Parameter.VAR_POSITIONAL:
895+
return leading, param.name
896+
if param.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD:
897+
leading.append(param.name)
898+
return leading, None
899+
900+
901+
def _invoke_command_func(
902+
func: Callable[..., Any],
903+
self_arg: Any,
904+
func_kwargs: dict[str, Any],
905+
*,
906+
leading_names: list[str],
907+
var_positional_name: str | None,
908+
) -> Any:
909+
"""Call *func* from parsed kwargs, unpacking ``*args`` positionally when present."""
910+
if var_positional_name is None:
911+
return func(self_arg, **func_kwargs)
912+
positional = [func_kwargs.pop(name) for name in leading_names if name in func_kwargs]
913+
var_values = func_kwargs.pop(var_positional_name, None) or ()
914+
return func(self_arg, *positional, *var_values, **func_kwargs)
915+
916+
813917
def _filtered_namespace_kwargs(
814918
ns: argparse.Namespace,
815919
*,
@@ -1018,12 +1122,15 @@ def build_subcommand_handler(
10181122
_validate_base_command_params(func)
10191123

10201124
_accepted = set(list(inspect.signature(func).parameters.keys())[1:])
1125+
_leading_names, _var_positional_name = _var_positional_call_plan(func)
10211126

10221127
@functools.wraps(func)
10231128
def handler(self_arg: Any, ns: Any) -> Any:
10241129
"""Unpack Namespace into typed kwargs for the subcommand handler."""
10251130
filtered = _filtered_namespace_kwargs(ns, accepted=_accepted)
1026-
return func(self_arg, **filtered)
1131+
return _invoke_command_func(
1132+
func, self_arg, filtered, leading_names=_leading_names, var_positional_name=_var_positional_name
1133+
)
10271134

10281135
def parser_builder() -> Cmd2ArgumentParser:
10291136
parser = build_parser_from_function(
@@ -1179,6 +1286,7 @@ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
11791286

11801287
# Cache signature introspection at decoration time, not per-invocation
11811288
accepted = set(list(inspect.signature(fn).parameters.keys())[1:])
1289+
leading_names, var_positional_name = _var_positional_call_plan(fn)
11821290

11831291
def parser_builder() -> Cmd2ArgumentParser:
11841292
parser = build_parser_from_function(
@@ -1234,7 +1342,9 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
12341342
func_kwargs["_unknown"] = unknown
12351343

12361344
func_kwargs.update(kwargs)
1237-
result: bool | None = fn(owner, **func_kwargs)
1345+
result: bool | None = _invoke_command_func(
1346+
fn, owner, func_kwargs, leading_names=leading_names, var_positional_name=var_positional_name
1347+
)
12381348
return result
12391349

12401350
setattr(cmd_wrapper, constants.CMD_ATTR_PARSER_SOURCE, parser_builder)

examples/annotated_example.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,26 @@ def do_sum(self, numbers: list[float]) -> None:
175175
"""
176176
self.poutput(f"{' + '.join(str(n) for n in numbers)} = {sum(numbers)}")
177177

178+
# -- Variadic positional (*args) -----------------------------------------
179+
# ``*args: T`` becomes a variadic positional (nargs='*') collected into a
180+
# tuple -- zero or more values. A keyword-only option after ``*args`` stays
181+
# an ordinary ``--flag``.
182+
183+
@with_annotated
184+
@cmd2.with_category(ANNOTATED_CATEGORY)
185+
def do_cat(self, *files: str, number: bool = False) -> None:
186+
"""Concatenate file names. ``*args`` accepts zero or more values.
187+
188+
Try:
189+
cat a.txt b.txt c.txt
190+
cat a.txt b.txt --number
191+
cat
192+
"""
193+
if not files:
194+
self.poutput("(no files)")
195+
for index, name in enumerate(files, start=1):
196+
self.poutput(f"{index}: {name}" if number else name)
197+
178198
# -- Literal + Decimal ---------------------------------------------------
179199
# Literal values become validated choices. Decimal values preserve precision.
180200

0 commit comments

Comments
 (0)