Skip to content

Commit f339a0b

Browse files
fix: more edge case
1 parent e435507 commit f339a0b

2 files changed

Lines changed: 58 additions & 33 deletions

File tree

cmd2/annotated.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1745,8 +1745,9 @@ def _resolve_parameters(
17451745
f"with_annotated(base_command=True) requires a '{constants.NS_ATTR_SUBCOMMAND_FUNC}' "
17461746
f"parameter in {func.__qualname__}"
17471747
)
1748-
# Resolve hints only for the parameters that become arguments
1749-
ignored = {next(iter(sig.parameters), None), *skip_params}
1748+
# Resolve hints only for the parameters that become arguments: the bound first parameter
1749+
# (self/cls), the injected skip_params, and the "return" annotation never become arguments
1750+
ignored = {next(iter(sig.parameters), None), "return", *skip_params}
17501751
ignored.discard(None)
17511752
relevant_annotations = {name: ann for name, ann in getattr(func, "__annotations__", {}).items() if name not in ignored}
17521753
try:

tests/test_annotated.py

Lines changed: 55 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,31 @@ def _func_positional_only_xy(self, x: str, /, y: int) -> None: ...
144144
def _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+
147172
def _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

Comments
 (0)