Skip to content

Commit 3746e62

Browse files
chore: address comment and update test
1 parent c0bb4bb commit 3746e62

3 files changed

Lines changed: 133 additions & 33 deletions

File tree

cmd2/annotated.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,6 @@ def _convert(value: str) -> Any:
318318
raise argparse.ArgumentTypeError(f"invalid choice: {value!r} (choose from {valid})")
319319

320320
_convert.__name__ = "literal"
321-
_convert._cmd2_strict_choice_converter = True # type: ignore[attr-defined]
322321
return _convert
323322

324323

@@ -341,7 +340,6 @@ def _convert(value: str) -> enum.Enum:
341340

342341
_convert.__name__ = enum_class.__name__
343342
_convert._cmd2_enum_class = enum_class # type: ignore[attr-defined]
344-
_convert._cmd2_strict_choice_converter = True # type: ignore[attr-defined]
345343
return _convert
346344

347345

@@ -596,15 +594,23 @@ def _resolve_type(
596594
if is_kw_only and not has_default:
597595
kwargs["required"] = True
598596

599-
# An optional positional scalar (``T | None`` without a default) takes 0-or-1 tokens.
600-
if is_optional and is_positional and "nargs" not in kwargs and not kwargs.get("is_collection"):
597+
# An optional positional scalar takes 0-or-1 tokens. This covers both ``T | None``
598+
# (no default) and a positional given an explicit default; without ``nargs='?'``
599+
# argparse would still require the latter, contradicting its default value.
600+
if (is_optional or has_default) and is_positional and "nargs" not in kwargs and not kwargs.get("is_collection"):
601601
kwargs["nargs"] = "?"
602602

603+
if is_positional and (is_optional or has_default) and isinstance(kwargs.get("nargs"), int):
604+
raise TypeError(
605+
f"A fixed-arity positional (nargs={kwargs['nargs']}) cannot be optional; argparse always "
606+
f"requires it. Drop the default or '| None', make it an option (give it a default without "
607+
f"Argument()), or use a variable-arity type such as tuple[T, ...]."
608+
)
609+
610+
# A user-supplied completer/choices_provider drives completion, so drop the inferred
611+
# static ``choices`` list.
603612
if kwargs.get("choices_provider") or kwargs.get("completer"):
604613
kwargs.pop("choices", None)
605-
converter = kwargs.get("type")
606-
if getattr(converter, "_cmd2_strict_choice_converter", False):
607-
kwargs.pop("type", None)
608614

609615
return base_type, kwargs
610616

docs/features/annotated.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ Unsupported patterns raise `TypeError`, including:
108108
- `Annotated[T, Argument(nargs=N)]` where `N` is `'*'`, `'+'`, or an integer `>= 1` and `T` is not a
109109
collection type. `nargs` values that produce a list of values need a collection annotation such as
110110
`list[T]` or `tuple[T, ...]`.
111+
- an optional fixed-arity positional, such as `Annotated[tuple[int, int], Argument()] = (1, 2)`,
112+
`Annotated[tuple[int, int] | None, Argument()]`, or any positional `Argument(nargs=N)` with a
113+
default or `| None`. argparse cannot make a fixed-arity positional optional (there is no `nargs`
114+
for "absent or exactly `N` tokens"), so use a variable-arity type like `tuple[T, ...]`, drop the
115+
default, or make it an option (give it a default without `Argument()`).
111116
- `Annotated[tuple[T, T], Argument(nargs=N)]` where `N` differs from the number of elements declared
112117
by the tuple. The tuple type already pins `nargs`; user metadata cannot change it.
113118

@@ -155,9 +160,11 @@ When an `Option(action=...)` uses an argparse action that does not accept `type=
155160
inferred `type` converter before calling `add_argument()`. This matches argparse behavior and avoids
156161
parser-construction errors such as combining `action='count'` with `type=int`.
157162

158-
When a user-supplied `choices_provider` or `completer` overrides an inferred `Enum` or `Literal`,
159-
the restrictive type converter is also dropped so the user-supplied values are not rejected at parse
160-
time. The `Path` converter is permissive and is preserved when a custom completer is provided.
163+
When a user-supplied `choices_provider` or `completer` is given for an inferred `Enum` or `Literal`,
164+
the inferred static `choices` list is dropped so completion is driven by the provider or completer.
165+
The inferred `type` converter is preserved, so parsed values still coerce to the declared type
166+
(`Literal[1, 2]` yields an `int`, an `Enum` yields its member) and values outside the type are
167+
rejected at parse time.
161168

162169
## Decorator options
163170

tests/test_annotated.py

Lines changed: 110 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,10 @@ def _func_literal_option(self, mode: Literal["fast", "slow"] = "fast") -> None:
106106
def _func_literal_int(self, level: Literal[1, 2, 3]) -> None: ...
107107
def _func_optional(self, name: str | None = None) -> None: ...
108108
def _func_optional_positional(self, val: Annotated[int | None, Argument()]) -> None: ...
109+
def _func_positional_with_default(self, arg: Annotated[str, Argument()] = "foo") -> None: ...
109110
def _func_optional_plain(self, val: int | None) -> None: ...
110111
def _func_optional_list(self, vals: list[int] | None) -> None: ...
111112
def _func_optional_tuple_ellipsis(self, vals: tuple[int, ...] | None) -> None: ...
112-
def _func_optional_explicit_nargs(self, vals: Annotated[tuple[int, ...] | None, Argument(nargs=2)]) -> None: ...
113113
def _func_list(self, files: list[str]) -> None: ...
114114
def _func_list_default(self, items: list[str] | None = None) -> None: ...
115115
def _func_set(self, tags: set[str]) -> None: ...
@@ -243,22 +243,26 @@ class TestBuildParser:
243243
pytest.param(
244244
_func_optional_positional, {"option_strings": [], "nargs": "?", "type": int}, id="optional_positional"
245245
),
246+
pytest.param(
247+
_func_positional_with_default,
248+
{"option_strings": [], "nargs": "?", "default": "foo"},
249+
id="positional_with_default",
250+
),
246251
pytest.param(_func_optional_plain, {"option_strings": [], "nargs": "?", "type": int}, id="optional_plain"),
247252
pytest.param(_func_optional_list, {"option_strings": [], "nargs": "*", "type": int}, id="optional_list"),
248253
pytest.param(
249254
_func_optional_tuple_ellipsis,
250255
{"option_strings": [], "nargs": "*", "type": int},
251256
id="optional_tuple_ellipsis",
252257
),
253-
pytest.param(
254-
_func_optional_explicit_nargs,
255-
{"option_strings": [], "nargs": 2, "type": int},
256-
id="optional_explicit_nargs_overrides",
257-
),
258258
# --- Options ---
259259
pytest.param(_func_int_option, {"option_strings": ["--count"], "type": int, "default": 1}, id="int_option"),
260260
pytest.param(_func_float_option, {"option_strings": ["--rate"], "type": float, "default": 1.0}, id="float_option"),
261-
pytest.param(_func_bool_false, {"option_strings": ["--verbose", "--no-verbose"]}, id="bool_optional_action"),
261+
pytest.param(
262+
_func_bool_false,
263+
{"option_strings": ["--verbose", "--no-verbose"], "default": False},
264+
id="bool_optional_action",
265+
),
262266
pytest.param(
263267
_func_bool_true,
264268
{"option_strings": ["--debug", "--no-debug"], "default": True},
@@ -375,6 +379,28 @@ def test_annotated_action_append(self) -> None:
375379
assert isinstance(action, argparse._AppendAction)
376380
assert action.option_strings == ["--tag"]
377381

382+
def test_positional_with_default_is_optional(self) -> None:
383+
"""A positional with a default takes 0-or-1 tokens and falls back to the default when absent."""
384+
parser = build_parser_from_function(_func_positional_with_default)
385+
assert parser.parse_args([]).arg == "foo"
386+
assert parser.parse_args(["bar"]).arg == "bar"
387+
388+
def test_str_default_on_int_option_coerced_at_parse(self) -> None:
389+
"""The decorator stores the default literally ('1', see ``default_not_coerced``); at parse
390+
time argparse applies ``type=int`` to the string default, so an absent ``--count`` yields int 1.
391+
"""
392+
parser = build_parser_from_function(_func_default_type_mismatch)
393+
assert parser.parse_args([]).count == 1
394+
assert parser.parse_args(["--count", "5"]).count == 5
395+
396+
def test_typing_optional_parses_end_to_end(self) -> None:
397+
"""typing.Optional[int] yields None when absent and coerces to int when provided."""
398+
parser = build_parser_from_function(_func_typing_optional)
399+
assert parser.parse_args([]).count is None
400+
parsed = parser.parse_args(["--count", "5"]).count
401+
assert parsed == 5
402+
assert isinstance(parsed, int)
403+
378404
@pytest.mark.parametrize(
379405
"func",
380406
[
@@ -455,25 +481,64 @@ class TestTypeInferenceBuildParser:
455481
def test_choices_provider_overrides_inferred_enum_choices(self) -> None:
456482
action = _get_param_action(_func_choices_provider_on_enum)
457483
assert action.choices is None
458-
assert action.get_choices_provider() is not None # type: ignore[attr-defined]
484+
assert action.get_choices_provider() is _provider # type: ignore[attr-defined]
459485
assert action.get_completer() is None # type: ignore[attr-defined]
460486

461-
def test_choices_provider_strips_strict_enum_converter(self) -> None:
462-
"""User-supplied choices_provider on Enum drops the restrictive enum converter."""
487+
def test_choices_provider_keeps_enum_coercion(self) -> None:
488+
"""A choices_provider on an Enum keeps the converter so values still coerce to the member."""
463489
action = _get_param_action(_func_choices_provider_on_enum)
464-
assert action.type is None
490+
assert action.type is not None
491+
assert action.type("red") is _Color.red
465492

466-
def test_choices_provider_strips_strict_literal_converter(self) -> None:
467-
"""User-supplied choices_provider on Literal drops the restrictive literal converter."""
493+
def test_choices_provider_keeps_literal_coercion(self) -> None:
494+
"""A choices_provider on a Literal keeps the converter (coercion) but drops the static choices."""
468495

469496
def func(
470497
self,
471-
mode: Annotated[Literal["fast", "slow"], Argument(choices_provider=_provider)],
498+
level: Annotated[Literal[1, 2], Argument(choices_provider=_provider)],
472499
) -> None: ...
473500

474501
action = _get_param_action(func)
475-
assert action.type is None
476502
assert action.choices is None
503+
assert action.type is not None
504+
assert action.type("1") == 1
505+
506+
def test_choices_provider_enum_coerces_at_parse(self) -> None:
507+
"""End-to-end: an Enum with a choices_provider still parses to the enum member, not a str."""
508+
parser = build_parser_from_function(_func_choices_provider_on_enum)
509+
assert parser.parse_args(["red"]).color is _Color.red
510+
511+
def test_choices_provider_literal_int_coerces_at_parse(self) -> None:
512+
"""End-to-end: a Literal[int] with a choices_provider parses to int, not a str."""
513+
514+
def func(
515+
self,
516+
level: Annotated[Literal[1, 2], Argument(choices_provider=_provider)],
517+
) -> None: ...
518+
519+
parser = build_parser_from_function(func)
520+
parsed = parser.parse_args(["1"]).level
521+
assert parsed == 1
522+
assert isinstance(parsed, int)
523+
524+
def test_bare_enum_literal_coerce_at_parse(self) -> None:
525+
"""Bare Enum/Literal positionals and options coerce to the declared type at parse time.
526+
527+
Uses identity / isinstance (not ``==``) so a stripped converter returning a raw ``str``
528+
cannot hide behind StrEnum/IntEnum equality.
529+
"""
530+
assert build_parser_from_function(_func_literal).parse_args(["fast"]).mode == "fast"
531+
assert build_parser_from_function(_func_literal_option).parse_args(["--mode", "slow"]).mode == "slow"
532+
533+
level = build_parser_from_function(_func_literal_int).parse_args(["2"]).level
534+
assert level == 2
535+
assert isinstance(level, int)
536+
537+
assert build_parser_from_function(_func_enum).parse_args(["red"]).color is _Color.red
538+
assert build_parser_from_function(_func_enum_option).parse_args(["--color", "red"]).color is _Color.red
539+
# Non-StrEnum cases: identity defeats the StrEnum/IntEnum ``==`` masking property.
540+
assert build_parser_from_function(_func_int_enum).parse_args(["1"]).color is _IntColor.red
541+
assert build_parser_from_function(_func_plain_enum).parse_args(["red"]).color is _PlainColor.RED
477542

478543
def test_completer_keeps_path_converter(self) -> None:
479544
"""User-supplied completer on Path preserves the (non-restrictive) Path converter."""
@@ -875,6 +940,28 @@ def test_nargs_overrides_fixed_arity_raises(self, annotation) -> None:
875940
with pytest.raises(TypeError, match=r"conflicts with the fixed arity"):
876941
_resolve_annotation(annotation)
877942

943+
@pytest.mark.parametrize(
944+
("annotation", "resolve_kwargs"),
945+
[
946+
pytest.param(
947+
Annotated[tuple[int, int], Argument()],
948+
{"has_default": True, "default": (1, 2)},
949+
id="fixed_tuple_with_default",
950+
),
951+
pytest.param(Annotated[tuple[int, int] | None, Argument()], {}, id="fixed_tuple_optional"),
952+
pytest.param(
953+
Annotated[tuple[int, ...], Argument(nargs=2)],
954+
{"has_default": True, "default": (1, 2)},
955+
id="explicit_nargs_with_default",
956+
),
957+
pytest.param(Annotated[tuple[int, ...] | None, Argument(nargs=2)], {}, id="explicit_nargs_optional"),
958+
],
959+
)
960+
def test_optional_fixed_arity_positional_raises(self, annotation, resolve_kwargs) -> None:
961+
"""argparse cannot make a fixed-arity positional optional, so the combination is rejected."""
962+
with pytest.raises(TypeError, match=r"fixed-arity positional"):
963+
_resolve_annotation(annotation, **resolve_kwargs)
964+
878965
@pytest.mark.parametrize(
879966
"annotation",
880967
[
@@ -1202,7 +1289,7 @@ def do_add(self, a: int, b: int = 0) -> None:
12021289
def do_paint(
12031290
self,
12041291
item: str,
1205-
color: Annotated[_Color, Option("--color", "-c", help_text="Color")] = _Color.blue,
1292+
color: Annotated[_Color, Option("--color", "-c", help_text="Color to use")] = _Color.blue,
12061293
verbose: bool = False,
12071294
) -> None:
12081295
msg = f"Painting {item} {color.value}"
@@ -1263,7 +1350,7 @@ def test_help_shows_arguments(self, runtime_app) -> None:
12631350
def test_help_shows_option_help(self, runtime_app) -> None:
12641351
out, _ = run_cmd(runtime_app, "help paint")
12651352
help_text = "\n".join(out)
1266-
assert "Color" in help_text or "color" in help_text
1353+
assert "Color to use" in help_text
12671354

12681355

12691356
class TestRuntimeCompletion:
@@ -1569,16 +1656,16 @@ def test_subcommand_executes(self, subcmd_app, command, expected) -> None:
15691656
assert out == expected
15701657

15711658
@pytest.mark.parametrize(
1572-
"command",
1659+
("command", "expected_error"),
15731660
[
1574-
pytest.param("manage", id="missing_subcmd"),
1575-
pytest.param("manage delete", id="invalid_subcmd"),
1576-
pytest.param("manage member", id="missing_nested_subcmd"),
1661+
pytest.param("manage", "the following arguments are required: SUBCOMMAND", id="missing_subcmd"),
1662+
pytest.param("manage delete", "invalid choice: 'delete'", id="invalid_subcmd"),
1663+
pytest.param("manage member", "the following arguments are required: SUBCOMMAND", id="missing_nested_subcmd"),
15771664
],
15781665
)
1579-
def test_subcommand_errors(self, subcmd_app, command) -> None:
1666+
def test_subcommand_errors(self, subcmd_app, command, expected_error) -> None:
15801667
_out, err = run_cmd(subcmd_app, command)
1581-
assert any("error" in line.lower() or "usage" in line.lower() or "invalid" in line.lower() for line in err)
1668+
assert any(expected_error in line for line in err), f"expected {expected_error!r} in {err}"
15821669

15831670
def test_subcommand_help(self, subcmd_app) -> None:
15841671
out, _err = run_cmd(subcmd_app, "help manage")

0 commit comments

Comments
 (0)