Skip to content

Commit

Permalink
Merge pull request #3668 from jobh/warn-on-small-searchspace
Browse files Browse the repository at this point in the history
  • Loading branch information
Zac-HD authored Jun 4, 2023
2 parents c58cb0f + 04ec679 commit 591dcbe
Show file tree
Hide file tree
Showing 11 changed files with 99 additions and 30 deletions.
6 changes: 6 additions & 0 deletions hypothesis-python/RELEASE.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
RELEASE_TYPE: minor

Warn in :func:`~hypothesis.strategies.from_type` if the inferred strategy
has no variation (always returning default instances). Also handles numpy
data types by calling :func:`~hypothesis.extra.numpy.from_dtype` on the
corresponding dtype, thus ensuring proper variation for these types.
5 changes: 5 additions & 0 deletions hypothesis-python/src/hypothesis/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,8 @@ class RewindRecursive(Exception):

def __init__(self, target):
self.target = target


class SmallSearchSpaceWarning(HypothesisWarning):
"""Indicates that an inferred strategy does not span the search space
in a meaningful way, for example by only creating default instances."""
8 changes: 5 additions & 3 deletions hypothesis-python/src/hypothesis/extra/ghostwriter.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
import re
import sys
import types
import warnings
from collections import OrderedDict, defaultdict
from itertools import permutations, zip_longest
from keyword import iskeyword
Expand Down Expand Up @@ -107,7 +108,7 @@
import black

from hypothesis import Verbosity, find, settings, strategies as st
from hypothesis.errors import InvalidArgument
from hypothesis.errors import InvalidArgument, SmallSearchSpaceWarning
from hypothesis.internal.compat import get_args, get_origin, get_type_hints
from hypothesis.internal.reflection import get_signature, is_mock
from hypothesis.internal.validation import check_type
Expand Down Expand Up @@ -608,12 +609,13 @@ def _imports_for_strategy(strategy):
for imp in _imports_for_object(arg)
}
elif _get_module(strategy.function).startswith("hypothesis.extra."):
return {(_get_module(strategy.function), strategy.function.__name__)}
module = _get_module(strategy.function).replace("._array_helpers", ".numpy")
return {(module, strategy.function.__name__)}

imports = set()
strategy = unwrap_strategies(strategy)
with warnings.catch_warnings():
warnings.simplefilter("ignore", SmallSearchSpaceWarning)
strategy = unwrap_strategies(strategy)

# Get imports for s.map(f), s.filter(f), s.flatmap(f), including both s and f
if isinstance(strategy, MappedSearchStrategy):
Expand Down
60 changes: 44 additions & 16 deletions hypothesis-python/src/hypothesis/strategies/_internal/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import string
import sys
import typing
import warnings
from decimal import Context, Decimal, localcontext
from fractions import Fraction
from functools import lru_cache, reduce
Expand Down Expand Up @@ -47,7 +48,12 @@

