Skip to content

Commit

Permalink
Merge pull request #3769 from h4l/optional-strategy-function
Browse files Browse the repository at this point in the history
  • Loading branch information
Zac-HD authored Oct 15, 2023
2 parents 9606972 + aad4098 commit 56af52a
Show file tree
Hide file tree
Showing 21 changed files with 242 additions and 75 deletions.
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ their individual contributions.
* `Gregory Petrosyan <https://github.com/flyingmutant>`_
* `Grzegorz Zieba <https://github.com/gzaxel>`_ ([email protected])
* `Grigorios Giannakopoulos <https://github.com/grigoriosgiann>`_
* `Hal Blackburn <https://github.com/h4l>`_
* `Hugo van Kemenade <https://github.com/hugovk>`_
* `Humberto Rocha <https://github.com/humrochagf>`_
* `Ilya Lebedev <https://github.com/melevir>`_ ([email protected])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ some point). If we have zero, that's a draw. If we have one, that's a
victory.

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

Fortunately, we don't have to because Hypothesis can do it for us!
Expand Down
2 changes: 1 addition & 1 deletion brand/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Colour palette in GIMP format

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

The ``hypothesis.gpl`` file should be copied or imported to the appropriate
location on your filesystem. For example:
Expand Down
5 changes: 5 additions & 0 deletions hypothesis-python/RELEASE.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
RELEASE_TYPE: minor

This release allows strategy-generating functions registered with
:func:`~hypothesis.strategies.register_type_strategy` to conditionally not
return a strategy, by returning :data:`NotImplemented` (:issue:`3767`).
2 changes: 1 addition & 1 deletion hypothesis-python/examples/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def get_discount_price(self, discount_percentage: float):
return self.price * (discount_percentage / 100)


