Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions hypothesis-python/RELEASE.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
RELEASE_TYPE: patch

This patch adds support for recursive forward references in
:func:`~hypothesis.strategies.from_type`, such as
``A = list[Union["A", str]]`` (:issue:`4542`).
Previously, such recursive type aliases would raise a ``ResolutionFailed``
error. Now, Hypothesis can automatically resolve the forward reference
by looking it up in the caller's namespace.
5 changes: 5 additions & 0 deletions hypothesis-python/src/hypothesis/strategies/_internal/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1369,6 +1369,11 @@ def from_type_guarded(thing):
if not types.is_a_type(thing):
if isinstance(thing, str):
# See https://github.com/HypothesisWorks/hypothesis/issues/3016
# String forward references like "LinkedList" can be converted to
# ForwardRef objects if they are valid Python identifiers.
# See https://github.com/HypothesisWorks/hypothesis/issues/4542
if thing.isidentifier():
return deferred(lambda thing=thing: from_type(typing.ForwardRef(thing)))
raise InvalidArgument(
f"Got {thing!r} as a type annotation, but the forward-reference "
"could not be resolved from a string to a type. Consider using "
Expand Down
36 changes: 36 additions & 0 deletions hypothesis-python/src/hypothesis/strategies/_internal/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import random
import re
import sys
import types
import typing
import uuid
import warnings
Expand Down Expand Up @@ -214,6 +215,35 @@ def type_sorting_key(t):
return (is_container, repr(t))


def _resolve_forward_ref_in_caller(forward_arg: str) -> typing.Any:
"""Try to resolve a forward reference name by walking up the call stack.

This allows us to resolve recursive forward references like:
A = list[Union["A", str]]

where "A" refers to the type alias being defined.

To avoid false positives from namespace collisions, we only return a value
if all frames that define this name have the same value (unambiguous).
"""
found_value: typing.Any = None
found = False
frame: types.FrameType | None = sys._getframe()
while frame is not None:
# Check locals first, then globals
for namespace in (frame.f_locals, frame.f_globals):
if forward_arg in namespace:
value = namespace[forward_arg]
if not found:
found_value = value
found = True
elif value is not found_value:
# Ambiguous: different values in different frames
return None
frame = frame.f_back
return found_value if found else None


def _compatible_args(args, superclass_args):
"""Check that the args of two generic types are compatible for try_issubclass."""
assert superclass_args is not None
Expand Down Expand Up @@ -562,6 +592,12 @@ def from_typing_type(thing):
and thing.__forward_arg__ in vars(builtins)
):
return st.from_type(getattr(builtins, thing.__forward_arg__))
elif (not mapping) and isinstance(thing, typing.ForwardRef):
# Try to resolve non-builtin forward references by walking up the call stack.
# This handles recursive forward references like A = list[Union["A", str]].
resolved = _resolve_forward_ref_in_caller(thing.__forward_arg__)
if resolved is not None and is_a_type(resolved):
return st.from_type(resolved)

def is_maximal(t):
# For each k in the mapping, we use it if it's the most general type
Expand Down
41 changes: 38 additions & 3 deletions hypothesis-python/tests/cover/test_lookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -609,13 +609,48 @@ def test_override_args_for_namedtuple(thing):
assert thing.a is None


@pytest.mark.parametrize("thing", [typing.Optional, list, type, _List, _Type])
def test_cannot_resolve_bare_forward_reference(thing):
@pytest.mark.parametrize("thing", [typing.Optional, list, _List])
def test_can_resolve_forward_reference_to_class(thing):
# Forward references to classes in scope should now be resolved
# See https://github.com/HypothesisWorks/hypothesis/issues/4542
t = thing["ConcreteFoo"]
with pytest.raises(InvalidArgument):
check_can_generate_examples(st.from_type(t))


@pytest.mark.parametrize("thing", [type, _Type])
def test_cannot_resolve_type_forward_reference(thing):
# Type[ForwardRef] still fails because it needs special handling
t = thing["ConcreteFoo"]
with pytest.raises(ResolutionFailed):
check_can_generate_examples(st.from_type(t))


def test_forward_ref_resolved_from_local_scope():
# Test that forward refs can be resolved from local variables (f_locals)
# This explicitly tests the f_locals lookup path in _resolve_forward_ref_in_caller
class LocalClass:
pass

LocalType = list[typing.Union["LocalClass", str]]
check_can_generate_examples(st.from_type(LocalType))