from hypothesis._settings import note_deprecation
from hypothesis.control import cleanup, current_build_context, note
from hypothesis.errors import InvalidArgument, ResolutionFailed, RewindRecursive
from hypothesis.errors import (
InvalidArgument,
ResolutionFailed,
RewindRecursive,
SmallSearchSpaceWarning,
)
from hypothesis.internal.cathetus import cathetus
from hypothesis.internal.charmap import as_general_categories
from hypothesis.internal.compat import (
Expand All @@ -71,6 +77,7 @@
get_signature,
is_first_param_referenced_in_function,
nicerepr,
repr_call,
required_args,
)
from hypothesis.internal.validation import (
Expand Down Expand Up @@ -988,7 +995,7 @@ def builds(

@cacheable
@defines_strategy(never_lazy=True)
def from_type(thing: Type[Ex], *, force_defer: bool = False) -> SearchStrategy[Ex]:
def from_type(thing: Type[Ex]) -> SearchStrategy[Ex]:
"""Looks up the appropriate search strategy for the given type.
``from_type`` is used internally to fill in missing arguments to
Expand Down Expand Up @@ -1040,11 +1047,15 @@ def everything_except(excluded_types):
This is useful when writing tests which check that invalid input is
rejected in a certain way.
"""
if not force_defer:
try:
try:
with warnings.catch_warnings():
warnings.simplefilter("error")
return _from_type(thing, [])
except Exception:
pass
except Exception:
return _from_type_deferred(thing)


def _from_type_deferred(thing: Type[Ex]) -> SearchStrategy[Ex]:
# This tricky little dance is because we want to show the repr of the actual
# underlying strategy wherever possible, as a form of user education, but
# would prefer to fall back to the default "from_type(...)" repr instead of
Expand Down Expand Up @@ -1079,13 +1090,13 @@ def as_strategy(strat_or_callable, thing, final=True):
raise ResolutionFailed(f"Error: {thing!r} resolved to an empty strategy")
return strategy

def defer_recursion(thing, producer):
def from_type_guarded(thing):
"""Returns the result of producer, or ... if recursion on thing is encountered"""
if thing in recurse_guard:
raise RewindRecursive(thing)
recurse_guard.append(thing)
try:
return producer()
return _from_type(thing, recurse_guard)
except RewindRecursive as rr:
if rr.target != thing:
raise
Expand Down Expand Up @@ -1158,9 +1169,9 @@ def defer_recursion(thing, producer):
raise InvalidArgument(
f"`{k}: {v.__name__}` is not a valid type annotation"
) from None
anns[k] = defer_recursion(v, lambda: _from_type(v, recurse_guard))
anns[k] = from_type_guarded(v)
if anns[k] is ...:
anns[k] = from_type(v, force_defer=True)
anns[k] = _from_type_deferred(v)
if (
(not anns)
and thing.__annotations__
Expand Down Expand Up @@ -1204,7 +1215,16 @@ def defer_recursion(thing, producer):
# may be able to fall back on type annotations.
if issubclass(thing, enum.Enum):
return sampled_from(thing)
# Handle numpy types. If numpy is not imported, the type cannot be numpy related.
if "numpy" in sys.modules:
import numpy as np

if issubclass(thing, np.generic):
dtype = np.dtype(thing)
if dtype.kind not in "OV":
from hypothesis.extra.numpy import from_dtype

return from_dtype(dtype)
# Finally, try to build an instance by calling the type object. Unlike builds(),
# this block *does* try to infer strategies for arguments with default values.
# That's because of the semantic different; builds() -> "call this with ..."
Expand All @@ -1227,19 +1247,27 @@ def defer_recursion(thing, producer):
hints = get_type_hints(thing)
params = get_signature(thing).parameters
except Exception:
return builds(thing)
params = {} # type: ignore
kwargs = {}
for k, p in params.items():
if (
k in hints
and k != "return"
and p.default is not Parameter.empty
and p.kind in (Parameter.POSITIONAL_OR_KEYWORD, Parameter.KEYWORD_ONLY)
):
kwargs[k] = defer_recursion(
hints[k],
lambda: just(p.default) | _from_type(hints[k], recurse_guard),
)
kwargs[k] = from_type_guarded(hints[k])
if p.default is not Parameter.empty and kwargs[k] is not ...:
kwargs[k] = just(p.default) | kwargs[k]
if params and not kwargs:
from_type_repr = repr_call(from_type, (thing,), {})
builds_repr = repr_call(builds, (thing,), {})
warnings.warn(
f"{from_type_repr} resolved to {builds_repr}, because we could not "
"find any (non-varargs) arguments. Use st.register_type_strategy() "
"to resolve to a strategy which can generate more than one value, "
"or silence this warning.",
SmallSearchSpaceWarning,
)
return builds(thing, **kwargs)
# And if it's an abstract type, we'll resolve to a union of subclasses instead.
subclasses = thing.__subclasses__()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,7 @@ def _networks(bits):
filter: st.builds(filter, st.just(lambda _: None), st.just(())),
map: st.builds(map, st.just(lambda _: None), st.just(())),
reversed: st.builds(reversed, st.just(())),
property: st.builds(property, st.just(lambda _: None)),
classmethod: st.builds(classmethod, st.just(lambda self: self)),
staticmethod: st.builds(staticmethod, st.just(lambda self: self)),
super: st.builds(super, st.from_type(type)),
Expand Down
8 changes: 5 additions & 3 deletions hypothesis-python/tests/cover/test_annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import pytest

from hypothesis import given, strategies as st
from hypothesis.errors import SmallSearchSpaceWarning
from hypothesis.internal.reflection import (
convert_positional_arguments,
define_function_signature,
Expand Down Expand Up @@ -124,6 +125,7 @@ def test_attrs_inference_builds(c):
pass


@given(st.from_type(Inferrables))
def test_attrs_inference_from_type(c):
pass
def test_attrs_inference_from_type():
s = st.from_type(Inferrables)
with pytest.warns(SmallSearchSpaceWarning):
s.example()
20 changes: 16 additions & 4 deletions hypothesis-python/tests/cover/test_lookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@
import string
import sys
import typing
import warnings
from inspect import signature
from numbers import Real

import pytest

from hypothesis import HealthCheck, assume, given, settings, strategies as st
from hypothesis.errors import InvalidArgument, ResolutionFailed
from hypothesis.errors import InvalidArgument, ResolutionFailed, SmallSearchSpaceWarning
from hypothesis.internal.compat import PYPY, get_type_hints
from hypothesis.internal.reflection import get_pretty_function_description
from hypothesis.strategies import from_type
Expand Down Expand Up @@ -562,12 +563,19 @@ def __init__(self, nxt: typing.Optional["MyList"] = None):
def __repr__(self):
return f"MyList({self.nxt})"

def __eq__(self, other):
return type(self) == type(other) and self.nxt == other.nxt


@given(lst=st.from_type(MyList))
def test_resolving_recursive_type_with_defaults(lst):
assert isinstance(lst, MyList)


def test_recursive_type_with_defaults_minimizes_to_defaults():
assert minimal(from_type(MyList), lambda ex: True) == MyList()


class A:
def __init__(self, nxt: typing.Optional["B"]):
self.nxt = nxt
Expand Down Expand Up @@ -962,13 +970,17 @@ def test_from_type_can_be_default_or_annotation():

@pytest.mark.parametrize("t", BUILTIN_TYPES, ids=lambda t: t.__name__)
def test_resolves_builtin_types(t):
v = st.from_type(t).example()
with warnings.catch_warnings():
warnings.simplefilter("ignore", SmallSearchSpaceWarning)
v = st.from_type(t).example()
assert isinstance(v, t)


@pytest.mark.parametrize("t", BUILTIN_TYPES, ids=lambda t: t.__name__)
def test_resolves_forwardrefs_to_builtin_types(t):
v = st.from_type(typing.ForwardRef(t.__name__)).example()
@given(data=st.data())
def test_resolves_forwardrefs_to_builtin_types(t, data):
s = st.from_type(typing.ForwardRef(t.__name__))
v = data.draw(s)
assert isinstance(v, t)


Expand Down
4 changes: 3 additions & 1 deletion hypothesis-python/tests/cover/test_type_lookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
HypothesisDeprecationWarning,
InvalidArgument,
ResolutionFailed,
SmallSearchSpaceWarning,
)
from hypothesis.internal.compat import get_type_hints
from hypothesis.internal.reflection import get_pretty_function_description
Expand Down Expand Up @@ -204,7 +205,8 @@ def test_uninspectable_builds():

def test_uninspectable_from_type():
with pytest.raises(TypeError, match="object is not callable"):
st.from_type(BrokenClass).example()
with pytest.warns(SmallSearchSpaceWarning):
st.from_type(BrokenClass).example()


def _check_instances(t):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ from example_code.future_annotations import CustomClass
from hypothesis import given, strategies as st


@given(c1=st.builds(CustomClass), c2=st.one_of(st.none(), st.builds(CustomClass)))
@given(
c1=st.builds(CustomClass, number=st.integers()),
c2=st.one_of(st.none(), st.builds(CustomClass, number=st.integers())),
)
def test_fuzz_add_custom_classes(
c1: example_code.future_annotations.CustomClass,
c2: typing.Union[example_code.future_annotations.CustomClass, None],
Expand Down
4 changes: 2 additions & 2 deletions hypothesis-python/tests/ghostwriter/test_expected_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,6 @@ def test_ghostwriter_example_outputs(update_recorded_outputs, data):
def test_ghostwriter_on_hypothesis(update_recorded_outputs):
actual = ghostwriter.magic(hypothesis).replace("Strategy[+Ex]", "Strategy")
expected = get_recorded("hypothesis_module_magic", actual * update_recorded_outputs)
if sys.version_info[:2] < (3, 10):
assert actual == expected
# if sys.version_info[:2] < (3, 10):
# assert actual == expected
exec(expected, {"not_set": not_set})
8 changes: 8 additions & 0 deletions hypothesis-python/tests/numpy/test_from_dtype.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from hypothesis.errors import InvalidArgument
from hypothesis.extra import numpy as nps
from hypothesis.internal.floats import width_smallest_normals
from hypothesis.strategies import from_type
from hypothesis.strategies._internal import SearchStrategy

from tests.common.debug import assert_no_examples, find_any
Expand Down Expand Up @@ -283,3 +284,10 @@ def condition(n):
find_any(strat, condition)
else:
assert_no_examples(strat, condition)


@pytest.mark.parametrize("dtype", STANDARD_TYPES)
def test_resolves_and_varies_numpy_type(dtype):
# Check that we find an instance that is not equal to the default
x = find_any(from_type(dtype.type), lambda x: x != type(x)())
assert isinstance(x, dtype.type)

0 comments on commit 591dcbe

Please sign in to comment.