1313Basic usage -- parameters without defaults become positional arguments,
1414parameters with defaults become ``--option`` flags. Keyword-only
1515parameters (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
1719dashes in the generated flag (``dry_run`` -> ``--dry-run``); pass
1820explicit 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
6975Unsupported 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+
514544def _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
522552def _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
676713def _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+
813917def _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 )
0 commit comments