# The @given decorater generates examples for us!
# The @given decorator generates examples for us!
@given(
price=st.floats(min_value=0, allow_nan=False, allow_infinity=False),
discount_percentage=st.floats(
Expand Down
34 changes: 25 additions & 9 deletions hypothesis-python/src/hypothesis/strategies/_internal/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1235,6 +1235,8 @@ def as_strategy(strat_or_callable, thing):
strategy = strat_or_callable(thing)
else:
strategy = strat_or_callable
if strategy is NotImplemented:
return NotImplemented
if not isinstance(strategy, SearchStrategy):
raise ResolutionFailed(
f"Error: {thing} was registered for {nicerepr(strat_or_callable)}, "
Expand Down Expand Up @@ -1277,7 +1279,9 @@ def from_type_guarded(thing):
# Check if we have an explicitly registered strategy for this thing,
# resolve it so, and otherwise resolve as for the base type.
if thing in types._global_type_lookup:
return as_strategy(types._global_type_lookup[thing], thing)
strategy = as_strategy(types._global_type_lookup[thing], thing)
if strategy is not NotImplemented:
return strategy
return _from_type(thing.__supertype__)
# Unions are not instances of `type` - but we still want to resolve them!
if types.is_a_union(thing):
Expand All @@ -1287,7 +1291,9 @@ def from_type_guarded(thing):
# They are represented as instances like `~T` when they come here.
# We need to work with their type instead.
if isinstance(thing, TypeVar) and type(thing) in types._global_type_lookup:
return as_strategy(types._global_type_lookup[type(thing)], thing)
strategy = as_strategy(types._global_type_lookup[type(thing)], thing)
if strategy is not NotImplemented:
return strategy
if not types.is_a_type(thing):
if isinstance(thing, str):
# See https://github.com/HypothesisWorks/hypothesis/issues/3016
Expand All @@ -1312,7 +1318,9 @@ def from_type_guarded(thing):
# convert empty results into an explicit error.
try:
if thing in types._global_type_lookup:
return as_strategy(types._global_type_lookup[thing], thing)
strategy = as_strategy(types._global_type_lookup[thing], thing)
if strategy is not NotImplemented:
return strategy
except TypeError: # pragma: no cover
# This is due to a bizarre divergence in behaviour under Python 3.9.0:
# typing.Callable[[], foo] has __args__ = (foo,) but collections.abc.Callable
Expand Down Expand Up @@ -1372,11 +1380,16 @@ def from_type_guarded(thing):
# type. For example, `Number -> integers() | floats()`, but bools() is
# not included because bool is a subclass of int as well as Number.
strategies = [
as_strategy(v, thing)
for k, v in sorted(types._global_type_lookup.items(), key=repr)
if isinstance(k, type)
and issubclass(k, thing)
and sum(types.try_issubclass(k, typ) for typ in types._global_type_lookup) == 1
s
for s in (
as_strategy(v, thing)
for k, v in sorted(types._global_type_lookup.items(), key=repr)
if isinstance(k, type)
and issubclass(k, thing)
and sum(types.try_issubclass(k, typ) for typ in types._global_type_lookup)
== 1
)
if s is not NotImplemented
]
if any(not s.is_empty for s in strategies):
return one_of(strategies)
Expand Down Expand Up @@ -2142,7 +2155,10 @@ def register_type_strategy(
for an argument with a default value.
``strategy`` may be a search strategy, or a function that takes a type and
returns a strategy (useful for generic types).
returns a strategy (useful for generic types). The function may return
:data:`NotImplemented` to conditionally not provide a strategy for the type
(the type will still be resolved by other methods, if possible, as if the
function was not registered).
Note that you may not register a parametrised generic type (such as
``MyCollection[int]``) directly, because the resolution logic does not
Expand Down
18 changes: 15 additions & 3 deletions hypothesis-python/src/hypothesis/strategies/_internal/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,9 +444,13 @@ def from_typing_type(thing):
mapping.pop(t)
# Sort strategies according to our type-sorting heuristic for stable output
strategies = [
v if isinstance(v, st.SearchStrategy) else v(thing)
for k, v in sorted(mapping.items(), key=lambda kv: type_sorting_key(kv[0]))
if sum(try_issubclass(k, T) for T in mapping) == 1
s
for s in (
v if isinstance(v, st.SearchStrategy) else v(thing)
for k, v in sorted(mapping.items(), key=lambda kv: type_sorting_key(kv[0]))
if sum(try_issubclass(k, T) for T in mapping) == 1
)
if s != NotImplemented
]
empty = ", ".join(repr(s) for s in strategies if s.is_empty)
if empty or not strategies:
Expand Down Expand Up @@ -484,6 +488,14 @@ def _networks(bits):
# As a general rule, we try to limit this to scalars because from_type()
# would have to decide on arbitrary collection elements, and we'd rather
# not (with typing module generic types and some builtins as exceptions).
#
# Strategy Callables may return NotImplemented, which should be treated in the
# same way as if the type was not registered.
#
# Note that NotImplemented cannot be typed in Python 3.8 because there's no type
# exposed for it, and NotImplemented itself is typed as Any so that it can be
# returned without being listed in a function signature:
# https://github.com/python/mypy/issues/6710#issuecomment-485580032
_global_type_lookup: typing.Dict[
type, typing.Union[st.SearchStrategy, typing.Callable[[type], st.SearchStrategy]]
] = {
Expand Down
2 changes: 1 addition & 1 deletion hypothesis-python/tests/array_api/test_arrays.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ def test_generate_unique_arrays_without_fill(xp, xps):
Covers the collision-related branches for fully dense unique arrays.
Choosing 25 of 256 possible values means we're almost certain to see
colisions thanks to the birthday paradox, but finding unique values should
collisions thanks to the birthday paradox, but finding unique values should
still be easy.
"""
skip_on_missing_unique_values(xp)
Expand Down
119 changes: 119 additions & 0 deletions hypothesis-python/tests/cover/test_lookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import abc
import builtins
import collections
import contextlib
import datetime
import enum
import inspect
Expand All @@ -21,6 +22,7 @@
import sys
import typing
import warnings
from dataclasses import dataclass
from inspect import signature
from numbers import Real

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


def test_typevars_can_be_resolved_conditionally():
sentinel = object()
A = typing.TypeVar("A")
B = typing.TypeVar("B")

def resolve_type_var(thing):
assert thing in (A, B)
if thing == A:
return st.just(sentinel)
return NotImplemented

with temp_registered(typing.TypeVar, resolve_type_var):
assert st.from_type(A).example() is sentinel
# We've re-defined the default TypeVar resolver, so there is no fallback.
# This causes the lookup to fail.
with pytest.raises(InvalidArgument):
st.from_type(B).example()


def annotated_func(a: int, b: int = 2, *, c: int, d: int = 4):
return a + b + c + d

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


@pytest.mark.parametrize("is_handled", [True, False])
def test_resolves_NewType_conditionally(is_handled):
sentinel = object()
typ = typing.NewType("T", int)

def resolve_custom_strategy(thing):
assert thing is typ
if is_handled:
return st.just(sentinel)
return NotImplemented

with temp_registered(typ, resolve_custom_strategy):
if is_handled:
assert st.from_type(typ).example() is sentinel
else:
assert isinstance(st.from_type(typ).example(), int)


E = enum.Enum("E", "a b c")


Expand Down Expand Up @@ -802,6 +841,58 @@ def test_supportsop_types_support_protocol(protocol, data):
assert issubclass(type(value), protocol)


@pytest.mark.parametrize("restrict_custom_strategy", [True, False])
def test_generic_aliases_can_be_conditionally_resolved_by_registered_function(
restrict_custom_strategy,
):
# Check that a custom strategy function may provide no strategy for a
# generic alias request like Container[T]. We test this under two scenarios:
# - where CustomContainer CANNOT be generated from requests for Container[T]
# (only for requests for exactly CustomContainer[T])
# - where CustomContainer CAN be generated from requests for Container[T]
T = typing.TypeVar("T")

@dataclass
class CustomContainer(typing.Container[T]):
content: T

def __contains__(self, value: object) -> bool:
return self.content == value

def get_custom_container_strategy(thing):
if restrict_custom_strategy and typing.get_origin(thing) != CustomContainer:
return NotImplemented
return st.builds(
CustomContainer, content=st.from_type(typing.get_args(thing)[0])
)

with temp_registered(CustomContainer, get_custom_container_strategy):

def is_custom_container_with_str(example):
return isinstance(example, CustomContainer) and isinstance(
example.content, str
)

def is_non_custom_container(example):
return isinstance(example, typing.Container) and not isinstance(
example, CustomContainer
)

assert_all_examples(
st.from_type(CustomContainer[str]), is_custom_container_with_str
)
# If the strategy function is restricting, it doesn't return a strategy
# for requests for Container[...], so it's never generated. When not
# restricting, it is generated.
if restrict_custom_strategy:
assert_all_examples(
st.from_type(typing.Container[str]), is_non_custom_container
)
else:
find_any(st.from_type(typing.Container[str]), is_custom_container_with_str)
find_any(st.from_type(typing.Container[str]), is_non_custom_container)


@pytest.mark.parametrize(
"protocol, typ",
[
Expand Down Expand Up @@ -1053,3 +1144,31 @@ def f(x: int):
msg = "@no_type_check decorator prevented Hypothesis from inferring a strategy"
with pytest.raises(TypeError, match=msg):
st.builds(f).example()


def test_custom_strategy_function_resolves_types_conditionally():
sentinel = object()

class A:
pass

class B(A):
pass

class C(A):
pass

def resolve_custom_strategy_for_b(thing):
if thing == B:
return st.just(sentinel)
return NotImplemented

with contextlib.ExitStack() as stack:
stack.enter_context(temp_registered(B, resolve_custom_strategy_for_b))
stack.enter_context(temp_registered(C, st.builds(C)))

# C's strategy can be used for A, but B's cannot because its function
# only returns a strategy for requests for exactly B.
assert_all_examples(st.from_type(A), lambda example: type(example) == C)
assert_all_examples(st.from_type(B), lambda example: example is sentinel)
assert_all_examples(st.from_type(C), lambda example: type(example) == C)
18 changes: 0 additions & 18 deletions hypothesis-python/tests/cover/test_targeting.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,21 +102,3 @@ def test_cannot_target_default_label_twice(_):
target(0.0)
with pytest.raises(InvalidArgument):
target(1.0)


@given(st.lists(st.integers()), st.none())
def test_targeting_with_following_empty(ls, n):
# This exercises some logic in the optimiser that prevents it from trying
# to mutate empty examples at the end of the test case.
target(float(len(ls)))


@given(
st.tuples(
*([st.none()] * 10 + [st.integers()] + [st.none()] * 10 + [st.integers()])
)
)
def test_targeting_with_many_empty(_):
# This exercises some logic in the optimiser that prevents it from trying
# to mutate empty examples in the middle of the test case.
target(1.0)
33 changes: 30 additions & 3 deletions hypothesis-python/tests/cover/test_type_lookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,19 @@ def test_lookup_keys_are_types():
assert "int" not in types._global_type_lookup


def test_lookup_values_are_strategies():
@pytest.mark.parametrize(
"typ, not_a_strategy",
[
(int, 42), # Values must be strategies
# Can't register NotImplemented directly, even though strategy functions
# can return it.
(int, NotImplemented),
],
)
def test_lookup_values_are_strategies(typ, not_a_strategy):
with pytest.raises(InvalidArgument):
st.register_type_strategy(int, 42)
assert 42 not in types._global_type_lookup.values()
st.register_type_strategy(typ, not_a_strategy)
assert not_a_strategy not in types._global_type_lookup.values()


@pytest.mark.parametrize("typ", sorted(types_with_core_strat, key=str))
Expand Down Expand Up @@ -147,6 +156,24 @@ def test_custom_type_resolution_with_function_non_strategy():
st.from_type(ParentUnknownType).example()


@pytest.mark.parametrize("strategy_returned", [True, False])
def test_conditional_type_resolution_with_function(strategy_returned):
sentinel = object()

def resolve_strategy(thing):
assert thing == UnknownType
if strategy_returned:
return st.just(sentinel)
return NotImplemented

with temp_registered(UnknownType, resolve_strategy):
if strategy_returned:
assert st.from_type(UnknownType).example() is sentinel
else:
with pytest.raises(ResolutionFailed):
st.from_type(UnknownType).example()


def test_errors_if_generic_resolves_empty():
with temp_registered(UnknownType, lambda _: st.nothing()):
fails_1 = st.from_type(UnknownType)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def test_bound_correct_forward_ref(built):
assert isinstance(built, int)


# Alises:
# Aliases:

_Alias = TypeVar("_Alias ", bound="OurAlias")

Expand Down
Loading

0 comments on commit 56af52a

Please sign in to comment.