Skip to content

Commit

Permalink
Merge pull request #4081 from HypothesisWorks/issue-4080
Browse files Browse the repository at this point in the history
Support TypeVar defaults and `typing_extensions.TypeVar`
  • Loading branch information
Zac-HD committed Aug 10, 2024
2 parents b5c44cf + c31e070 commit e8b9d91
Show file tree
Hide file tree
Showing 6 changed files with 98 additions and 14 deletions.
5 changes: 5 additions & 0 deletions hypothesis-python/RELEASE.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
RELEASE_TYPE: patch

Support ``__default__`` field of :obj:`~python:typing.TypeVar`
and support the same from :pypi:`typing_extensions`
in :func:`~hypothesis.strategies.from_type`.
2 changes: 1 addition & 1 deletion hypothesis-python/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def local_file(name):
"pytest": ["pytest>=4.6"],
"dpcontracts": ["dpcontracts>=0.4"],
"redis": ["redis>=3.0.0"],
"crosshair": ["hypothesis-crosshair>=0.0.11", "crosshair-tool>=0.0.65"],
"crosshair": ["hypothesis-crosshair>=0.0.12", "crosshair-tool>=0.0.66"],
# zoneinfo is an odd one: every dependency is conditional, because they're
# only necessary on old versions of Python or Windows systems or emscripten.
"zoneinfo": [
Expand Down
44 changes: 32 additions & 12 deletions hypothesis-python/src/hypothesis/strategies/_internal/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,12 @@
extended_get_origin = get_origin # type: ignore


# Used on `TypeVar` objects with no default:
NoDefaults = (
getattr(typing, "NoDefault", object()),
getattr(typing_extensions, "NoDefault", object()),
)

# We use this variable to be sure that we are working with a type from `typing`:
typing_root_type = (typing._Final, typing._GenericAlias) # type: ignore

Expand Down Expand Up @@ -440,9 +446,9 @@ def is_generic_type(type_):
)


