1212from typing import (
1313 Annotated ,
1414 Literal ,
15+ Optional ,
1516)
1617
1718import pytest
@@ -79,6 +80,10 @@ class _PlainColor(enum.Enum):
7980]
8081
8182
83+ class _Port (int ):
84+ """Subclass of ``int`` used to verify subclass fallback in type resolution."""
85+
86+
8287# ---------------------------------------------------------------------------
8388# Single-parameter test functions for build_parser_from_function.
8489# Each has exactly one param (besides self) so dest is auto-derived.
@@ -134,6 +139,19 @@ def _func_plain_enum(self, color: _PlainColor) -> None: ...
134139def _func_list_int (self , nums : list [int ]) -> None : ...
135140def _func_set_int (self , nums : set [int ]) -> None : ...
136141def _func_tuple_fixed_triple (self , triple : tuple [int , int , int ]) -> None : ...
142+ def _func_list_bool (self , flags : list [bool ]) -> None : ...
143+ def _func_set_bool (self , flags : set [bool ]) -> None : ...
144+ def _func_list_path (self , files : list [Path ]) -> None : ...
145+ def _func_list_enum (self , colors : list [_Color ]) -> None : ...
146+ def _func_list_literal (self , modes : list [Literal ["fast" , "slow" ]]) -> None : ...
147+ def _func_tuple_paths (self , src_dst : tuple [Path , Path ]) -> None : ...
148+ def _func_tuple_enums (self , pair : tuple [_Color , _Color ]) -> None : ...
149+ def _func_optional_str_nondefault (self , name : str | None = "world" ) -> None : ...
150+ def _func_typing_optional (self , count : Optional [int ] = None ) -> None : ... # noqa: UP045
151+ def _func_int_subclass (self , port : _Port ) -> None : ...
152+ def _func_store_true_action (self , verbose : Annotated [bool , Option ("--verbose" , action = "store_true" )] = False ) -> None : ...
153+ def _func_store_false_action (self , quiet : Annotated [bool , Option ("--quiet" , action = "store_false" )] = True ) -> None : ...
154+ def _func_append_action (self , tag : Annotated [str | None , Option ("--tag" , action = "append" )] = None ) -> None : ...
137155def _func_multi (self , a : str , b : int , c : int = 1 ) -> None : ...
138156def _func_grouped (
139157 self ,
@@ -287,6 +305,40 @@ class TestBuildParser:
287305 {"option_strings" : ["--name" ], "default" : None },
288306 id = "optional_annotated_inside" ,
289307 ),
308+ # --- Collections of complex element types ---
309+ pytest .param (_func_list_bool , {"option_strings" : [], "nargs" : "+" , "type" : _parse_bool }, id = "list_bool" ),
310+ pytest .param (_func_set_bool , {"option_strings" : [], "nargs" : "+" , "type" : _parse_bool }, id = "set_bool" ),
311+ pytest .param (_func_list_path , {"option_strings" : [], "nargs" : "+" , "type" : Path }, id = "list_path" ),
312+ pytest .param (
313+ _func_list_literal ,
314+ {"option_strings" : [], "nargs" : "+" , "choices" : ["fast" , "slow" ]},
315+ id = "list_literal" ,
316+ ),
317+ pytest .param (
318+ _func_list_enum ,
319+ {"option_strings" : [], "nargs" : "+" , "choices" : _COLOR_CHOICE_ITEMS },
320+ id = "list_enum" ,
321+ ),
322+ pytest .param (_func_tuple_paths , {"option_strings" : [], "nargs" : 2 , "type" : Path }, id = "tuple_paths" ),
323+ pytest .param (
324+ _func_tuple_enums ,
325+ {"option_strings" : [], "nargs" : 2 , "choices" : _COLOR_CHOICE_ITEMS },
326+ id = "tuple_enums" ,
327+ ),
328+ # --- Subclass fallback (Port(int) uses int converter) ---
329+ pytest .param (_func_int_subclass , {"option_strings" : [], "type" : int }, id = "int_subclass" ),
330+ # --- Optional with non-None default ---
331+ pytest .param (
332+ _func_optional_str_nondefault ,
333+ {"option_strings" : ["--name" ], "default" : "world" },
334+ id = "optional_str_nondefault" ,
335+ ),
336+ # --- typing.Optional[T] (vs T | None) end-to-end ---
337+ pytest .param (
338+ _func_typing_optional ,
339+ {"option_strings" : ["--count" ], "type" : int , "default" : None },
340+ id = "typing_optional" ,
341+ ),
290342 ],
291343 )
292344 def test_action_attributes (self , func , expected ) -> None :
@@ -303,6 +355,26 @@ def test_annotated_action_count_non_bool(self) -> None:
303355 assert isinstance (action , argparse ._CountAction )
304356 assert action .default == 0
305357
358+ def test_annotated_action_store_true (self ) -> None :
359+ """``action='store_true'`` strips the inferred bool converter."""
360+ action = _get_param_action (_func_store_true_action )
361+ assert isinstance (action , argparse ._StoreTrueAction )
362+ assert action .type is None
363+ assert action .default is False
364+
365+ def test_annotated_action_store_false (self ) -> None :
366+ """``action='store_false'`` strips the inferred bool converter."""
367+ action = _get_param_action (_func_store_false_action )
368+ assert isinstance (action , argparse ._StoreFalseAction )
369+ assert action .type is None
370+ assert action .default is True
371+
372+ def test_annotated_action_append (self ) -> None :
373+ """``action='append'`` collects repeated flag values into a list."""
374+ action = _get_param_action (_func_append_action )
375+ assert isinstance (action , argparse ._AppendAction )
376+ assert action .option_strings == ["--tag" ]
377+
306378 @pytest .mark .parametrize (
307379 "func" ,
308380 [
@@ -420,6 +492,20 @@ def test_inferred_enum_choices_match_type_converter(self) -> None:
420492 for choice in action .choices :
421493 assert isinstance (converter (str (choice )), _Color )
422494
495+ @pytest .mark .parametrize (
496+ "func" ,
497+ [
498+ pytest .param (_func_path , id = "path_positional" ),
499+ pytest .param (_func_path_option , id = "path_option" ),
500+ pytest .param (_func_list_path , id = "list_path" ),
501+ pytest .param (_func_tuple_paths , id = "tuple_paths" ),
502+ ],
503+ )
504+ def test_path_annotation_wires_path_completer (self , func ) -> None :
505+ """A bare ``Path`` annotation (no user metadata) auto-wires ``Cmd.path_complete``."""
506+ action = _get_param_action (func )
507+ assert action .get_completer () is cmd2 .Cmd .path_complete # type: ignore[attr-defined]
508+
423509
424510# ---------------------------------------------------------------------------
425511# Argument groups and mutually exclusive groups
@@ -988,6 +1074,50 @@ def test_non_list_passthrough(self) -> None:
9881074 assert ns .items == "single_value"
9891075
9901076
1077+ class TestCollectionRuntimeCast :
1078+ """End-to-end verify ``parse_args`` returns the declared container type, not a plain list."""
1079+
1080+ def test_set_int_returns_set (self ) -> None :
1081+ parser = build_parser_from_function (_func_set_int )
1082+ ns = parser .parse_args (["1" , "2" , "2" , "3" ])
1083+ assert isinstance (ns .nums , set )
1084+ assert ns .nums == {1 , 2 , 3 }
1085+
1086+ def test_tuple_ellipsis_returns_tuple (self ) -> None :
1087+ parser = build_parser_from_function (_func_tuple_ellipsis )
1088+ ns = parser .parse_args (["1" , "2" , "3" ])
1089+ assert isinstance (ns .values , tuple )
1090+ assert ns .values == (1 , 2 , 3 )
1091+
1092+ def test_tuple_fixed_returns_tuple (self ) -> None :
1093+ parser = build_parser_from_function (_func_tuple_fixed )
1094+ ns = parser .parse_args (["5" , "10" ])
1095+ assert isinstance (ns .pair , tuple )
1096+ assert ns .pair == (5 , 10 )
1097+
1098+ def test_list_bool_returns_list_of_bools (self ) -> None :
1099+ parser = build_parser_from_function (_func_list_bool )
1100+ ns = parser .parse_args (["true" , "no" , "on" ])
1101+ assert ns .flags == [True , False , True ]
1102+
1103+ def test_tuple_paths_returns_tuple_of_paths (self ) -> None :
1104+ parser = build_parser_from_function (_func_tuple_paths )
1105+ ns = parser .parse_args (["/tmp/a" , "/tmp/b" ])
1106+ assert isinstance (ns .src_dst , tuple )
1107+ assert ns .src_dst == (Path ("/tmp/a" ), Path ("/tmp/b" ))
1108+
1109+ def test_append_action_collects_values (self ) -> None :
1110+ parser = build_parser_from_function (_func_append_action )
1111+ ns = parser .parse_args (["--tag" , "a" , "--tag" , "b" ])
1112+ assert ns .tag == ["a" , "b" ]
1113+
1114+ def test_int_subclass_uses_int_converter (self ) -> None :
1115+ """``Port(int)`` falls back to ``int`` converter; argparse returns ``int``, not ``Port``."""
1116+ parser = build_parser_from_function (_func_int_subclass )
1117+ ns = parser .parse_args (["8080" ])
1118+ assert ns .port == 8080
1119+
1120+
9911121# ---------------------------------------------------------------------------
9921122# _filtered_namespace_kwargs edge cases
9931123# ---------------------------------------------------------------------------
0 commit comments