@@ -144,6 +144,31 @@ def _func_positional_only_xy(self, x: str, /, y: int) -> None: ...
144144def _func_positional_only_mixed (self , x : str , / , y : int , * , z : int = 0 ) -> None : ...
145145
146146
147+ # Forward references with no matching name in this module's globals, so they are unresolvable at
148+ # runtime. They exercise type-hint resolution: hints on parameters that never become arguments
149+ # (``self``/``cls`` and the injected, skipped ``cmd2_statement``/``cmd2_subcommand_func``) must be tolerated,
150+ # while hints on real arguments must still raise. ``noqa: F821`` marks the intentionally-undefined names.
151+ def _func_unresolvable_self (self : "UnimportableCmd" , name : str , count : int = 1 ) -> None : ... # noqa: F821
152+ def _func_unresolvable_cmd2_statement (self , cmd2_statement : "UnimportableStatement" , name : str , count : int = 1 ) -> None : ... # noqa: F821
153+ def _func_unresolvable_cmd2_subcommand_func (self , cmd2_subcommand_func : "UnimportableHandler" , verbose : bool = False ) -> None : ... # noqa: F821
154+ def _func_unresolvable_return (self , name : str ) -> "UnimportableReturn" : ... # noqa: F821
155+ def _func_unresolvable_argument (self , name : "NonExistentType" ) -> None : ... # noqa: F821
156+ def _func_unresolvable_argument_base (self , cmd2_subcommand_func , name : "NonExistentType" ) -> None : ... # noqa: F821
157+
158+
159+ def _arg_names_via_parser (func : Any ) -> set [str ]:
160+ """Resolve argument names through the public ``build_parser_from_function`` entry point."""
161+ parser = build_parser_from_function (func )
162+ return {action .dest for action in parser ._actions if action .dest != "help" }
163+
164+
165+ def _arg_names_via_base_command (func : Any ) -> set [str ]:
166+ """Resolve a base command's argument names (``base_command`` is not exposed on the public builder)."""
167+ from cmd2 .annotated import _resolve_parameters
168+
169+ return {arg .name for arg in _resolve_parameters (func , base_command = True )}
170+
171+
147172def _provider (cmd : cmd2 .Cmd ):
148173 return []
149174
@@ -625,40 +650,39 @@ def test_self_only_method_produces_empty_parser(self) -> None:
625650 assert dests == set ()
626651 assert parser .parse_args ([]) == argparse .Namespace ()
627652
628- def test_get_type_hints_failure_raises (self ) -> None :
629- def do_broken (self , name : "NonExistentType" ): # noqa: F821
630- pass
631-
632- with pytest .raises (TypeError , match = "Failed to resolve type hints" ):
633- build_parser_from_function (do_broken )
634-
635- def test_unresolvable_hint_on_ignored_self_is_tolerated (self ) -> None :
636- """An unresolvable annotation on the bound parameter (self/cls) must not abort
637- parser generation, since it is never turned into an argument. This happens when
638- ``self`` is annotated with a forward reference that is only importable under
639- ``TYPE_CHECKING``.
653+ @pytest .mark .parametrize (
654+ ("func" , "resolve_arg_names" , "expected_args" ),
655+ [
656+ pytest .param (_func_unresolvable_self , _arg_names_via_parser , {"name" , "count" }, id = "self" ),
657+ pytest .param (_func_unresolvable_cmd2_statement , _arg_names_via_parser , {"name" , "count" }, id = "cmd2_statement" ),
658+ pytest .param (_func_unresolvable_cmd2_subcommand_func , _arg_names_via_base_command , {"verbose" }, id = "cmd2_subcommand_func" ),
659+ pytest .param (_func_unresolvable_return , _arg_names_via_parser , {"name" }, id = "return" ),
660+ ],
661+ )
662+ def test_unresolvable_hint_on_ignored_param_is_tolerated (self , func , resolve_arg_names , expected_args ) -> None :
663+ """An unresolvable forward reference on an annotation that never becomes an argument must not
664+ abort parser generation -- the bound ``self``/``cls``, the injected, skipped
665+ ``cmd2_statement``/``cmd2_subcommand_func`` parameters, and the function's ``return`` annotation. This
666+ is the common case of annotating with a type only importable under ``TYPE_CHECKING``. Only
667+ hints for parameters that actually become arguments are resolved; without that filtering each
668+ case raises "Failed to resolve type hints".
640669 """
670+ assert resolve_arg_names (func ) == expected_args
641671
642- def do_cmd (self : "UnimportableCmd" , name : str , count : int = 1 ): # noqa: F821
643- pass
644-
645- # Without the lenient retry this raises "Failed to resolve type hints".
646- parser = build_parser_from_function (do_cmd )
647- dests = {action .dest for action in parser ._actions if action .dest != "help" }
648- assert dests == {"name" , "count" }
649- ns = parser .parse_args (["alice" ])
650- assert ns .name == "alice"
651- assert ns .count == 1
652-
653- def test_validate_base_command_type_hints_failure_raises (self ) -> None :
654- """Base-command validation should raise, not swallow, type hint failures."""
655- from cmd2 .annotated import _resolve_parameters
656-
657- def do_broken (self , cmd2_subcommand_func , name : "NonExistentType" ): # noqa: F821
658- pass
659-
672+ @pytest .mark .parametrize (
673+ ("func" , "resolve_arg_names" ),
674+ [
675+ pytest .param (_func_unresolvable_argument , _arg_names_via_parser , id = "non_base" ),
676+ pytest .param (_func_unresolvable_argument_base , _arg_names_via_base_command , id = "base_command" ),
677+ ],
678+ )
679+ def test_unresolvable_hint_on_real_argument_raises (self , func , resolve_arg_names ) -> None :
680+ """An unresolvable forward reference on a parameter that *does* become an argument must abort
681+ with a clear, actionable error rather than being silently swallowed -- for both plain commands
682+ and base commands.
683+ """
660684 with pytest .raises (TypeError , match = "Failed to resolve type hints" ):
661- _resolve_parameters ( do_broken , base_command = True )
685+ resolve_arg_names ( func )
662686
663687 def test_dest_param_raises (self ) -> None :
664688 with pytest .raises (ValueError , match = "dest" ):
0 commit comments