Skip to content

Commit c087b8b

Browse files
chore: more *args edge case handling
1 parent faa2420 commit c087b8b

2 files changed

Lines changed: 86 additions & 8 deletions

File tree

cmd2/annotated.py

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ def do_paint(
6161
- ``list[T]`` / ``set[T]`` / ``tuple[T, ...]`` -- ``nargs='+'`` (or ``'*'`` if has a default or is ``| None``)
6262
- ``tuple[T, T]`` (fixed arity, same type) -- ``nargs=N`` with ``type=T``
6363
- ``*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
64+
``T`` is the type of each value (a scalar), not the collected tuple.
65+
``Annotated[T, Argument(...)]`` metadata (help text, metavar, choices) is honored
6566
- ``T | None`` (no default) -- positional with ``nargs='?'`` (accepts 0-or-1 tokens)
6667
- ``T | None = None`` -- ``--flag`` option with ``default=None``
6768
@@ -87,6 +88,12 @@ def do_paint(
8788
- ``*args: tuple[T, ...]`` (or ``*args: list[T]`` / any collection element) -- on ``*args``
8889
the annotation is the type of each value, so a collection element would mean a
8990
tuple-of-collections. Annotate the element type instead, e.g. ``*args: str``
91+
- ``*args: Annotated[T, Option(...)]`` -- ``*args`` is always positional, so ``Option()``
92+
metadata is rejected; use ``Argument()`` instead
93+
- ``*args: Annotated[T, Argument(nargs=N)]`` -- ``*args`` arity is fixed to ``nargs='*'``
94+
and cannot be overridden
95+
- a keyword-only parameter (after ``*``) annotated with ``Argument()`` metadata -- ``Argument()``
96+
marks a positional, which contradicts keyword-only; use ``Option()`` or omit the metadata
9097
- ``Annotated[T, Argument(nargs=N)]`` where ``N`` produces a list (``'*'``, ``'+'``,
9198
or integer ``>= 1``) and ``T`` is not a collection type. Use ``list[T]`` or
9299
``tuple[T, ...]`` to match the runtime shape.
@@ -550,7 +557,7 @@ def _nargs_yields_list(nargs: Any) -> bool:
550557

551558

552559
def _resolve_type(
553-
tp: type,
560+
tp: Any,
554561
*,
555562
is_positional: bool = False,
556563
is_optional: bool = False,
@@ -837,10 +844,24 @@ def _resolve_parameters(
837844
if param.kind == inspect.Parameter.VAR_POSITIONAL:
838845
# ``*args: T`` is a variadic positional: zero or more values (nargs='*')
839846
# 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)
847+
# each value). Peel any Annotated/Optional so we see the real element
848+
# type and any Argument() metadata (help text, metavar, choices, ...).
849+
element, metadata, _element_optional = _normalize_annotation(hints.get(name, str))
850+
851+
if isinstance(metadata, Option):
852+
raise TypeError(
853+
f"Parameter '*{name}' in {func.__qualname__} uses Option() metadata, but *args is "
854+
f"always a positional argument. Use Argument() metadata instead."
855+
)
856+
if isinstance(metadata, Argument) and metadata.nargs is not None:
857+
raise TypeError(
858+
f"Parameter '*{name}' in {func.__qualname__} sets nargs={metadata.nargs!r} via Argument(), "
859+
f"but *args always accepts zero or more values (nargs='*') and its arity cannot be overridden."
860+
)
861+
862+
# Annotating *args with a collection -- e.g. ``*args: tuple[str, ...]`` -- would
863+
# mean each value is itself a tuple (a tuple-of-collections), which cannot be
864+
# mapped onto a flat command line.
844865
_, element_kwargs = _resolve_type(element, is_positional=True)
845866
if element_kwargs.get("is_collection"):
846867
# Show the parametrized form (e.g. ``tuple[str, ...]``), not the bare origin.
@@ -852,8 +873,15 @@ def _resolve_parameters(
852873
f"'{element_display}'. Annotate the element type instead "
853874
f"(e.g. '*{name}: str'); values are always collected into a tuple."
854875
)
855-
variadic_annotation = types.GenericAlias(tuple, (element, ...))
856-
kwargs, metadata, positional = _resolve_annotation(variadic_annotation, is_variadic=True)
876+
877+
# Each value has type ``element``; values are collected into a tuple (nargs='*').
878+
# ``is_optional=True`` selects nargs='*' (zero or more); any Argument() metadata
879+
# (help text, metavar, choices) is applied to the variadic positional.
880+
variadic_tuple = types.GenericAlias(tuple, (element, ...))
881+
_, kwargs = _resolve_type(variadic_tuple, is_positional=True, is_optional=True, metadata=metadata)
882+
kwargs.pop("is_collection", None)
883+
kwargs.pop("base_type", None)
884+
positional = True
857885
else:
858886
annotation = hints.get(name, param.annotation)
859887
has_default = param.default is not inspect.Parameter.empty
@@ -867,6 +895,13 @@ def _resolve_parameters(
867895
is_kw_only=is_kw_only,
868896
)
869897

898+
if is_kw_only and isinstance(metadata, Argument):
899+
raise TypeError(
900+
f"Parameter '{name}' in {func.__qualname__} is keyword-only but uses Argument() metadata, "
901+
f"which marks it as a positional argument. Keyword-only parameters always become options; "
902+
f"use Option() metadata (or omit the metadata) instead."
903+
)
904+
870905
if positional:
871906
flags: list[str] = []
872907
else:

tests/test_annotated.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,12 @@ def _func_star_args_bare(self, *args) -> None: ... # type: ignore[no-untyped-de
136136
def _func_star_args_tuple(self, *files: tuple[str, ...]) -> None: ...
137137
def _func_star_args_list(self, *xs: list[str]) -> None: ...
138138
def _func_star_args_bare_list(self, *xs: list) -> None: ... # type: ignore[type-arg]
139+
def _func_star_args_meta(self, *files: Annotated[str, Argument(help_text="a file", metavar="FILE")]) -> None: ...
140+
def _func_star_args_meta_choices(self, *modes: Annotated[str, Argument(choices=["a", "b"])]) -> None: ...
141+
def _func_star_args_option_meta(self, *files: Annotated[str, Option("--files")]) -> None: ...
142+
def _func_star_args_nargs_meta(self, *files: Annotated[str, Argument(nargs=2)]) -> None: ...
143+
def _func_kw_only_argument(self, *, name: Annotated[str, Argument()]) -> None: ...
144+
def _func_kw_only_argument_default(self, *, name: Annotated[str, Argument()] = "x") -> None: ...
139145
def _func_var_keyword(self, name: str, **kwargs: str) -> None: ...
140146
def _func_dest_param(self, dest: str) -> None: ...
141147
def _func_kw_only(self, *, name: str) -> None: ...
@@ -526,6 +532,43 @@ def test_star_args_collection_element_raises(self, func) -> None:
526532
with pytest.raises(TypeError, match=r"the type of each value"):
527533
build_parser_from_function(func)
528534

535+
def test_star_args_honors_argument_metadata(self) -> None:
536+
"""``Annotated[T, Argument(...)]`` on ``*args`` applies help/metavar to the variadic positional."""
537+
action = _get_param_action(_func_star_args_meta)
538+
assert action.option_strings == []
539+
assert action.nargs == "*"
540+
assert action.help == "a file"
541+
assert action.metavar == "FILE"
542+
543+
def test_star_args_honors_argument_choices(self) -> None:
544+
"""``Argument(choices=...)`` on ``*args`` restricts every value to the choices."""
545+
parser = build_parser_from_function(_func_star_args_meta_choices)
546+
assert parser.parse_args(["a", "b", "a"]).modes == ("a", "b", "a")
547+
with pytest.raises(SystemExit):
548+
parser.parse_args(["a", "nope"])
549+
550+
def test_star_args_option_metadata_raises(self) -> None:
551+
"""``Option()`` on ``*args`` is rejected; *args is always positional."""
552+
with pytest.raises(TypeError, match=r"\*args is always a positional"):
553+
build_parser_from_function(_func_star_args_option_meta)
554+
555+
def test_star_args_nargs_metadata_raises(self) -> None:
556+
"""An explicit ``nargs`` on ``*args`` is rejected; its arity is fixed to ``'*'``."""
557+
with pytest.raises(TypeError, match=r"arity cannot be overridden"):
558+
build_parser_from_function(_func_star_args_nargs_meta)
559+
560+
@pytest.mark.parametrize(
561+
"func",
562+
[
563+
pytest.param(_func_kw_only_argument, id="no_default"),
564+
pytest.param(_func_kw_only_argument_default, id="with_default"),
565+
],
566+
)
567+
def test_kw_only_with_argument_metadata_raises(self, func) -> None:
568+
"""A keyword-only parameter cannot use ``Argument()`` (which marks a positional)."""
569+
with pytest.raises(TypeError, match=r"keyword-only but uses Argument\(\)"):
570+
build_parser_from_function(func)
571+
529572
def test_optional_annotated_outside_raises(self) -> None:
530573
with pytest.raises(TypeError, match="Annotated"):
531574
build_parser_from_function(_func_optional_annotated_outside)

0 commit comments

Comments
 (0)