def _try_import_forward_ref(thing, bound, *, type_params): # pragma: no cover
def _try_import_forward_ref(thing, typ, *, type_params): # pragma: no cover
"""
Tries to import a real bound type from ``TypeVar`` bound to a ``ForwardRef``.
Tries to import a real bound or default type from ``ForwardRef`` in ``TypeVar``.
This function is very "magical" to say the least, please don't use it.
This function fully covered, but is excluded from coverage
Expand All @@ -452,13 +458,13 @@ def _try_import_forward_ref(thing, bound, *, type_params): # pragma: no cover
kw = {"globalns": vars(sys.modules[thing.__module__]), "localns": None}
if __EVAL_TYPE_TAKES_TYPE_PARAMS:
kw["type_params"] = type_params
return typing._eval_type(bound, **kw)
return typing._eval_type(typ, **kw)
except (KeyError, AttributeError, NameError):
# We fallback to `ForwardRef` instance, you can register it as a type as well:
# >>> from typing import ForwardRef
# >>> from hypothesis import strategies as st
# >>> st.register_type_strategy(ForwardRef('YourType'), your_strategy)
return bound
return typ


def from_typing_type(thing):
Expand Down Expand Up @@ -1082,25 +1088,39 @@ def resolve_Callable(thing):


@register(typing.TypeVar)
@register("TypeVar", module=typing_extensions)
def resolve_TypeVar(thing):
type_var_key = f"typevar={thing!r}"

if getattr(thing, "__bound__", None) is not None:
bound = thing.__bound__
if isinstance(bound, typing.ForwardRef):
bound = getattr(thing, "__bound__", None)
default = getattr(thing, "__default__", NoDefaults[0])
original_strategies = []

def resolve_strategies(typ):
if isinstance(typ, typing.ForwardRef):
# TODO: on Python 3.13 and later, we should work out what type_params
# could be part of this type, and pass them in here.
bound = _try_import_forward_ref(thing, bound, type_params=())
strat = unwrap_strategies(st.from_type(bound))
typ = _try_import_forward_ref(thing, typ, type_params=())
strat = unwrap_strategies(st.from_type(typ))
if not isinstance(strat, OneOfStrategy):
return strat
# The bound was a union, or we resolved it as a union of subtypes,
original_strategies.append(strat)
else:
original_strategies.extend(strat.original_strategies)

if bound is not None:
resolve_strategies(bound)
if default not in NoDefaults: # pragma: no cover
# Coverage requires 3.13 or `typing_extensions` package.
resolve_strategies(default)

if original_strategies:
# The bound / default was a union, or we resolved it as a union of subtypes,
# so we need to unpack the strategy to ensure consistency across uses.
# This incantation runs a sampled_from over the strategies inferred for
# each part of the union, wraps that in shared so that we only generate
# from one type per testcase, and flatmaps that back to instances.
return st.shared(
st.sampled_from(strat.original_strategies), key=type_var_key
st.sampled_from(original_strategies), key=type_var_key
).flatmap(lambda s: s)

builtin_scalar_types = [type(None), bool, int, float, str, bytes]
Expand Down
4 changes: 4 additions & 0 deletions hypothesis-python/tests/cover/test_deadline.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ def slow(i):
slow()


@pytest.mark.skipif(
settings.get_profile(settings._current_profile).deadline is None,
reason="not expected to fail if deadline is disabled",
)
@fails_with(DeadlineExceeded)
@given(st.integers())
def test_slow_tests_are_errors_by_default(i):
Expand Down
1 change: 1 addition & 0 deletions hypothesis-python/tests/cover/test_type_lookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ def _check_instances(t):
t.__module__ != "typing"
and t.__name__ != "ByteString"
and not t.__module__.startswith("pydantic")
and t.__module__ != "typing_extensions"
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
TypeIs,
)

from hypothesis import assume, given, strategies as st
from hypothesis import HealthCheck, assume, given, settings, strategies as st
from hypothesis.errors import InvalidArgument
from hypothesis.strategies import from_type
from hypothesis.strategies._internal.types import NON_RUNTIME_TYPES
Expand Down Expand Up @@ -335,3 +335,57 @@ class Baz(TypedDict):

def test_literal_string_is_just_a_string():
assert_all_examples(from_type(LiteralString), lambda thing: isinstance(thing, str))


class Foo:
def __init__(self, x):
pass


class Bar(Foo):
pass


class Baz(Foo):
pass


st.register_type_strategy(Bar, st.builds(Bar, st.integers()))
st.register_type_strategy(Baz, st.builds(Baz, st.integers()))

T = typing_extensions.TypeVar("T")
T_int = typing_extensions.TypeVar("T_int", bound=int)


@pytest.mark.parametrize(
"var,expected",
[
(typing_extensions.TypeVar("V"), object),
# Bound:
(typing_extensions.TypeVar("V", bound=int), int),
(typing_extensions.TypeVar("V", bound=Foo), (Bar, Baz)),
(typing_extensions.TypeVar("V", bound=Union[int, str]), (int, str)),
# Constraints:
(typing_extensions.TypeVar("V", int, str), (int, str)),
# Default:
(typing_extensions.TypeVar("V", default=int), int),
(typing_extensions.TypeVar("V", default=T), object),
(typing_extensions.TypeVar("V", default=Foo), (Bar, Baz)),
(typing_extensions.TypeVar("V", default=Union[int, str]), (int, str)),
(typing_extensions.TypeVar("V", default=T_int), int),
(typing_extensions.TypeVar("V", default=T_int, bound=int), int),
(typing_extensions.TypeVar("V", int, str, default=int), (int, str)),
# This case is not correct from typing's perspective, but its not
# our job to very this, static type-checkers should do that:
(typing_extensions.TypeVar("V", default=T_int, bound=str), (int, str)),
],
)
@settings(suppress_health_check=[HealthCheck.too_slow])
@given(data=st.data())
def test_typevar_type_is_consistent(data, var, expected):
strat = st.from_type(var)
v1 = data.draw(strat)
v2 = data.draw(strat)
assume(v1 != v2) # Values may vary, just not types
assert type(v1) == type(v2)
assert isinstance(v1, expected)

0 comments on commit e8b9d91

Please sign in to comment.