@@ -106,10 +106,10 @@ def _func_literal_option(self, mode: Literal["fast", "slow"] = "fast") -> None:
106106def _func_literal_int (self , level : Literal [1 , 2 , 3 ]) -> None : ...
107107def _func_optional (self , name : str | None = None ) -> None : ...
108108def _func_optional_positional (self , val : Annotated [int | None , Argument ()]) -> None : ...
109+ def _func_positional_with_default (self , arg : Annotated [str , Argument ()] = "foo" ) -> None : ...
109110def _func_optional_plain (self , val : int | None ) -> None : ...
110111def _func_optional_list (self , vals : list [int ] | None ) -> None : ...
111112def _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 : ...
113113def _func_list (self , files : list [str ]) -> None : ...
114114def _func_list_default (self , items : list [str ] | None = None ) -> None : ...
115115def _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
12691356class 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