Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resolves strategies from Annotated type #3082

Merged
merged 2 commits into from
Sep 6, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was not aware of this hack! Oh wow!


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