Skip to content

Commit 56af52a

Browse files
authored
Merge pull request #3769 from h4l/optional-strategy-function
2 parents 9606972 + aad4098 commit 56af52a

21 files changed

Lines changed: 242 additions & 75 deletions

File tree

AUTHORS.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ their individual contributions.
7373
* `Gregory Petrosyan <https://github.com/flyingmutant>`_
7474
* `Grzegorz Zieba <https://github.com/gzaxel>`_ ([email protected])
7575
* `Grigorios Giannakopoulos <https://github.com/grigoriosgiann>`_
76+
* `Hal Blackburn <https://github.com/h4l>`_
7677
* `Hugo van Kemenade <https://github.com/hugovk>`_
7778
* `Humberto Rocha <https://github.com/humrochagf>`_
7879
* `Ilya Lebedev <https://github.com/melevir>`_ ([email protected])

HypothesisWorks.github.io/_posts/2016-05-26-exploring-voting-with-hypothesis.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ some point). If we have zero, that's a draw. If we have one, that's a
8787
victory.
8888

8989
It seems pretty plausible that these would not produce the same answer
90-
all the time (it would be surpising if they did!), but it's maybe not
90+
all the time (it would be surprising if they did!), but it's maybe not
9191
obvious how you would go about constructing an example that shows it.
9292

9393
Fortunately, we don't have to because Hypothesis can do it for us!

brand/README.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Colour palette in GIMP format
3838

3939
A `colour palette in GIMP format <hypothesis.gpl>`__ (``.gpl``) is also provided
4040
with the intent of making it easier to produce graphics and documents which
41-
re-use the colours in the Hypothesis Dragonfly logo by Libby Berrie.
41+
reuse the colours in the Hypothesis Dragonfly logo by Libby Berrie.
4242

4343
The ``hypothesis.gpl`` file should be copied or imported to the appropriate
4444
location on your filesystem. For example:

hypothesis-python/RELEASE.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
RELEASE_TYPE: minor
2+
3+
This release allows strategy-generating functions registered with
4+
:func:`~hypothesis.strategies.register_type_strategy` to conditionally not
5+
return a strategy, by returning :data:`NotImplemented` (:issue:`3767`).

hypothesis-python/examples/test_basic.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def get_discount_price(self, discount_percentage: float):
2020
return self.price * (discount_percentage / 100)
2121

2222

