Skip to content

Commit

Permalink
Merge pull request #3986 from Zac-HD/better-annotated-resolution
Browse files Browse the repository at this point in the history
Improve handling of GroupedMetadata
  • Loading branch information
Zac-HD committed May 13, 2024
2 parents fbf5945 + 39f85d4 commit 5983af0
Show file tree
Hide file tree
Showing 4 changed files with 74 additions and 20 deletions.
13 changes: 13 additions & 0 deletions hypothesis-python/RELEASE.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
RELEASE_TYPE: minor

This release improves our support for the :pypi:`annotated-types` iterable
``GroupedMetadata`` protocol. In order to treat the elements "as if they
had been unpacked", if one such element is a :class:`~hypothesis.strategies.SearchStrategy`
we now resolve to that strategy. Previously, we treated this as an unknown
filter predicate.

We expect this to be useful for libraries implementing custom metadata -
instead of requiring downstream integration, they can implement the protocol
and yield a lazily-created strategy. Doing so only if Hypothesis is in
:obj:`sys.modules` gives powerful integration with no runtime overhead
or extra dependencies.
42 changes: 24 additions & 18 deletions hypothesis-python/src/hypothesis/strategies/_internal/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,14 +291,18 @@ def get_constraints_filter_map():


def _get_constraints(args: Tuple[Any, ...]) -> Iterator["at.BaseMetadata"]:
if at := sys.modules.get("annotated_types"):
for arg in args:
if isinstance(arg, at.BaseMetadata):
yield arg
elif getattr(arg, "__is_annotated_types_grouped_metadata__", False):
yield from arg
elif isinstance(arg, slice) and arg.step in (1, None):
yield from at.Len(arg.start or 0, arg.stop)
at = sys.modules.get("annotated_types")
for arg in args:
if at and isinstance(arg, at.BaseMetadata):
yield arg
elif getattr(arg, "__is_annotated_types_grouped_metadata__", False):
for subarg in arg:
if getattr(subarg, "__is_annotated_types_grouped_metadata__", False):
yield from _get_constraints(tuple(subarg))
else:
yield subarg
elif at and isinstance(arg, slice) and arg.step in (1, None):
yield from at.Len(arg.start or 0, arg.stop)


def _flat_annotated_repr_parts(annotated_type):
Expand Down Expand Up @@ -341,16 +345,18 @@ def find_annotated_strategy(annotated_type):
return arg

filter_conditions = []
if "annotated_types" in sys.modules:
unsupported = []
for constraint in _get_constraints(metadata):
if convert := get_constraints_filter_map().get(type(constraint)):
filter_conditions.append(convert(constraint))
else:
unsupported.append(constraint)
if unsupported:
msg = f"Ignoring unsupported {', '.join(map(repr, unsupported))}"
warnings.warn(msg, HypothesisWarning, stacklevel=2)
unsupported = []
constraints_map = get_constraints_filter_map()
for constraint in _get_constraints(metadata):
if isinstance(constraint, st.SearchStrategy):
return constraint
if convert := constraints_map.get(type(constraint)):
filter_conditions.append(convert(constraint))
else:
unsupported.append(constraint)
if unsupported:
msg = f"Ignoring unsupported {', '.join(map(repr, unsupported))}"
warnings.warn(msg, HypothesisWarning, stacklevel=2)

base_strategy = st.from_type(annotated_type.__origin__)
for filter_condition in filter_conditions:
Expand Down
19 changes: 17 additions & 2 deletions hypothesis-python/tests/cover/test_lookup_py39.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def test_typing_Annotated(annotated_type, expected_strategy_repr):


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

Expand All @@ -54,7 +54,7 @@ def arg_positive(x: PositiveInt):
assert x > 0


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


Expand Down Expand Up @@ -161,3 +161,18 @@ def test_lookup_registered_tuple():
with temp_registered(tuple, st.just(sentinel)):
assert_simple_property(st.from_type(typ), lambda v: v is sentinel)
assert_simple_property(st.from_type(typ), lambda v: v is not sentinel)


sentinel = object()


class LazyStrategyAnnotation:
__is_annotated_types_grouped_metadata__ = True

def __iter__(self):
return iter([st.just(sentinel)])


@given(...)
def test_grouped_protocol_strategy(x: typing.Annotated[int, LazyStrategyAnnotation()]):
assert x is sentinel
20 changes: 20 additions & 0 deletions hypothesis-python/tests/test_annotated_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from hypothesis.errors import HypothesisWarning, ResolutionFailed
from hypothesis.strategies._internal.lazy import unwrap_strategies
from hypothesis.strategies._internal.strategies import FilteredStrategy
from hypothesis.strategies._internal.types import _get_constraints

from tests.common.debug import check_can_generate_examples

Expand Down Expand Up @@ -117,3 +118,22 @@ def test_collection_size_from_slice(data):
t = Annotated[MyCollection, "we just ignore this", slice(1, 10)]
value = data.draw(st.from_type(t))
assert 1 <= len(value) <= 10


class GroupedStuff:
__is_annotated_types_grouped_metadata__ = True

def __init__(self, *args) -> None:
self._args = args

def __iter__(self):
return iter(self._args)

def __repr__(self) -> str:
return f"GroupedStuff({', '.join(map(repr, self._args))})"


def test_flattens_grouped_metadata():
grp = GroupedStuff(GroupedStuff(GroupedStuff(at.Len(min_length=1, max_length=5))))
constraints = list(_get_constraints(grp))
assert constraints == [at.MinLen(1), at.MaxLen(5)]

0 comments on commit 5983af0

Please sign in to comment.