Skip to content

Commit a29300f

Browse files
committed
Support recursive forward references in from_type
Add support for self-referential type aliases like `A = list[Union["A", str]]` in from_type(). Previously such recursive types would raise ResolutionFailed, but now Hypothesis can resolve them by looking up forward references in the caller's namespace. Fixes #4542
1 parent eaecbba commit a29300f

File tree

4 files changed

+76
-27
lines changed

4 files changed

+76
-27
lines changed

hypothesis-python/RELEASE.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
RELEASE_TYPE: patch
2+
3+
This patch adds support for recursive forward references in
4+
:func:`~hypothesis.strategies.from_type`, such as
5+
``A = list[Union["A", str]]`` (:issue:`4542`).
6+
Previously, such recursive type aliases would raise a ``ResolutionFailed``
7+
error. Now, Hypothesis can automatically resolve the forward reference
8+
by looking it up in the caller's namespace.

hypothesis-python/src/hypothesis/strategies/_internal/core.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1369,6 +1369,11 @@ def from_type_guarded(thing):
13691369
if not types.is_a_type(thing):
13701370
if isinstance(thing, str):
13711371
# See https://github.com/HypothesisWorks/hypothesis/issues/3016
1372+
# String forward references like "LinkedList" can be converted to
1373+
# ForwardRef objects if they are valid Python identifiers.
1374+
# See https://github.com/HypothesisWorks/hypothesis/issues/4542
1375+
if thing.isidentifier():
1376+
return deferred(lambda thing=thing: from_type(typing.ForwardRef(thing)))
13721377
raise InvalidArgument(
13731378
f"Got {thing!r} as a type annotation, but the forward-reference "
13741379
"could not be resolved from a string to a type. Consider using "

hypothesis-python/src/hypothesis/strategies/_internal/types.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,25 @@ def type_sorting_key(t):
214214
return (is_container, repr(t))
215215

216216

217+
def _resolve_forward_ref_in_caller(forward_arg: str):
218+
"""Try to resolve a forward reference name by walking up the call stack.
219+
220+
This allows us to resolve recursive forward references like:
221+
A = list[Union["A", str]]
222+
223+
where "A" refers to the type alias being defined.
224+
"""
225+
frame = sys._getframe()
226+
while frame is not None:
227+
# Check locals first, then globals
228+
if forward_arg in frame.f_locals:
229+
return frame.f_locals[forward_arg]
230+
if forward_arg in frame.f_globals:
231+
return frame.f_globals[forward_arg]
232+
frame = frame.f_back
233+
return None
234+
235+
217236
def _compatible_args(args, superclass_args):
218237
"""Check that the args of two generic types are compatible for try_issubclass."""
219238
assert superclass_args is not None
@@ -562,6 +581,12 @@ def from_typing_type(thing):
562581
and thing.__forward_arg__ in vars(builtins)
563582
):
564583
return st.from_type(getattr(builtins, thing.__forward_arg__))
584+
elif (not mapping) and isinstance(thing, typing.ForwardRef):
585+
# Try to resolve non-builtin forward references by walking up the call stack.
586+
# This handles recursive forward references like A = list[Union["A", str]].
587+
resolved = _resolve_forward_ref_in_caller(thing.__forward_arg__)
588+
if resolved is not None and is_a_type(resolved):
589+
return st.from_type(resolved)
565590

566591
def is_maximal(t):
567592
# For each k in the mapping, we use it if it's the most general type
@@ -1140,8 +1165,7 @@ def resolve_strategies(typ):
11401165
return st.shared(
11411166
st.sampled_from(
11421167
# Constraints may be None or () on various Python versions.
1143-
getattr(thing, "__constraints__", None)
1144-
or builtin_scalar_types,
1168+
getattr(thing, "__constraints__", None) or builtin_scalar_types,
11451169
),
11461170
key=type_var_key,
11471171
).flatmap(st.from_type)

hypothesis-python/tests/nocover/test_type_lookup_forward_ref.py

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@
88
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
99
# obtain one at https://mozilla.org/MPL/2.0/.
1010

11-
from typing import Dict as _Dict, ForwardRef, Union
11+
from typing import Dict as _Dict, Union
1212

1313
import pytest
1414

1515
from hypothesis import given, settings, strategies as st
1616
from hypothesis.errors import ResolutionFailed
1717

18-
from tests.common import utils
18+
from tests.common.debug import find_any
1919
from tests.common.utils import skipif_threading
2020

2121
# error only occurs with typing variants
@@ -30,24 +30,36 @@
3030
)
3131

3232

33+
# Self-referential recursive forward references
34+
# See https://github.com/HypothesisWorks/hypothesis/issues/4542
35+
36+
37+
def test_self_referential_forward_ref():
38+
# The example from issue #4542 - a type alias that references itself
39+
A = list[Union["A", str]]
40+
# This should work without needing manual registration
41+
result = find_any(st.from_type(A))
42+
assert isinstance(result, list)
43+
44+
45+
def test_self_referential_forward_ref_nested():
46+
# Test with nested self-reference
47+
Tree = dict[str, Union["Tree", int]]
48+
result = find_any(st.from_type(Tree))
49+
assert isinstance(result, dict)
50+
51+
3352
@skipif_threading # weird errors around b_strategy scope?
3453
@given(st.data())
3554
def test_mutually_recursive_types_with_typevar(data):
36-
# The previously-failing example from the issue
55+
# The previously-failing example from issue #2722
56+
# Now works because forward refs are resolved via caller namespace lookup
3757
A = _Dict[bool, "B"]
3858
B = Union[list[bool], A]
3959

40-
with pytest.raises(ResolutionFailed, match=r"Could not resolve ForwardRef\('B'\)"):
41-
data.draw(st.from_type(A))
42-
43-
with utils.temp_registered(
44-
ForwardRef("B"),
45-
lambda _: st.deferred(lambda: b_strategy),
46-
):
47-
b_strategy = st.from_type(B)
48-
data.draw(b_strategy)
49-
data.draw(st.from_type(A))
50-
data.draw(st.from_type(B))
60+
# Both A and B are in scope, so forward refs should resolve
61+
data.draw(st.from_type(A))
62+
data.draw(st.from_type(B))
5163

5264

5365
@skipif_threading # weird errors around d_strategy scope?
@@ -58,14 +70,14 @@ def test_mutually_recursive_types_with_typevar_alternate(data):
5870
C = Union[list[bool], "D"]
5971
D = dict[bool, C]
6072

61-
with pytest.raises(ResolutionFailed, match=r"Could not resolve ForwardRef\('D'\)"):
62-
data.draw(st.from_type(C))
63-
64-
with utils.temp_registered(
65-
ForwardRef("D"),
66-
lambda _: st.deferred(lambda: d_strategy),
67-
):
68-
d_strategy = st.from_type(D)
69-
data.draw(d_strategy)
70-
data.draw(st.from_type(C))
71-
data.draw(st.from_type(D))
73+
# Both C and D are in scope, so forward refs should resolve
74+
data.draw(st.from_type(C))
75+
data.draw(st.from_type(D))
76+
77+
78+
def test_forward_ref_to_undefined_still_fails():
79+
# Forward references to undefined names should still fail
80+
A = _Dict[bool, "UndefinedType"] # noqa: F821
81+
82+
with pytest.raises(ResolutionFailed, match=r"Could not resolve"):
83+
find_any(st.from_type(A))

0 commit comments

Comments
 (0)