diff --git a/hypothesis-python/RELEASE.rst b/hypothesis-python/RELEASE.rst new file mode 100644 index 0000000000..a52389af17 --- /dev/null +++ b/hypothesis-python/RELEASE.rst @@ -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. diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/core.py b/hypothesis-python/src/hypothesis/strategies/_internal/core.py index 5f3069fd05..c8a7f285d7 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/core.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/core.py @@ -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 " diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/types.py b/hypothesis-python/src/hypothesis/strategies/_internal/types.py index 9965bffe2c..745b1db336 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/types.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/types.py @@ -24,6 +24,7 @@ import random import re import sys +import types import typing import uuid import warnings @@ -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 @@ -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 diff --git a/hypothesis-python/tests/cover/test_lookup.py b/hypothesis-python/tests/cover/test_lookup.py index 05f999a5ca..16b74cbc59 100644 --- a/hypothesis-python/tests/cover/test_lookup.py +++ b/hypothesis-python/tests/cover/test_lookup.py @@ -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 diff --git a/hypothesis-python/tests/cover/test_lookup_py39.py b/hypothesis-python/tests/cover/test_lookup_py39.py index 683b8e6234..990e0594ce 100644 --- a/hypothesis-python/tests/cover/test_lookup_py39.py +++ b/hypothesis-python/tests/cover/test_lookup_py39.py @@ -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, @@ -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)) diff --git a/hypothesis-python/tests/nocover/test_type_lookup_forward_ref.py b/hypothesis-python/tests/nocover/test_type_lookup_forward_ref.py index dbb4d14944..9d8801836c 100644 --- a/hypothesis-python/tests/nocover/test_type_lookup_forward_ref.py +++ b/hypothesis-python/tests/nocover/test_type_lookup_forward_ref.py @@ -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 @@ -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? @@ -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))