Skip to content

Commit 9402a91

Browse files
fix: resolve forward references against the unwrapped function's module
1 parent f339a0b commit 9402a91

2 files changed

Lines changed: 52 additions & 3 deletions

File tree

cmd2/annotated.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1750,10 +1750,15 @@ def _resolve_parameters(
17501750
ignored = {next(iter(sig.parameters), None), "return", *skip_params}
17511751
ignored.discard(None)
17521752
relevant_annotations = {name: ann for name, ann in getattr(func, "__annotations__", {}).items() if name not in ignored}
1753+
# Forward references resolve against the *original* function's module. When func is a
1754+
# functools.wraps wrapper (e.g. a user decorator stacked under @with_annotated), wraps copies
1755+
# __annotations__ but not __globals__, so resolve against the unwrapped function's globals --
1756+
# the same module get_type_hints would find by walking a bare function's __wrapped__ chain.
1757+
unwrapped = inspect.unwrap(func)
17531758
try:
17541759
hints = get_type_hints(
17551760
types.SimpleNamespace(__annotations__=relevant_annotations),
1756-
globalns=getattr(func, "__globals__", {}),
1761+
globalns=getattr(unwrapped, "__globals__", {}),
17571762
include_extras=True,
17581763
)
17591764
except (NameError, AttributeError, TypeError) as exc:

tests/test_annotated.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import datetime
1010
import decimal
1111
import enum
12+
import functools
1213
import inspect
1314
import types
1415
import uuid
@@ -150,7 +151,11 @@ def _func_positional_only_mixed(self, x: str, /, y: int, *, z: int = 0) -> None:
150151
# while hints on real arguments must still raise. ``noqa: F821`` marks the intentionally-undefined names.
151152
def _func_unresolvable_self(self: "UnimportableCmd", name: str, count: int = 1) -> None: ... # noqa: F821
152153
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_cmd2_subcommand_func(
155+
self,
156+
cmd2_subcommand_func: "UnimportableHandler", # noqa: F821
157+
verbose: bool = False,
158+
) -> None: ...
154159
def _func_unresolvable_return(self, name: str) -> "UnimportableReturn": ... # noqa: F821
155160
def _func_unresolvable_argument(self, name: "NonExistentType") -> None: ... # noqa: F821
156161
def _func_unresolvable_argument_base(self, cmd2_subcommand_func, name: "NonExistentType") -> None: ... # noqa: F821
@@ -169,6 +174,25 @@ def _arg_names_via_base_command(func: Any) -> set[str]:
169174
return {arg.name for arg in _resolve_parameters(func, base_command=True)}
170175

171176

177+
def _wrap_in_foreign_module(func: Any) -> Any:
178+
"""Wrap *func* as a ``functools.wraps`` decorator would, but give the wrapper a ``__globals__``
179+
that lacks this module's names. This mimics a user decorator defined in *another* module and
180+
stacked under ``@with_annotated``: ``functools.wraps`` copies ``__annotations__`` but not
181+
``__globals__``, so a forward reference can only be resolved via ``__wrapped__`` (the original
182+
function's module), not the wrapper's. Rebinding the wrapper's globals is the single-module way
183+
to recreate that cross-module split.
184+
"""
185+
186+
def wrapper(*args: Any, **kwargs: Any) -> Any:
187+
return func(*args, **kwargs)
188+
189+
foreign = types.FunctionType(
190+
wrapper.__code__, {"__builtins__": __builtins__}, func.__name__, wrapper.__defaults__, wrapper.__closure__
191+
)
192+
functools.update_wrapper(foreign, func) # copies __annotations__ etc. and sets __wrapped__ = func
193+
return foreign
194+
195+
172196
def _provider(cmd: cmd2.Cmd):
173197
return []
174198

@@ -655,7 +679,9 @@ def test_self_only_method_produces_empty_parser(self) -> None:
655679
[
656680
pytest.param(_func_unresolvable_self, _arg_names_via_parser, {"name", "count"}, id="self"),
657681
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"),
682+
pytest.param(
683+
_func_unresolvable_cmd2_subcommand_func, _arg_names_via_base_command, {"verbose"}, id="cmd2_subcommand_func"
684+
),
659685
pytest.param(_func_unresolvable_return, _arg_names_via_parser, {"name"}, id="return"),
660686
],
661687
)
@@ -684,6 +710,24 @@ def test_unresolvable_hint_on_real_argument_raises(self, func, resolve_arg_names
684710
with pytest.raises(TypeError, match="Failed to resolve type hints"):
685711
resolve_arg_names(func)
686712

713+
def test_forward_ref_resolves_through_functools_wraps_wrapper(self) -> None:
714+
"""A forward reference must resolve against the *original* function's module even when the
715+
function reaching the parser builder is a ``functools.wraps`` wrapper defined in another
716+
module (e.g. a user decorator stacked under ``@with_annotated``). ``functools.wraps`` copies
717+
``__annotations__`` but not ``__globals__``, so resolution must follow ``__wrapped__`` --
718+
mirroring what ``typing.get_type_hints`` does for a bare function.
719+
"""
720+
721+
def do_path(self, target: "Path", count: int = 1):
722+
pass
723+
724+
# The wrapper carries a foreign global namespace lacking ``Path``; resolution must use the
725+
# unwrapped function's globals (this module) instead, or it raises "Failed to resolve".
726+
wrapped = _wrap_in_foreign_module(do_path)
727+
parser = build_parser_from_function(wrapped)
728+
dests = {action.dest for action in parser._actions if action.dest != "help"}
729+
assert dests == {"target", "count"}
730+
687731
def test_dest_param_raises(self) -> None:
688732
with pytest.raises(ValueError, match="dest"):
689733
build_parser_from_function(_make_func(str, name="dest"))

0 commit comments

Comments
 (0)