Skip to content

Commit 39f85d4

Browse files
committed
Improve handling of GroupedMetadata
1 parent fbf5945 commit 39f85d4

File tree

4 files changed

+74
-20
lines changed

4 files changed

+74
-20
lines changed

hypothesis-python/RELEASE.rst

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
RELEASE_TYPE: minor
2+
3+
This release improves our support for the :pypi:`annotated-types` iterable
4+
``GroupedMetadata`` protocol. In order to treat the elements "as if they
5+
had been unpacked", if one such element is a :class:`~hypothesis.strategies.SearchStrategy`
6+
we now resolve to that strategy. Previously, we treated this as an unknown
7+
filter predicate.
8+
9+
We expect this to be useful for libraries implementing custom metadata -
10+
instead of requiring downstream integration, they can implement the protocol
11+
and yield a lazily-created strategy. Doing so only if Hypothesis is in
12+
:obj:`sys.modules` gives powerful integration with no runtime overhead
13+
or extra dependencies.

hypothesis-python/src/hypothesis/strategies/_internal/types.py

+24-18
Original file line numberDiff line numberDiff line change
@@ -291,14 +291,18 @@ def get_constraints_filter_map():
291291

292292

293293
def _get_constraints(args: Tuple[Any, ...]) -> Iterator["at.BaseMetadata"]:
294-
if at := sys.modules.get("annotated_types"):
295-
for arg in args:
296-
if isinstance(arg, at.BaseMetadata):
297-
yield arg
298-
elif getattr(arg, "__is_annotated_types_grouped_metadata__", False):
299-
yield from arg
300-
elif isinstance(arg, slice) and arg.step in (1, None):
301-
yield from at.Len(arg.start or 0, arg.stop)
294+
at = sys.modules.get("annotated_types")
295+
for arg in args:
296+
if at and isinstance(arg, at.BaseMetadata):
297+
yield arg
298+
elif getattr(arg, "__is_annotated_types_grouped_metadata__", False):
299+
for subarg in arg:
300+
if getattr(subarg, "__is_annotated_types_grouped_metadata__", False):
301+
yield from _get_constraints(tuple(subarg))
302+
else:
303+
yield subarg
304+
elif at and isinstance(arg, slice) and arg.step in (1, None):
305+
yield from at.Len(arg.start or 0, arg.stop)
302306

303307

304308
def _flat_annotated_repr_parts(annotated_type):
@@ -341,16 +345,18 @@ def find_annotated_strategy(annotated_type):
341345
return arg
342346

343347
filter_conditions = []
344-
if "annotated_types" in sys.modules:
345-
unsupported = []
346-
for constraint in _get_constraints(metadata):
347-
if convert := get_constraints_filter_map().get(type(constraint)):
348-
filter_conditions.append(convert(constraint))
349-
else:
350-
unsupported.append(constraint)
351-
if unsupported:
352-
msg = f"Ignoring unsupported {', '.join(map(repr, unsupported))}"
353-
warnings.warn(msg, HypothesisWarning, stacklevel=2)
348+
unsupported = []
349+
constraints_map = get_constraints_filter_map()
350+
for constraint in _get_constraints(metadata):
351+
if isinstance(constraint, st.SearchStrategy):
352+
return constraint
353+
if convert := constraints_map.get(type(constraint)):
354+
filter_conditions.append(convert(constraint))
355+
else:
356+
unsupported.append(constraint)
357+
if unsupported:
358+
msg = f"Ignoring unsupported {', '.join(map(repr, unsupported))}"
359+
warnings.warn(msg, HypothesisWarning, stacklevel=2)
354360

355361
base_strategy = st.from_type(annotated_type.__origin__)
356362
for filter_condition in filter_conditions:

hypothesis-python/tests/cover/test_lookup_py39.py

+17-2
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def test_typing_Annotated(annotated_type, expected_strategy_repr):
4545

4646

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

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

5656

57-
def arg_more_than_ten(x: MoreThenTenInt):
57+
def arg_more_than_ten(x: MoreThanTenInt):
5858
assert x > 10
5959

6060

@@ -161,3 +161,18 @@ def test_lookup_registered_tuple():
161161
with temp_registered(tuple, st.just(sentinel)):
162162
assert_simple_property(st.from_type(typ), lambda v: v is sentinel)
163163
assert_simple_property(st.from_type(typ), lambda v: v is not sentinel)
164+
165+
166+
sentinel = object()
167+
168+
169+
class LazyStrategyAnnotation:
170+
__is_annotated_types_grouped_metadata__ = True
171+
172+
def __iter__(self):
173+
return iter([st.just(sentinel)])
174+
175+
176+
@given(...)
177+
def test_grouped_protocol_strategy(x: typing.Annotated[int, LazyStrategyAnnotation()]):
178+
assert x is sentinel

hypothesis-python/tests/test_annotated_types.py

+20
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from hypothesis.errors import HypothesisWarning, ResolutionFailed
1919
from hypothesis.strategies._internal.lazy import unwrap_strategies
2020
from hypothesis.strategies._internal.strategies import FilteredStrategy
21+
from hypothesis.strategies._internal.types import _get_constraints
2122

2223
from tests.common.debug import check_can_generate_examples
2324

@@ -117,3 +118,22 @@ def test_collection_size_from_slice(data):
117118
t = Annotated[MyCollection, "we just ignore this", slice(1, 10)]
118119
value = data.draw(st.from_type(t))
119120
assert 1 <= len(value) <= 10
121+
122+
123+
class GroupedStuff:
124+
__is_annotated_types_grouped_metadata__ = True
125+
126+
def __init__(self, *args) -> None:
127+
self._args = args
128+
129+
def __iter__(self):
130+
return iter(self._args)
131+
132+
def __repr__(self) -> str:
133+
return f"GroupedStuff({', '.join(map(repr, self._args))})"
134+
135+
136+
def test_flattens_grouped_metadata():
137+
grp = GroupedStuff(GroupedStuff(GroupedStuff(at.Len(min_length=1, max_length=5))))
138+
constraints = list(_get_constraints(grp))
139+
assert constraints == [at.MinLen(1), at.MaxLen(5)]

0 commit comments

Comments
 (0)