23-
# The @given decorater generates examples for us!
23+
# The @given decorator generates examples for us!
2424
@given(
2525
price=st.floats(min_value=0, allow_nan=False, allow_infinity=False),
2626
discount_percentage=st.floats(

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

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1235,6 +1235,8 @@ def as_strategy(strat_or_callable, thing):
12351235
strategy = strat_or_callable(thing)
12361236
else:
12371237
strategy = strat_or_callable
1238+
if strategy is NotImplemented:
1239+
return NotImplemented
12381240
if not isinstance(strategy, SearchStrategy):
12391241
raise ResolutionFailed(
12401242
f"Error: {thing} was registered for {nicerepr(strat_or_callable)}, "
@@ -1277,7 +1279,9 @@ def from_type_guarded(thing):
12771279
# Check if we have an explicitly registered strategy for this thing,
12781280
# resolve it so, and otherwise resolve as for the base type.
12791281
if thing in types._global_type_lookup:
1280-
return as_strategy(types._global_type_lookup[thing], thing)
1282+
strategy = as_strategy(types._global_type_lookup[thing], thing)
1283+
if strategy is not NotImplemented:
1284+
return strategy
12811285
return _from_type(thing.__supertype__)
12821286
# Unions are not instances of `type` - but we still want to resolve them!
12831287
if types.is_a_union(thing):
@@ -1287,7 +1291,9 @@ def from_type_guarded(thing):
12871291
# They are represented as instances like `~T` when they come here.
12881292
# We need to work with their type instead.
12891293
if isinstance(thing, TypeVar) and type(thing) in types._global_type_lookup:
1290-
return as_strategy(types._global_type_lookup[type(thing)], thing)
1294+
strategy = as_strategy(types._global_type_lookup[type(thing)], thing)
1295+
if strategy is not NotImplemented:
1296+
return strategy
12911297
if not types.is_a_type(thing):
12921298
if isinstance(thing, str):
12931299
# See https://github.com/HypothesisWorks/hypothesis/issues/3016
@@ -1312,7 +1318,9 @@ def from_type_guarded(thing):
13121318
# convert empty results into an explicit error.
13131319
try:
13141320
if thing in types._global_type_lookup:
1315-
return as_strategy(types._global_type_lookup[thing], thing)
1321+
strategy = as_strategy(types._global_type_lookup[thing], thing)
1322+
if strategy is not NotImplemented:
1323+
return strategy
13161324
except TypeError: # pragma: no cover
13171325
# This is due to a bizarre divergence in behaviour under Python 3.9.0:
13181326
# typing.Callable[[], foo] has __args__ = (foo,) but collections.abc.Callable
@@ -1372,11 +1380,16 @@ def from_type_guarded(thing):
13721380
# type. For example, `Number -> integers() | floats()`, but bools() is
13731381
# not included because bool is a subclass of int as well as Number.
13741382
strategies = [
1375-
as_strategy(v, thing)
1376-
for k, v in sorted(types._global_type_lookup.items(), key=repr)
1377-
if isinstance(k, type)
1378-
and issubclass(k, thing)
1379-
and sum(types.try_issubclass(k, typ) for typ in types._global_type_lookup) == 1
1383+
s
1384+
for s in (
1385+
as_strategy(v, thing)
1386+
for k, v in sorted(types._global_type_lookup.items(), key=repr)
1387+
if isinstance(k, type)
1388+
and issubclass(k, thing)
1389+
and sum(types.try_issubclass(k, typ) for typ in types._global_type_lookup)
1390+
== 1
1391+
)
1392+
if s is not NotImplemented
13801393
]
13811394
if any(not s.is_empty for s in strategies):
13821395
return one_of(strategies)
@@ -2142,7 +2155,10 @@ def register_type_strategy(
21422155
for an argument with a default value.
21432156
21442157
``strategy`` may be a search strategy, or a function that takes a type and
2145-
returns a strategy (useful for generic types).
2158+
returns a strategy (useful for generic types). The function may return
2159+
:data:`NotImplemented` to conditionally not provide a strategy for the type
2160+
(the type will still be resolved by other methods, if possible, as if the
2161+
function was not registered).
21462162
21472163
Note that you may not register a parametrised generic type (such as
21482164
``MyCollection[int]``) directly, because the resolution logic does not

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -444,9 +444,13 @@ def from_typing_type(thing):
444444
mapping.pop(t)
445445
# Sort strategies according to our type-sorting heuristic for stable output
446446
strategies = [
447-
v if isinstance(v, st.SearchStrategy) else v(thing)
448-
for k, v in sorted(mapping.items(), key=lambda kv: type_sorting_key(kv[0]))
449-
if sum(try_issubclass(k, T) for T in mapping) == 1
447+
s
448+
for s in (
449+
v if isinstance(v, st.SearchStrategy) else v(thing)
450+
for k, v in sorted(mapping.items(), key=lambda kv: type_sorting_key(kv[0]))
451+
if sum(try_issubclass(k, T) for T in mapping) == 1
452+
)
453+
if s != NotImplemented
450454
]
451455
empty = ", ".join(repr(s) for s in strategies if s.is_empty)
452456
if empty or not strategies:
@@ -484,6 +488,14 @@ def _networks(bits):
484488
# As a general rule, we try to limit this to scalars because from_type()
485489
# would have to decide on arbitrary collection elements, and we'd rather
486490
# not (with typing module generic types and some builtins as exceptions).
491+
#
492+
# Strategy Callables may return NotImplemented, which should be treated in the
493+
# same way as if the type was not registered.
494+
#
495+
# Note that NotImplemented cannot be typed in Python 3.8 because there's no type
496+
# exposed for it, and NotImplemented itself is typed as Any so that it can be
497+
# returned without being listed in a function signature:
498+
# https://github.com/python/mypy/issues/6710#issuecomment-485580032
487499
_global_type_lookup: typing.Dict[
488500
type, typing.Union[st.SearchStrategy, typing.Callable[[type], st.SearchStrategy]]
489501
] = {

hypothesis-python/tests/array_api/test_arrays.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ def test_generate_unique_arrays_without_fill(xp, xps):
391391
392392
Covers the collision-related branches for fully dense unique arrays.
393393
Choosing 25 of 256 possible values means we're almost certain to see
394-
colisions thanks to the birthday paradox, but finding unique values should
394+
collisions thanks to the birthday paradox, but finding unique values should
395395
still be easy.
396396
"""
397397
skip_on_missing_unique_values(xp)

hypothesis-python/tests/cover/test_lookup.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import abc
1212
import builtins
1313
import collections
14+
import contextlib
1415
import datetime
1516
import enum
1617
import inspect
@@ -21,6 +22,7 @@
2122
import sys
2223
import typing
2324
import warnings
25+
from dataclasses import dataclass
2426
from inspect import signature
2527
from numbers import Real
2628

@@ -371,6 +373,25 @@ def test_typevars_can_be_redefine_with_factory():
371373
assert_all_examples(st.from_type(A), lambda obj: obj == "A")
372374

373375

376+
def test_typevars_can_be_resolved_conditionally():
377+
sentinel = object()
378+
A = typing.TypeVar("A")
379+
B = typing.TypeVar("B")
380+
381+
def resolve_type_var(thing):
382+
assert thing in (A, B)
383+
if thing == A:
384+
return st.just(sentinel)
385+
return NotImplemented
386+
387+
with temp_registered(typing.TypeVar, resolve_type_var):
388+
assert st.from_type(A).example() is sentinel
389+
# We've re-defined the default TypeVar resolver, so there is no fallback.
390+
# This causes the lookup to fail.
391+
with pytest.raises(InvalidArgument):
392+
st.from_type(B).example()
393+
394+
374395
def annotated_func(a: int, b: int = 2, *, c: int, d: int = 4):
375396
return a + b + c + d
376397

@@ -465,6 +486,24 @@ def test_resolves_NewType():
465486
assert isinstance(from_type(uni).example(), (int, type(None)))
466487

467488

489+
@pytest.mark.parametrize("is_handled", [True, False])
490+
def test_resolves_NewType_conditionally(is_handled):
491+
sentinel = object()
492+
typ = typing.NewType("T", int)
493+
494+
def resolve_custom_strategy(thing):
495+
assert thing is typ
496+
if is_handled:
497+
return st.just(sentinel)
498+
return NotImplemented
499+
500+
with temp_registered(typ, resolve_custom_strategy):
501+
if is_handled:
502+
assert st.from_type(typ).example() is sentinel
503+
else:
504+
assert isinstance(st.from_type(typ).example(), int)
505+
506+
468507
E = enum.Enum("E", "a b c")
469508

470509

@@ -802,6 +841,58 @@ def test_supportsop_types_support_protocol(protocol, data):
802841
assert issubclass(type(value), protocol)
803842

804843

844+
@pytest.mark.parametrize("restrict_custom_strategy", [True, False])
845+
def test_generic_aliases_can_be_conditionally_resolved_by_registered_function(
846+
restrict_custom_strategy,
847+
):
848+
# Check that a custom strategy function may provide no strategy for a
849+
# generic alias request like Container[T]. We test this under two scenarios:
850+
# - where CustomContainer CANNOT be generated from requests for Container[T]
851+
# (only for requests for exactly CustomContainer[T])
852+
# - where CustomContainer CAN be generated from requests for Container[T]
853+
T = typing.TypeVar("T")
854+
855+
@dataclass
856+
class CustomContainer(typing.Container[T]):
857+
content: T
858+
859+
def __contains__(self, value: object) -> bool:
860+
return self.content == value
861+
862+
def get_custom_container_strategy(thing):
863+
if restrict_custom_strategy and typing.get_origin(thing) != CustomContainer:
864+
return NotImplemented
865+
return st.builds(
866+
CustomContainer, content=st.from_type(typing.get_args(thing)[0])
867+
)
868+
869+
with temp_registered(CustomContainer, get_custom_container_strategy):
870+
871+
def is_custom_container_with_str(example):
872+
return isinstance(example, CustomContainer) and isinstance(
873+
example.content, str
874+
)
875+
876+
def is_non_custom_container(example):
877+
return isinstance(example, typing.Container) and not isinstance(
878+
example, CustomContainer
879+
)
880+
881+
assert_all_examples(
882+
st.from_type(CustomContainer[str]), is_custom_container_with_str
883+
)
884+
# If the strategy function is restricting, it doesn't return a strategy
885+
# for requests for Container[...], so it's never generated. When not
886+
# restricting, it is generated.
887+
if restrict_custom_strategy:
888+
assert_all_examples(
889+
st.from_type(typing.Container[str]), is_non_custom_container
890+
)
891+
else:
892+
find_any(st.from_type(typing.Container[str]), is_custom_container_with_str)
893+
find_any(st.from_type(typing.Container[str]), is_non_custom_container)
894+
895+
805896
@pytest.mark.parametrize(
806897
"protocol, typ",
807898
[
@@ -1053,3 +1144,31 @@ def f(x: int):
10531144
msg = "@no_type_check decorator prevented Hypothesis from inferring a strategy"
10541145
with pytest.raises(TypeError, match=msg):
10551146
st.builds(f).example()
1147+
1148+
1149+
def test_custom_strategy_function_resolves_types_conditionally():
1150+
sentinel = object()
1151+
1152+
class A:
1153+
pass
1154+
1155+
class B(A):
1156+
pass
1157+
1158+
class C(A):
1159+
pass
1160+
1161+
def resolve_custom_strategy_for_b(thing):
1162+
if thing == B:
1163+
return st.just(sentinel)
1164+
return NotImplemented
1165+
1166+
with contextlib.ExitStack() as stack:
1167+
stack.enter_context(temp_registered(B, resolve_custom_strategy_for_b))
1168+
stack.enter_context(temp_registered(C, st.builds(C)))
1169+
1170+
# C's strategy can be used for A, but B's cannot because its function
1171+
# only returns a strategy for requests for exactly B.
1172+
assert_all_examples(st.from_type(A), lambda example: type(example) == C)
1173+
assert_all_examples(st.from_type(B), lambda example: example is sentinel)
1174+
assert_all_examples(st.from_type(C), lambda example: type(example) == C)

hypothesis-python/tests/cover/test_targeting.py

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -102,21 +102,3 @@ def test_cannot_target_default_label_twice(_):
102102
target(0.0)
103103
with pytest.raises(InvalidArgument):
104104
target(1.0)
105-
106-
107-
@given(st.lists(st.integers()), st.none())
108-
def test_targeting_with_following_empty(ls, n):
109-
# This exercises some logic in the optimiser that prevents it from trying
110-
# to mutate empty examples at the end of the test case.
111-
target(float(len(ls)))
112-
113-
114-
@given(
115-
st.tuples(
116-
*([st.none()] * 10 + [st.integers()] + [st.none()] * 10 + [st.integers()])
117-
)
118-
)
119-
def test_targeting_with_many_empty(_):
120-
# This exercises some logic in the optimiser that prevents it from trying
121-
# to mutate empty examples in the middle of the test case.
122-
target(1.0)

0 commit comments

Comments
 (0)