Repository: HypothesisWorks/hypothesis
Pull request: Improve handling of forward references in get_type_hints
Closes #3519

Problem statement:
Hypothesis fails with ForwardRefs and recursive pydantic models
I would like to use hypothesis to generate instances of pydantic models, but it looks like it cannot handle recursive models with ForwardRefs.

(Quite) minimal example triggering the problem:

```python
from pydantic import BaseModel
from typing import Optional, Union
from hypothesis import given, strategies as st

class Dummy(BaseModel):
    x: Optional[Union[int, Dummy]]

Dummy.update_forward_refs()


@given(st.builds(Dummy))
def test_dummy(obj):
    print(obj.json(indent=2))
```

When running this with pytest, it fails with:
```
E           hypothesis.errors.ResolutionFailed: Could not resolve ForwardRef('Optional[Union[int, Dummy]]') to a strategy; consider using register_type_strategy

.../site-packages/hypothesis/strategies/_internal/types.py:442: ResolutionFailed
```

**Extra info:**
* Python 3.8
* pydantic 1.10.2
* hypothesis 6.58
* pytest 7.0.1

Hints from issue discussion:
Thanks for the report - I cut this down further to:

```python
import typing
from pydantic import BaseModel
from hypothesis.internal import compat

class Dummy(BaseModel):
    x: "typing.Union[int, Dummy]"

Dummy.update_forward_refs()
print(typing.get_type_hints(Dummy))
print(compat.get_type_hints(Dummy))
```
```
{'__slots__': typing.Tuple[str, ...], 'x': typing.Union[int, __main__.Dummy]}
{'__slots__': typing.Tuple[str, ...], 'x': ForwardRef('typing.Union[int, Dummy]'), 'data': typing.Any, 'return': <class 'NoneType'>}
```

digging even further, I think this is because Hypothesis prioritizes the annotations in a `.__signature__` attribute over the results of `typing.get_type_hints()`.  That still seems correct in general, but arguably we *shouldn't* do so when the new value is a ForwardRef and there's an existing non-forward-ref annotation.
With #3521

```python
from pydantic import BaseModel
from typing import Optional, Union
from hypothesis import given, strategies as st

class Dummy(BaseModel):
    x: Union[int, "Dummy"]

Dummy.update_forward_refs()


@given(st.builds(Dummy))
def test_dummy(obj):
    print(obj)

test_dummy()
```
```
x=0
x=22658
x=Dummy(x=0)
x=Dummy(x=16792)
x=Dummy(x=0)
x=0
x=0
x=2271479883078318998
x=Dummy(x=0)
x=Dummy(x=Dummy(x=0))
...
```

Commit message: fix failing tests

Patch to review:
diff --git a/hypothesis-python/RELEASE.rst b/hypothesis-python/RELEASE.rst
new file mode 100644
index 0000000000..01c2236f3a
--- /dev/null
+++ b/hypothesis-python/RELEASE.rst
@@ -0,0 +1,6 @@
+RELEASE_TYPE: minor
+
+This release improves Hypothesis' ability to resolve forward references in 
+type annotations. It fixes a bug that prevented 
+:func:`~hypothesis.strategies.builds` from being used with `pydantic models that
+possess updated forward references <https://pydantic-docs.helpmanual.io/usage/postponed_annotations/>`__. See :issue:`3519`.
\ No newline at end of file
diff --git a/hypothesis-python/src/hypothesis/internal/compat.py b/hypothesis-python/src/hypothesis/internal/compat.py
index 2d6fa1d7ad..41c0d2f4ad 100644
--- a/hypothesis-python/src/hypothesis/internal/compat.py
+++ b/hypothesis-python/src/hypothesis/internal/compat.py
@@ -13,6 +13,37 @@
 import platform
 import sys
 import typing
+from typing import Any, ForwardRef, Tuple
+
+try:
+    from typing import get_args
+except ImportError:
+    # remove at Python 3.7 end-of-life
+    from collections.abc import Callable as _Callable
+
+    def get_args(
+        tp: Any,
+    ) -> Tuple[Any, ...]:  # pragma: no cover
+        """
+        Examples
+        --------
+        >>> assert get_args(int) == ()
+        >>> assert get_args(Dict[str, int]) == (str, int)
+        >>> assert get_args(Union[int, Union[T, int], str][int]) == (int, str)
+        >>> assert get_args(Union[int, Tuple[T, int]][str]) == (int, Tuple[str, int])
+        >>> assert get_args(Callable[[], T][int]) == ([], int)
+        """
+        if hasattr(tp, "__origin__") and hasattr(tp, "__args__"):
+            args = tp.__args__
+            if (
+                getattr(tp, "__origin__", None) is _Callable
+                and args
+                and args[0] is not Ellipsis
+            ):
+                args = (list(args[:-1]), args[-1])
+            return args
+        return ()
+
 
 try:
     BaseExceptionGroup = BaseExceptionGroup