def test_ambiguous_forward_ref_is_not_resolved():
# If different frames define the same name with different values,
# we should not resolve it (ambiguous) to avoid false positives.
class Ambiguous:
pass

def helper():
Ambiguous = int # shadows outer class
# "Ambiguous" now refers to different things in different frames
AmbiguousType = list[typing.Union["Ambiguous", str]]
with pytest.raises(ResolutionFailed):
check_can_generate_examples(st.from_type(AmbiguousType))

helper()


class Tree:
def __init__(self, left: typing.Optional["Tree"], right: typing.Optional["Tree"]):
self.left = left
Expand Down
11 changes: 4 additions & 7 deletions hypothesis-python/tests/cover/test_lookup_py39.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,11 @@

import collections.abc
import dataclasses
import sys
import typing

import pytest

from hypothesis import given, strategies as st
from hypothesis.errors import InvalidArgument

from tests.common.debug import (
assert_all_examples,
Expand Down Expand Up @@ -87,12 +85,11 @@ class User:
following: list["User"] # works with typing.List


@pytest.mark.skipif(sys.version_info[:2] >= (3, 11), reason="works in new Pythons")
def test_string_forward_ref_message():
# See https://github.com/HypothesisWorks/hypothesis/issues/3016
def test_string_forward_ref_resolved():
# Forward references to types in scope now work
# See https://github.com/HypothesisWorks/hypothesis/issues/4542
s = st.builds(User)
with pytest.raises(InvalidArgument, match="`from __future__ import annotations`"):
check_can_generate_examples(s)
check_can_generate_examples(s)


@pytest.mark.parametrize("typ", (typing.Union[list[int], int], list[int] | int))
Expand Down
62 changes: 37 additions & 25 deletions hypothesis-python/tests/nocover/test_type_lookup_forward_ref.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
# obtain one at https://mozilla.org/MPL/2.0/.

from typing import Dict as _Dict, ForwardRef, Union
from typing import Dict as _Dict, Union

import pytest

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

from tests.common import utils
from tests.common.debug import find_any
from tests.common.utils import skipif_threading

# error only occurs with typing variants
Expand All @@ -30,24 +30,36 @@
)


# Self-referential recursive forward references
# See https://github.com/HypothesisWorks/hypothesis/issues/4542


def test_self_referential_forward_ref():
# The example from issue #4542 - a type alias that references itself
A = list[Union["A", str]]
# This should work without needing manual registration
result = find_any(st.from_type(A))
assert isinstance(result, list)


def test_self_referential_forward_ref_nested():
# Test with nested self-reference
Tree = dict[str, Union["Tree", int]]
result = find_any(st.from_type(Tree))
assert isinstance(result, dict)


@skipif_threading # weird errors around b_strategy scope?
@given(st.data())
def test_mutually_recursive_types_with_typevar(data):
# The previously-failing example from the issue
# The previously-failing example from issue #2722
# Now works because forward refs are resolved via caller namespace lookup
A = _Dict[bool, "B"]
B = Union[list[bool], A]

with pytest.raises(ResolutionFailed, match=r"Could not resolve ForwardRef\('B'\)"):
data.draw(st.from_type(A))

with utils.temp_registered(
ForwardRef("B"),
lambda _: st.deferred(lambda: b_strategy),
):
b_strategy = st.from_type(B)
data.draw(b_strategy)
data.draw(st.from_type(A))
data.draw(st.from_type(B))
# Both A and B are in scope, so forward refs should resolve
data.draw(st.from_type(A))
data.draw(st.from_type(B))


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

with pytest.raises(ResolutionFailed, match=r"Could not resolve ForwardRef\('D'\)"):
data.draw(st.from_type(C))

with utils.temp_registered(
ForwardRef("D"),
lambda _: st.deferred(lambda: d_strategy),
):
d_strategy = st.from_type(D)
data.draw(d_strategy)
data.draw(st.from_type(C))
data.draw(st.from_type(D))
# Both C and D are in scope, so forward refs should resolve
data.draw(st.from_type(C))
data.draw(st.from_type(D))


def test_forward_ref_to_undefined_still_fails():
# Forward references to undefined names should still fail
A = _Dict[bool, "UndefinedType"] # noqa: F821

with pytest.raises(ResolutionFailed, match=r"Could not resolve"):
find_any(st.from_type(A))
Loading