Skip to content

Commit

Permalink
Allow strategy-generating functions to not provide strategies
Browse files Browse the repository at this point in the history
Custom type strategies registered with register_type_strategy() can now
choose not to provide a strategy for a type by returning NotImplemented
instead of a strategy.

This allows custom strategies to only provide a custom strategies for
certain subtypes of a type, or certain generic parameters, while
deferring to another strategy for the unhandled cases.
  • Loading branch information
h4l committed Oct 15, 2023
1 parent da3d383 commit 273a103
Show file tree
Hide file tree
Showing 6 changed files with 194 additions and 15 deletions.
1 change: 1 addition & 0 deletions AUTHORS.rst
Expand Up @@ -73,6 +73,7 @@ their individual contributions.
* `Gregory Petrosyan <https://github.com/flyingmutant>`_
* `Grzegorz Zieba <https://github.com/gzaxel>`_ (g.zieba@erax.pl)
* `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>`_ (melevir@gmail.com)
Expand Down
5 changes: 5 additions & 0 deletions hypothesis-python/RELEASE.rst
@@ -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`).
34 changes: 25 additions & 9 deletions hypothesis-python/src/hypothesis/strategies/_internal/core.py
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
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
118 changes: 118 additions & 0 deletions hypothesis-python/tests/cover/test_lookup.py
Expand Up @@ -11,6 +11,8 @@
import abc
import builtins
import collections
import contextlib
from dataclasses import dataclass
import datetime
import enum
import inspect
Expand Down Expand Up @@ -371,6 +373,24 @@ 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
with pytest.raises(ResolutionFailed):
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 +485,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 +840,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 +1143,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)
33 changes: 30 additions & 3 deletions hypothesis-python/tests/cover/test_type_lookup.py
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

0 comments on commit 273a103

Please sign in to comment.