From 4bb67e42bc45d081bab3666e313587e14d67b477 Mon Sep 17 00:00:00 2001 From: Zac-HD Date: Thu, 23 Apr 2020 12:29:38 +1000 Subject: [PATCH] Improved get_type_hints() for classes --- hypothesis-python/RELEASE.rst | 5 +++ .../src/hypothesis/internal/compat.py | 25 ++++++++++---- hypothesis-python/tests/conftest.py | 2 ++ .../tests/cover/test_lookup_py36.py | 33 +++++++++++++++++++ .../tests/cover/test_lookup_py38.py | 12 +++++++ 5 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 hypothesis-python/RELEASE.rst create mode 100644 hypothesis-python/tests/cover/test_lookup_py36.py diff --git a/hypothesis-python/RELEASE.rst b/hypothesis-python/RELEASE.rst new file mode 100644 index 0000000000..8db588019e --- /dev/null +++ b/hypothesis-python/RELEASE.rst @@ -0,0 +1,5 @@ +RELEASE_TYPE: patch + +This patch improves the internals of :func:`~hypothesis.strategies.builds` type +inference, to handle recursive forward references in certain dataclasses. +This is useful for e.g. :pypi:`hypothesmith`'s forthcoming :pypi:`LibCST` mode. diff --git a/hypothesis-python/src/hypothesis/internal/compat.py b/hypothesis-python/src/hypothesis/internal/compat.py index 861ca34dcc..0f71b4cff9 100644 --- a/hypothesis-python/src/hypothesis/internal/compat.py +++ b/hypothesis-python/src/hypothesis/internal/compat.py @@ -125,14 +125,25 @@ def get_type_hints(thing): else: def get_type_hints(thing): - if inspect.isclass(thing) and not hasattr(thing, "__signature__"): - if is_typed_named_tuple(thing): - # Special handling for typing.NamedTuple - return thing._field_types # type: ignore - thing = thing.__init__ # type: ignore + """Like the typing version, but tries harder and never errors. + + Tries harder: if the thing to inspect is a class but typing.get_type_hints + raises an error or returns no hints, then this function will try calling it + on the __init__ method. This second step often helps with user-defined + classes on older versions of Python. + + Never errors: instead of raising TypeError for uninspectable objects, or + NameError for unresolvable forward references, just return an empty dict. + """ try: - return typing.get_type_hints(thing) - except TypeError: + hints = typing.get_type_hints(thing) + except (TypeError, NameError): + hints = {} + if hints or not inspect.isclass(thing): + return hints + try: + return typing.get_type_hints(thing.__init__) + except (TypeError, NameError, AttributeError): return {} diff --git a/hypothesis-python/tests/conftest.py b/hypothesis-python/tests/conftest.py index 3cc6d33f67..261bb9dd94 100644 --- a/hypothesis-python/tests/conftest.py +++ b/hypothesis-python/tests/conftest.py @@ -29,6 +29,8 @@ # Skip collection of tests which require the Django test runner, # or that don't work on the current version of Python. collect_ignore_glob = ["django/*"] +if sys.version_info < (3, 6): # Remove after dropping Python 3.5 + collect_ignore_glob.append("cover/*py36*") if sys.version_info < (3, 8): collect_ignore_glob.append("cover/*py38*") diff --git a/hypothesis-python/tests/cover/test_lookup_py36.py b/hypothesis-python/tests/cover/test_lookup_py36.py new file mode 100644 index 0000000000..334099948c --- /dev/null +++ b/hypothesis-python/tests/cover/test_lookup_py36.py @@ -0,0 +1,33 @@ +# This file is part of Hypothesis, which may be found at +# https://github.com/HypothesisWorks/hypothesis/ +# +# Most of this work is copyright (C) 2013-2020 David R. MacIver +# (david@drmaciver.com), but it contains contributions by others. See +# CONTRIBUTING.rst for a full list of people who may hold copyright, and +# consult the git log if you need to determine who owns an individual +# contribution. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, +# 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/. +# +# END HEADER + +import typing + +import pytest + +from hypothesis import given, strategies as st +from hypothesis.internal.compat import PYPY + + +class TreeForwardRefs(typing.NamedTuple): + val: int + l: typing.Optional["TreeForwardRefs"] + r: typing.Optional["TreeForwardRefs"] + + +@pytest.mark.skipif(PYPY, reason="pypy36 does not resolve the forward refs") +@given(st.builds(TreeForwardRefs)) +def test_resolves_forward_references_outside_annotations(t): + assert isinstance(t, TreeForwardRefs) diff --git a/hypothesis-python/tests/cover/test_lookup_py38.py b/hypothesis-python/tests/cover/test_lookup_py38.py index 822cd1396a..9724a838c4 100644 --- a/hypothesis-python/tests/cover/test_lookup_py38.py +++ b/hypothesis-python/tests/cover/test_lookup_py38.py @@ -13,6 +13,7 @@ # # END HEADER +import dataclasses import typing import pytest @@ -101,3 +102,14 @@ def test_layered_optional_key_is_optional(): # Optional keys are not currently supported, as PEP-589 leaves no traces # at runtime. See https://github.com/python/cpython/pull/17214 find_any(from_type(C), lambda d: "b" not in d) + + +@dataclasses.dataclass() +class Node: + left: typing.Union["Node", int] + right: typing.Union["Node", int] + + +@given(st.builds(Node)) +def test_can_resolve_recursive_dataclass(val): + assert isinstance(val, Node)