@@ -71,6 +102,10 @@ def is_typed_named_tuple(cls):
     )
 
 
+def _hint_and_args(x):
+    return (x,) + get_args(x)
+
+
 def get_type_hints(thing):
     """Like the typing version, but tries harder and never errors.
 
@@ -111,10 +146,25 @@ def get_type_hints(thing):
                     and is_a_type(p.annotation)
                     and p.annotation is not p.empty
                 ):
+
+                    p_hint = p.annotation
+
+                    # Defer to `get_type_hints` if signature annotation is, or
+                    # contains, a forward reference that is otherwise resolved.
+                    if any(
+                        isinstance(sig_hint, ForwardRef)
+                        and not isinstance(hint, ForwardRef)
+                        for sig_hint, hint in zip(
+                            _hint_and_args(p.annotation),
+                            _hint_and_args(hints.get(p.name, Any)),
+                        )
+                    ):
+                        p_hint = hints[p.name]
+                    print(p_hint, p.default)
                     if p.default is None:
-                        hints[p.name] = typing.Optional[p.annotation]
+                        hints[p.name] = typing.Optional[p_hint]
                     else:
-                        hints[p.name] = p.annotation
+                        hints[p.name] = p_hint
     except (AttributeError, TypeError, NameError):  # pragma: no cover
         pass
 
diff --git a/hypothesis-python/tests/cover/test_compat.py b/hypothesis-python/tests/cover/test_compat.py
index 673b19e07a..90f4d1a2eb 100644
--- a/hypothesis-python/tests/cover/test_compat.py
+++ b/hypothesis-python/tests/cover/test_compat.py
@@ -9,7 +9,9 @@
 # obtain one at https://mozilla.org/MPL/2.0/.
 
 import math
-from inspect import Parameter, Signature
+from dataclasses import dataclass
+from inspect import Parameter, Signature, signature
+from typing import ForwardRef, Optional, Union
 
 import pytest
 
@@ -45,3 +47,44 @@ class WeirdSig:
 
 def test_no_type_hints():
     assert get_type_hints(WeirdSig) == {}
+
+
+@dataclass
+class Foo:
+    x: "Foo" = None  # type: ignore
+
+
+Foo.__signature__ = signature(Foo).replace(  # type: ignore
+    parameters=[
+        Parameter(
+            "x",
+            Parameter.POSITIONAL_OR_KEYWORD,
+            annotation=ForwardRef("Foo"),
+            default=None,
+        )
+    ]
+)
+
+
+@dataclass
+class Bar:
+    x: Optional[Union[int, "Bar"]]
+
+
+Bar.__signature__ = signature(Bar).replace(  # type: ignore
+    parameters=[
+        Parameter(
+            "x",
+            Parameter.POSITIONAL_OR_KEYWORD,
+            annotation=Optional[Union[int, ForwardRef("Bar")]],  # type: ignore
+        )
+    ]
+)
+
+
+@pytest.mark.parametrize(
+    "obj,expected", [(Foo, Optional[Foo]), (Bar, Optional[Union[int, Bar]])]
+)
+def test_resolve_fwd_refs(obj, expected):
+    # See: https://github.com/HypothesisWorks/hypothesis/issues/3519
+    assert get_type_hints(obj)["x"] == expected


Code review comments:
diff_hunk: @@ -111,10 +146,25 @@ def get_type_hints(thing):
                     and is_a_type(p.annotation)
                     and p.annotation is not p.empty
                 ):
+
+                    p_hint = p.annotation
+
+                    # Defer to `get_type_hints` if signature annotation is, or
+                    # contains, a forward reference that is otherwise resolved.
+                    if any(
+                        isinstance(sig_hint, ForwardRef)
+                        and not isinstance(hint, ForwardRef)
+                        for sig_hint, hint in zip(
+                            _hint_and_args(p.annotation),
+                            _hint_and_args(hints.get(p.name, Any)),
+                        )
+                    ):
+                        p_hint = hints[p.name]
+                    print(p_hint, p.default)
path: hypothesis-python/src/hypothesis/internal/compat.py
text: @author:
```suggestion
```