Skip to content

Commit

Permalink
Merge pull request #3082 from HypothesisWorks/issue-2978
Browse files Browse the repository at this point in the history
Resolves strategies from `Annotated` type
  • Loading branch information
Zac-HD committed Sep 6, 2021
2 parents 79b1a19 + 80b49d6 commit 5846a1f
Show file tree
Hide file tree
Showing 6 changed files with 118 additions and 11 deletions.
15 changes: 15 additions & 0 deletions hypothesis-python/RELEASE.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
RELEASE_TYPE: minor

This release teaches :func:`~hypothesis.strategies.from_type` a neat trick:
when resolving an :obj:`python:typing.Annotated` type, if one of the annotations
is a strategy object we use that as the inferred strategy. For example:

.. code-block:: python
PositiveInt = Annotated[int, st.integers(min_value=1)]
If there are multiple strategies, we use the last outer-most annotation.
See :issue:`2978` and :pull:`3082` for discussion.

*Requires Python 3.9 or later for*
:func:`get_type_hints(..., include_extras=False) <typing.get_type_hints>`.
13 changes: 4 additions & 9 deletions hypothesis-python/src/hypothesis/internal/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,28 +81,25 @@ def get_type_hints(thing):
Never errors: instead of raising TypeError for uninspectable objects, or
NameError for unresolvable forward references, just return an empty dict.
"""
kwargs = {} if sys.version_info[:2] < (3, 9) else {"include_extras": True}

try:
hints = typing.get_type_hints(thing)
hints = typing.get_type_hints(thing, **kwargs)
except (AttributeError, TypeError, NameError):
hints = {}

if not inspect.isclass(thing):
return hints

try:
hints.update(typing.get_type_hints(thing.__init__))
hints.update(typing.get_type_hints(thing.__init__, **kwargs))
except (TypeError, NameError, AttributeError):
pass

try:
if hasattr(thing, "__signature__"):
# It is possible for the signature and annotations attributes to
# differ on an object due to renamed arguments.
# To prevent missing arguments we use the signature to provide any type
# hints it has and then override any common names with the more
# comprehensive type information from get_type_hints
# See https://github.com/HypothesisWorks/hypothesis/pull/2580
# for more details.
from hypothesis.strategies._internal.types import is_a_type

vkinds = (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
Expand All @@ -112,8 +109,6 @@ def get_type_hints(thing):
hints[p.name] = typing.Optional[p.annotation]
else:
hints[p.name] = p.annotation
else: # pragma: no cover
pass
except (AttributeError, TypeError, NameError): # pragma: no cover
pass

Expand Down
18 changes: 18 additions & 0 deletions hypothesis-python/src/hypothesis/strategies/_internal/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,21 @@ def is_annotated_type(thing):
)


def find_annotated_strategy(annotated_type): # pragma: no cover
flattened_meta = []

all_args = (
*getattr(annotated_type, "__args__", ()),
*getattr(annotated_type, "__metadata__", ()),
)
for arg in all_args:
if is_annotated_type(arg):
flattened_meta.append(find_annotated_strategy(arg))
if isinstance(arg, st.SearchStrategy):
flattened_meta.append(arg)
return flattened_meta[-1] if flattened_meta else None


def has_type_arguments(type_):
"""Decides whethere or not this type has applied type arguments."""
args = getattr(type_, "__args__", None)
Expand Down Expand Up @@ -258,6 +273,9 @@ def from_typing_type(thing):
return st.sampled_from(literals)
if is_annotated_type(thing): # pragma: no cover
# This requires Python 3.9+ or the typing_extensions package
annotated_strategy = find_annotated_strategy(thing)
if annotated_strategy is not None:
return annotated_strategy
args = thing.__args__
assert args, "it's impossible to make an annotated type with no args"
annotated_type = args[0]
Expand Down
13 changes: 12 additions & 1 deletion hypothesis-python/tests/cover/test_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@
# END HEADER

import math
from inspect import Parameter, Signature

import pytest

from hypothesis.internal.compat import ceil, floor
from hypothesis.internal.compat import ceil, floor, get_type_hints

floor_ceil_values = [
-10.7,
Expand All @@ -39,3 +40,13 @@ def test_our_floor_agrees_with_math_floor(value):
@pytest.mark.parametrize("value", floor_ceil_values)
def test_our_ceil_agrees_with_math_ceil(value):
assert ceil(value) == math.ceil(value)


class WeirdSig:
__signature__ = Signature(
parameters=[Parameter(name="args", kind=Parameter.VAR_POSITIONAL)]
)


def test_no_type_hints():
assert get_type_hints(WeirdSig) == {}
36 changes: 35 additions & 1 deletion hypothesis-python/tests/cover/test_lookup_py39.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

import pytest

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


Expand All @@ -40,6 +40,40 @@ def test_typing_Annotated(annotated_type, expected_strategy_repr):
assert repr(st.from_type(annotated_type)) == expected_strategy_repr


PositiveInt = typing.Annotated[int, st.integers(min_value=1)]
MoreThenTenInt = typing.Annotated[PositiveInt, st.integers(min_value=10 + 1)]
WithTwoStrategies = typing.Annotated[int, st.integers(), st.none()]
ExtraAnnotationNoStrategy = typing.Annotated[PositiveInt, "metadata"]


def arg_positive(x: PositiveInt):
assert x > 0


def arg_more_than_ten(x: MoreThenTenInt):
assert x > 10


@given(st.data())
def test_annotated_positive_int(data):
data.draw(st.builds(arg_positive))


@given(st.data())
def test_annotated_more_than_ten(data):
data.draw(st.builds(arg_more_than_ten))


@given(st.data())
def test_annotated_with_two_strategies(data):
assert data.draw(st.from_type(WithTwoStrategies)) is None


@given(st.data())
def test_annotated_extra_metadata(data):
assert data.draw(st.from_type(ExtraAnnotationNoStrategy)) > 0


@dataclasses.dataclass
class User:
id: int
Expand Down
34 changes: 34 additions & 0 deletions hypothesis-python/tests/typing_extensions/test_backported_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,37 @@ def test_defaultdict(ex):
)
def test_typing_extensions_Annotated(annotated_type, expected_strategy_repr):
assert repr(st.from_type(annotated_type)) == expected_strategy_repr


PositiveInt = Annotated[int, st.integers(min_value=1)]
MoreThenTenInt = Annotated[PositiveInt, st.integers(min_value=10 + 1)]
WithTwoStrategies = Annotated[int, st.integers(), st.none()]
ExtraAnnotationNoStrategy = Annotated[PositiveInt, "metadata"]


def arg_positive(x: PositiveInt):
assert x > 0


def arg_more_than_ten(x: MoreThenTenInt):
assert x > 10


@given(st.data())
def test_annotated_positive_int(data):
data.draw(st.builds(arg_positive))


@given(st.data())
def test_annotated_more_than_ten(data):
data.draw(st.builds(arg_more_than_ten))


@given(st.data())
def test_annotated_with_two_strategies(data):
assert data.draw(st.from_type(WithTwoStrategies)) is None


@given(st.data())
def test_annotated_extra_metadata(data):
assert data.draw(st.from_type(ExtraAnnotationNoStrategy)) > 0

0 comments on commit 5846a1f

Please sign in to comment.