diff --git a/hypothesis/RELEASE.rst b/hypothesis/RELEASE.rst new file mode 100644 index 0000000000..157b3c0ef1 --- /dev/null +++ b/hypothesis/RELEASE.rst @@ -0,0 +1,3 @@ +RELEASE_TYPE: patch + +Add an internal helper for use in future work. diff --git a/hypothesis/src/hypothesis/errors.py b/hypothesis/src/hypothesis/errors.py index c7de51eef4..794dec0b5e 100644 --- a/hypothesis/src/hypothesis/errors.py +++ b/hypothesis/src/hypothesis/errors.py @@ -66,6 +66,24 @@ class ChoiceTooLarge(HypothesisException): """An internal error raised by choice_from_index.""" +class CannotInvert(HypothesisException): + """Internal error raised by SearchStrategy._invert.""" + + +class CannotInvertYet(CannotInvert): + """ + Internal error raised by SearchStrategy._invert. Raised when a value could in theory + be inverted, we just haven't implemented the inversion yet. + """ + + +class DefinitelyCannotInvert(CannotInvert): + """ + Internal error raised by SearchStrategy._invert. Raised when we are certain a value + cannot be inverted. + """ + + class Flaky(_Trimmable): """ Base class for indeterministic failures. Usually one of the more diff --git a/hypothesis/src/hypothesis/internal/conjecture/junkdrawer.py b/hypothesis/src/hypothesis/internal/conjecture/junkdrawer.py index 0463b9b9b0..2314bd5aeb 100644 --- a/hypothesis/src/hypothesis/internal/conjecture/junkdrawer.py +++ b/hypothesis/src/hypothesis/internal/conjecture/junkdrawer.py @@ -34,10 +34,53 @@ from sortedcontainers import SortedList from hypothesis.errors import HypothesisWarning +from hypothesis.internal.floats import float_to_int T = TypeVar("T") +def deep_equal(a: Any, b: Any) -> bool: + """ + Equivalent to == but, handles float comparisons correctly. + """ + + if type(a) is not type(b): + return False + if isinstance(a, float): + return float_to_int(a) == float_to_int(b) + if isinstance(a, (list, tuple)): + return len(a) == len(b) and all( + deep_equal(x, y) for x, y in zip(a, b, strict=True) + ) + if isinstance(a, dict): + # note that because we need custom equality, dict and set comparisons + # turn from O(n) to O(n^2). This is unfortunate and maybe not worth the cost. + if len(a) != len(b): + return False + remaining = list(b.items()) + for ka, va in a.items(): + for i, (kb, vb) in enumerate(remaining): + if deep_equal(ka, kb) and deep_equal(va, vb): + del remaining[i] + break + else: + return False + return True + if isinstance(a, (set, frozenset)): + if len(a) != len(b): + return False + remaining = list(b) + for x in a: + for i, y in enumerate(remaining): + if deep_equal(x, y): + del remaining[i] + break + else: + return False + return True + return a == b + + def replace_all( ls: Sequence[T], replacements: Iterable[tuple[int, int, Sequence[T]]], diff --git a/hypothesis/src/hypothesis/internal/conjecture/utils.py b/hypothesis/src/hypothesis/internal/conjecture/utils.py index e916fe2e3f..dee1bd42a8 100644 --- a/hypothesis/src/hypothesis/internal/conjecture/utils.py +++ b/hypothesis/src/hypothesis/internal/conjecture/utils.py @@ -25,6 +25,7 @@ from hypothesis.internal.lambda_sources import _function_key if TYPE_CHECKING: + from hypothesis.internal.conjecture.choice import ChoiceT from hypothesis.internal.conjecture.data import ConjectureData @@ -344,6 +345,19 @@ def reject(self, why: str | None = None) -> None: self.force_stop = True +class invert_many: + def __init__(self, min_size: int, max_size: int | float) -> None: + self._variable_size = min_size != max_size + + def more(self) -> tuple["ChoiceT", ...]: + return (True,) if self._variable_size else () + + def done(self) -> tuple["ChoiceT", ...]: + if self._variable_size: + return (False,) + return () + + SMALLEST_POSITIVE_FLOAT: float = next_up(0.0) or sys.float_info.min diff --git a/hypothesis/src/hypothesis/strategies/_internal/collections.py b/hypothesis/src/hypothesis/strategies/_internal/collections.py index 97de24f249..1b4a5d78d8 100644 --- a/hypothesis/src/hypothesis/strategies/_internal/collections.py +++ b/hypothesis/src/hypothesis/strategies/_internal/collections.py @@ -15,8 +15,9 @@ from hypothesis import strategies as st from hypothesis.control import current_build_context -from hypothesis.errors import InvalidArgument +from hypothesis.errors import CannotInvertYet, DefinitelyCannotInvert, InvalidArgument from hypothesis.internal.conjecture import utils as cu +from hypothesis.internal.conjecture.choice import ChoiceT from hypothesis.internal.conjecture.data import ConjectureData from hypothesis.internal.conjecture.engine import BUFFER_SIZE from hypothesis.internal.conjecture.junkdrawer import LazySequenceCopy @@ -86,6 +87,17 @@ def do_draw(self, data: ConjectureData) -> tuple[Ex, ...]: ) return result + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + if not isinstance(value, tuple) or len(value) != len(self.element_strategies): + raise DefinitelyCannotInvert( + f"{value!r} is not a tuple of the expected length" + ) + return tuple( + c + for s, element in zip(self.element_strategies, value, strict=True) + for c in s._invert(element) + ) + def calc_is_empty(self, recur: RecurT) -> bool: return any(recur(e) for e in self.element_strategies) @@ -226,6 +238,22 @@ def do_draw(self, data: ConjectureData) -> list[Ex]: result.append(data.draw(self.element_strategy)) return result + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + if not isinstance(value, list): + raise DefinitelyCannotInvert(f"{value!r} is not a list") + if not (self.min_size <= len(value) <= self.max_size): + raise DefinitelyCannotInvert( + f"len={len(value)} outside " + f"[{self.min_size}, {self.max_size!r}] for {self!r}" + ) + elements = cu.invert_many(self.min_size, self.max_size) + seq: list[ChoiceT] = [] + for v in value: + seq.extend(elements.more()) + seq.extend(self.element_strategy._invert(v)) + seq.extend(elements.done()) + return tuple(seq) + def __repr__(self) -> str: return ( f"{self.__class__.__name__}({self.element_strategy!r}, " @@ -290,6 +318,10 @@ def __init__( self.keys = keys self.tuple_suffixes = tuple_suffixes + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + # very annoying to invert + raise CannotInvertYet(f"cannot invert {self!r} (value={value!r})") + def do_draw(self, data: ConjectureData) -> list[Ex]: if self.element_strategy.is_empty: assert self.min_size == 0 diff --git a/hypothesis/src/hypothesis/strategies/_internal/core.py b/hypothesis/src/hypothesis/strategies/_internal/core.py index 9ef4a555c4..525e894751 100644 --- a/hypothesis/src/hypothesis/strategies/_internal/core.py +++ b/hypothesis/src/hypothesis/strategies/_internal/core.py @@ -9,6 +9,7 @@ # obtain one at https://mozilla.org/MPL/2.0/. import codecs +import dataclasses import enum import math import operator @@ -60,6 +61,8 @@ should_note, ) from hypothesis.errors import ( + CannotInvertYet, + DefinitelyCannotInvert, HypothesisSideeffectWarning, HypothesisWarning, InvalidArgument, @@ -81,7 +84,9 @@ get_type_hints, is_typed_named_tuple, ) +from hypothesis.internal.conjecture.choice import ChoiceT from hypothesis.internal.conjecture.data import ConjectureData +from hypothesis.internal.conjecture.junkdrawer import deep_equal from hypothesis.internal.conjecture.utils import ( calc_label_from_callable, calc_label_from_name, @@ -1067,6 +1072,28 @@ def calc_label(self) -> int: *[strat.label for strat in self.kwargs.values()], ) + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + if not self.args and not self.kwargs: + return () + if isinstance(self.target, type) and dataclasses.is_dataclass(self.target): + # builds(MyDataclass, ...) is inspectable: positional args map to + # fields in declaration order, kwargs map to fields by name. + if not isinstance(value, self.target): + raise DefinitelyCannotInvert( + f"{value!r} is not an instance of {self.target!r}" + ) + field_names = [f.name for f in dataclasses.fields(self.target)] + pairs = [ + *zip(field_names, self.args, strict=False), + *self.kwargs.items(), + ] + return tuple( + c for name, s in pairs for c in s._invert(getattr(value, name)) + ) + # there are a number of other special cases we could add here. we'll get to them + # in time. + raise CannotInvertYet(f"cannot invert {self!r} (value={value!r})") + def do_draw(self, data: ConjectureData) -> Ex: context = current_build_context() arg_labels: ArgLabelsT = {} @@ -1920,6 +1947,34 @@ def do_draw(self, data): result[i], result[j] = result[j], result[i] return result + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + if not isinstance(value, list) or len(value) != len(self.values): + raise DefinitelyCannotInvert( + f"{value!r} is not a list of the expected length" + ) + # Reverse the Fisher-Yates shuffle: walk the current intermediate + # array forward, and at each step pick j such that current[j] equals + # the target value at this index. + current = list(self.values) + seq: list[ChoiceT] = [] + for i, target in enumerate(value[:-1]): + for j in range(i, len(current)): + if deep_equal(current[j], target): + seq.append(j) + current[i], current[j] = current[j], current[i] + break + else: + raise DefinitelyCannotInvert( + f"{value!r} is not a permutation of {self.values!r}" + ) + # The last position is fixed by the previous swaps - verify the + # input is a valid permutation. + if current and not deep_equal(current[-1], value[-1]): + raise DefinitelyCannotInvert( + f"{value!r} is not a permutation of {self.values!r}" + ) + return tuple(seq) + @defines_strategy() def permutations(values: Sequence[T]) -> SearchStrategy[list[T]]: diff --git a/hypothesis/src/hypothesis/strategies/_internal/datetime.py b/hypothesis/src/hypothesis/strategies/_internal/datetime.py index 62d97b91fe..58d1cd10dd 100644 --- a/hypothesis/src/hypothesis/strategies/_internal/datetime.py +++ b/hypothesis/src/hypothesis/strategies/_internal/datetime.py @@ -16,8 +16,10 @@ from functools import cache, partial from importlib import resources from pathlib import Path +from typing import Any -from hypothesis.errors import InvalidArgument +from hypothesis.errors import DefinitelyCannotInvert, InvalidArgument +from hypothesis.internal.conjecture.choice import ChoiceT from hypothesis.internal.validation import check_type, check_valid_interval from hypothesis.strategies._internal.core import sampled_from from hypothesis.strategies._internal.misc import just, none, nothing @@ -159,6 +161,29 @@ def draw_naive_datetime_and_combine(self, data, tz): f"{self.max_value!r} with timezone from {self.tz_strat!r}." ) + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + if type(value) is not dt.datetime: + raise DefinitelyCannotInvert(f"{value!r} is not a datetime") + naive = value.replace(tzinfo=None) + if not (self.min_value <= naive <= self.max_value): + raise DefinitelyCannotInvert( + f"{value!r} outside [{self.min_value!r}, {self.max_value!r}]" + ) + if not self.allow_imaginary and datetime_does_not_exist(value): + raise DefinitelyCannotInvert(f"{value!r} is an imaginary datetime") + tz_inv = self.tz_strat._invert(value.tzinfo) + return ( + *tz_inv, + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + value.microsecond, + value.fold, + ) + @defines_strategy(force_reusable_values=True) def datetimes( @@ -230,6 +255,26 @@ def do_draw(self, data): tz = data.draw(self.tz_strat) return dt.time(**result, tzinfo=tz) + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + if type(value) is not dt.time: + raise DefinitelyCannotInvert(f"{value!r} is not a time") + naive = value.replace(tzinfo=None) + if not (self.min_value <= naive <= self.max_value): + raise DefinitelyCannotInvert( + f"{value!r} outside [{self.min_value!r}, {self.max_value!r}]" + ) + # draw_capped_multipart emits fold *before* tz_strat draws (because + # min_value=dt.time has fold), but do_draw draws tz AFTER. + tz_inv = self.tz_strat._invert(value.tzinfo) + return ( + value.hour, + value.minute, + value.second, + value.microsecond, + value.fold, + *tz_inv, + ) + @defines_strategy(force_reusable_values=True) def times( @@ -271,6 +316,15 @@ def do_draw(self, data): **draw_capped_multipart(data, self.min_value, self.max_value, DATENAMES) ) + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + if type(value) is not dt.date: + raise DefinitelyCannotInvert(f"{value!r} is not a date") + if not (self.min_value <= value <= self.max_value): + raise DefinitelyCannotInvert( + f"{value!r} outside [{self.min_value!r}, {self.max_value!r}]" + ) + return (value.year, value.month, value.day) + def filter(self, condition): if ( isinstance(condition, partial) @@ -343,6 +397,15 @@ def do_draw(self, data): high_bound = high_bound and val == high return dt.timedelta(**result) + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + if type(value) is not dt.timedelta: + raise DefinitelyCannotInvert(f"{value!r} is not a timedelta") + if not (self.min_value <= value <= self.max_value): + raise DefinitelyCannotInvert( + f"{value!r} outside [{self.min_value!r}, {self.max_value!r}]" + ) + return (value.days, value.seconds, value.microseconds) + @defines_strategy(force_reusable_values=True) def timedeltas( diff --git a/hypothesis/src/hypothesis/strategies/_internal/deferred.py b/hypothesis/src/hypothesis/strategies/_internal/deferred.py index 1688cf2009..e9f8ffbbb6 100644 --- a/hypothesis/src/hypothesis/strategies/_internal/deferred.py +++ b/hypothesis/src/hypothesis/strategies/_internal/deferred.py @@ -10,9 +10,11 @@ import inspect from collections.abc import Callable, Sequence +from typing import Any from hypothesis.configuration import check_sideeffect_during_initialization from hypothesis.errors import InvalidArgument +from hypothesis.internal.conjecture.choice import ChoiceT from hypothesis.internal.conjecture.data import ConjectureData from hypothesis.internal.reflection import get_pretty_function_description from hypothesis.strategies._internal.strategies import ( @@ -91,3 +93,6 @@ def __repr__(self) -> str: def do_draw(self, data: ConjectureData) -> Ex: return data.draw(self.wrapped_strategy) + + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + return self.wrapped_strategy._invert(value) diff --git a/hypothesis/src/hypothesis/strategies/_internal/lazy.py b/hypothesis/src/hypothesis/strategies/_internal/lazy.py index 2cbdeb9698..3a4f86118f 100644 --- a/hypothesis/src/hypothesis/strategies/_internal/lazy.py +++ b/hypothesis/src/hypothesis/strategies/_internal/lazy.py @@ -14,6 +14,7 @@ from weakref import WeakKeyDictionary from hypothesis.configuration import check_sideeffect_during_initialization +from hypothesis.internal.conjecture.choice import ChoiceT from hypothesis.internal.conjecture.data import ConjectureData from hypothesis.internal.reflection import ( convert_keyword_arguments, @@ -174,3 +175,6 @@ def __repr__(self) -> str: def do_draw(self, data: ConjectureData) -> Ex: return data.draw(self.wrapped_strategy) + + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + return self.wrapped_strategy._invert(value) diff --git a/hypothesis/src/hypothesis/strategies/_internal/misc.py b/hypothesis/src/hypothesis/strategies/_internal/misc.py index cbcfa32445..8b3490aeba 100644 --- a/hypothesis/src/hypothesis/strategies/_internal/misc.py +++ b/hypothesis/src/hypothesis/strategies/_internal/misc.py @@ -11,7 +11,10 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any, NoReturn +from hypothesis.errors import DefinitelyCannotInvert +from hypothesis.internal.conjecture.choice import ChoiceT from hypothesis.internal.conjecture.data import ConjectureData +from hypothesis.internal.conjecture.junkdrawer import deep_equal from hypothesis.internal.reflection import get_pretty_function_description from hypothesis.strategies._internal.strategies import ( Ex, @@ -63,6 +66,11 @@ def do_filtered_draw(self, data: ConjectureData) -> Ex | UniqueIdentifier: # we have exactly one value. (This also avoids drawing any data.) return self._transform(self.value) + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + if not deep_equal(self._transform(self.value), value): + raise DefinitelyCannotInvert(f"{value!r} is not produced by {self!r}") + return () + @defines_strategy(eager=True) def just(value: T) -> SearchStrategy[T]: @@ -134,5 +142,10 @@ class BooleansStrategy(SearchStrategy[bool]): def do_draw(self, data: ConjectureData) -> bool: return data.draw_boolean() + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + if not isinstance(value, bool): + raise DefinitelyCannotInvert(f"{value!r} is not a bool") + return (value,) + def __repr__(self) -> str: return "booleans()" diff --git a/hypothesis/src/hypothesis/strategies/_internal/numbers.py b/hypothesis/src/hypothesis/strategies/_internal/numbers.py index 307bb37359..68aba8dc4d 100644 --- a/hypothesis/src/hypothesis/strategies/_internal/numbers.py +++ b/hypothesis/src/hypothesis/strategies/_internal/numbers.py @@ -11,10 +11,11 @@ import math from decimal import Decimal from fractions import Fraction -from typing import Literal, cast +from typing import Any, Literal, cast from hypothesis.control import reject -from hypothesis.errors import InvalidArgument +from hypothesis.errors import DefinitelyCannotInvert, InvalidArgument +from hypothesis.internal.conjecture.choice import ChoiceT from hypothesis.internal.conjecture.data import ConjectureData from hypothesis.internal.filtering import ( get_float_predicate_bounds, @@ -30,6 +31,7 @@ next_down_normal, next_up, next_up_normal, + sign_aware_lte, width_smallest_normals, ) from hypothesis.internal.validation import ( @@ -85,6 +87,15 @@ def do_draw(self, data: ConjectureData) -> int: min_value=self.start, max_value=self.end, weights=weights ) + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + if not isinstance(value, int) or isinstance(value, bool): + raise DefinitelyCannotInvert(f"{value!r} is not an integer") + if self.start is not None and value < self.start: + raise DefinitelyCannotInvert(f"{value!r} is below min_value={self.start!r}") + if self.end is not None and value > self.end: + raise DefinitelyCannotInvert(f"{value!r} is above max_value={self.end!r}") + return (value,) + def filter(self, condition): if condition is math.isfinite: return self @@ -189,6 +200,26 @@ def do_draw(self, data: ConjectureData) -> float: smallest_nonzero_magnitude=self.smallest_nonzero_magnitude, ) + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + if type(value) is not float: + raise DefinitelyCannotInvert(f"{value!r} is not a float") + if math.isnan(value): + if not self.allow_nan: + raise DefinitelyCannotInvert(f"NaN not permitted by {self!r}") + return (value,) + if 0 < abs(value) < self.smallest_nonzero_magnitude: + raise DefinitelyCannotInvert( + f"{value!r} below smallest_nonzero_magnitude=" + f"{self.smallest_nonzero_magnitude!r}" + ) + if not sign_aware_lte(self.min_value, value) or not sign_aware_lte( + value, self.max_value + ): + raise DefinitelyCannotInvert( + f"{value!r} outside [{self.min_value!r}, {self.max_value!r}]" + ) + return (value,) + def filter(self, condition): # Handle a few specific weird cases. if condition is math.isfinite: @@ -526,3 +557,11 @@ def do_draw(self, data: ConjectureData) -> float: nan_bits = float_to_int(math.nan) mantissa_bits = data.draw_integer(0, 2**52 - 1) return int_to_float(sign_bit | nan_bits | mantissa_bits) + + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + if not isinstance(value, float) or not math.isnan(value): + raise DefinitelyCannotInvert(f"{value!r} is not NaN") + bits = float_to_int(value) + sign = bool(bits >> 63) + mantissa = bits & ((1 << 52) - 1) + return (sign, mantissa) diff --git a/hypothesis/src/hypothesis/strategies/_internal/recursive.py b/hypothesis/src/hypothesis/strategies/_internal/recursive.py index e507462b28..9a5cdd160d 100644 --- a/hypothesis/src/hypothesis/strategies/_internal/recursive.py +++ b/hypothesis/src/hypothesis/strategies/_internal/recursive.py @@ -15,6 +15,7 @@ from typing import Any, TypeVar from hypothesis.errors import HypothesisWarning, InvalidArgument +from hypothesis.internal.conjecture.choice import ChoiceT from hypothesis.internal.conjecture.data import ConjectureData from hypothesis.internal.reflection import ( get_pretty_function_description, @@ -71,6 +72,9 @@ def do_draw(self, data: ConjectureData) -> T: self.marker -= 1 return data.draw(self.base_strategy) + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + return self.base_strategy._invert(value) + @contextmanager def capped(self, max_templates: int) -> Generator[None, None, None]: try: @@ -183,3 +187,6 @@ def do_draw(self, data: ConjectureData) -> Any: f"Draw for {self!r} exceeded " f"max_leaves={self.max_leaves} and had to be retried" ] = "" + + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + return self.strategy._invert(value) diff --git a/hypothesis/src/hypothesis/strategies/_internal/shared.py b/hypothesis/src/hypothesis/strategies/_internal/shared.py index 07fe866ca2..24038cca83 100644 --- a/hypothesis/src/hypothesis/strategies/_internal/shared.py +++ b/hypothesis/src/hypothesis/strategies/_internal/shared.py @@ -12,7 +12,8 @@ from collections.abc import Hashable from typing import Any -from hypothesis.errors import HypothesisWarning +from hypothesis.errors import CannotInvertYet, HypothesisWarning +from hypothesis.internal.conjecture.choice import ChoiceT from hypothesis.internal.conjecture.data import ConjectureData from hypothesis.strategies._internal import SearchStrategy from hypothesis.strategies._internal.strategies import Ex @@ -54,3 +55,12 @@ def do_draw(self, data: ConjectureData) -> Any: stacklevel=1, ) return drawn + + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + # _invert only has local knowledge. To correctly invert st.shared, _invert needs + # global knowledge of the entire test function, because st.shared adds to the + # choice sequence only the first time it draws, and _invert therefore needs to + # know whether this was the first draw or not. + # + # Leave uninvertible for now. + raise CannotInvertYet(f"st.shared cannot be inverted locally (value={value!r})") diff --git a/hypothesis/src/hypothesis/strategies/_internal/strategies.py b/hypothesis/src/hypothesis/strategies/_internal/strategies.py index f2ff62bfd6..756d402400 100644 --- a/hypothesis/src/hypothesis/strategies/_internal/strategies.py +++ b/hypothesis/src/hypothesis/strategies/_internal/strategies.py @@ -33,6 +33,9 @@ from hypothesis._settings import HealthCheck, Phase, Verbosity, settings from hypothesis.control import _current_build_context, current_build_context from hypothesis.errors import ( + CannotInvert, + CannotInvertYet, + DefinitelyCannotInvert, HypothesisException, HypothesisWarning, InvalidArgument, @@ -40,7 +43,9 @@ UnsatisfiedAssumption, ) from hypothesis.internal.conjecture import utils as cu +from hypothesis.internal.conjecture.choice import ChoiceT from hypothesis.internal.conjecture.data import ConjectureData +from hypothesis.internal.conjecture.junkdrawer import deep_equal from hypothesis.internal.conjecture.utils import ( calc_label_from_cls, calc_label_from_hash, @@ -552,6 +557,22 @@ def do_validate(self) -> None: def do_draw(self, data: ConjectureData) -> Ex: raise NotImplementedError(f"{type(self).__name__}.do_draw") + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + """ + Return a choice sequence `choices`, such that we expect + + data = ConjectureData.for_choices(choices) + drawn_value = data.draw(strategy) + + would produce value == drawn_value. Note that _invert is not required to + satisfy this property, and the caller the case where _invert returns a choice + sequence which does not invert to `value`. _invert should still try to satisfy + this property with high probability. + + Raises CannotInvert if we cannot construct such a choice sequence. + """ + raise CannotInvertYet(f"{type(self).__name__} does not support inversion") + def _is_hashable(value: object) -> tuple[bool, int | None]: # hashing can be expensive; return the hash value if we compute it, so that @@ -737,6 +758,17 @@ def do_draw(self, data: ConjectureData) -> Ex: def get_element(self, i: int) -> Ex | UniqueIdentifier: return self._transform(self.elements[i]) + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + # find the smallest index whose (possibly transformed) element equals `value`. + for i, element in enumerate(self.elements): + # _transform might depend on external state, and give us the wrong answer + # for our inversion here. This is fine - _invert is allowed to be fallible - + # but worth keeping in mind. + transformed = self._transform(element) + if deep_equal(transformed, value): + return (i,) + raise DefinitelyCannotInvert(f"{value!r} is not produced by {self!r}") + def do_filtered_draw(self, data: ConjectureData) -> Ex | UniqueIdentifier: # Set of indices that have been tried so far, so that we never test # the same element twice during a draw. @@ -863,6 +895,18 @@ def do_draw(self, data: ConjectureData) -> Ex: ) return data.draw(strategy) + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + # do_draw first samples a branch index, then draws from that branch. + for i, branch in enumerate(self.element_strategies): + try: + inner = branch._invert(value) + except CannotInvert: + continue + return (i, *inner) + raise DefinitelyCannotInvert( + f"{value!r} is not produced by any branch of {self!r}" + ) + def __repr__(self) -> str: return "one_of({})".format(", ".join(map(repr, self.original_strategies))) @@ -1031,6 +1075,12 @@ def __repr__(self) -> str: def do_validate(self) -> None: self.mapped_strategy.validate() + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + # todo: handle common special cases like .map("".join) + raise CannotInvertYet( + f"cannot invert {self!r} for {value!r} (map() is not invertible)" + ) + def do_draw(self, data: ConjectureData) -> MappedTo: with warnings.catch_warnings(): if isinstance(self.pack, type) and issubclass( @@ -1247,6 +1297,14 @@ def do_filtered_draw(self, data: ConjectureData) -> Ex | UniqueIdentifier: return filter_not_satisfied + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + # If the predicate accepts `value`, the runtime would have succeeded + # on the first try - so the inverse is just the inner strategy's + # inverse. + if not self.condition(value): + raise DefinitelyCannotInvert(f"{value!r} does not satisfy filter {self!r}") + return self.filtered_strategy._invert(value) + @property def branches(self) -> Sequence[SearchStrategy[Ex]]: return [ diff --git a/hypothesis/src/hypothesis/strategies/_internal/strings.py b/hypothesis/src/hypothesis/strategies/_internal/strings.py index 9b4020e20e..1579dc7042 100644 --- a/hypothesis/src/hypothesis/strategies/_internal/strings.py +++ b/hypothesis/src/hypothesis/strategies/_internal/strings.py @@ -13,11 +13,12 @@ import warnings from collections.abc import Collection from functools import cache, lru_cache, partial -from typing import cast +from typing import Any, cast -from hypothesis.errors import HypothesisWarning, InvalidArgument +from hypothesis.errors import DefinitelyCannotInvert, HypothesisWarning, InvalidArgument from hypothesis.internal import charmap from hypothesis.internal.charmap import Categories +from hypothesis.internal.conjecture.choice import ChoiceT from hypothesis.internal.conjecture.data import ConjectureData from hypothesis.internal.conjecture.providers import COLLECTION_DEFAULT_MAX_SIZE from hypothesis.internal.filtering import max_len, min_len @@ -134,6 +135,13 @@ def __repr__(self) -> str: def do_draw(self, data: ConjectureData) -> str: return data.draw_string(self.intervals, min_size=1, max_size=1) + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + if not isinstance(value, str) or len(value) != 1: + raise DefinitelyCannotInvert(f"{value!r} is not a single character") + if ord(value) not in self.intervals: + raise DefinitelyCannotInvert(f"{value!r} is not in {self.intervals!r}") + return (value,) + _nonempty_names = ( "capitalize", @@ -181,6 +189,28 @@ def do_draw(self, data): ) return "".join(super().do_draw(data)) + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + if not isinstance(value, str): + raise DefinitelyCannotInvert(f"{value!r} is not a string") + elems = unwrap_strategies(self.element_strategy) + if isinstance(elems, OneCharStringStrategy): + effective_max = ( + COLLECTION_DEFAULT_MAX_SIZE + if self.max_size == float("inf") + else self.max_size + ) + if not (self.min_size <= len(value) <= effective_max): + raise DefinitelyCannotInvert( + f"len({value!r})={len(value)} outside " + f"[{self.min_size}, {effective_max}]" + ) + if any(ord(c) not in elems.intervals for c in value): + raise DefinitelyCannotInvert( + f"{value!r} contains chars outside {elems!r}" + ) + return (value,) + return ListStrategy._invert(self, list(value)) + def __repr__(self) -> str: args = [] if repr(self.element_strategy) != "characters()": @@ -365,6 +395,16 @@ def __init__(self, min_size: int, max_size: int | None): def do_draw(self, data: ConjectureData) -> bytes: return data.draw_bytes(self.min_size, self.max_size) + def _invert(self, value: Any) -> tuple[ChoiceT, ...]: + if not isinstance(value, bytes): + raise DefinitelyCannotInvert(f"{value!r} is not bytes") + if not (self.min_size <= len(value) <= self.max_size): + raise DefinitelyCannotInvert( + f"len({value!r})={len(value)} outside " + f"[{self.min_size}, {self.max_size}]" + ) + return (value,) + _nonempty_filters = ( *ListStrategy._nonempty_filters, bytes, diff --git a/hypothesis/tests/conjecture/test_deep_equal.py b/hypothesis/tests/conjecture/test_deep_equal.py new file mode 100644 index 0000000000..dea649af09 --- /dev/null +++ b/hypothesis/tests/conjecture/test_deep_equal.py @@ -0,0 +1,70 @@ +# This file is part of Hypothesis, which may be found at +# https://github.com/HypothesisWorks/hypothesis/ +# +# Copyright the Hypothesis Authors. +# Individual contributors are listed in AUTHORS.rst and the git log. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, +# v. 2.0. If a copy of the MPL was not distributed with this file, You can +# obtain one at https://mozilla.org/MPL/2.0/. + +import copy +import math + +import pytest + +from hypothesis import given, strategies as st +from hypothesis.internal.conjecture.junkdrawer import deep_equal + + +def values(*, eq_divergent: bool = True): + # If eq_divergent, include the leaf types where deep_equal intentionally + # diverges from ==: floats (NaN, signed zero) and bools (True == 1). + leaves = st.none() | st.integers() | st.text() | st.binary() + if eq_divergent: + leaves |= st.booleans() | st.floats() + hashable = st.recursive(leaves, lambda v: st.tuples(v) | st.frozensets(v)) + return st.recursive( + leaves, + lambda v: ( + st.lists(v) + | st.tuples(v) + | st.dictionaries(hashable, v) + | st.frozensets(hashable) + ), + ) + + +@given(values()) +def test_deep_equal_reflexive(v): + # deepcopy so we can't trivially pass via an `is` shortcut + assert deep_equal(v, copy.deepcopy(v)) + + +@given(values(), values()) +def test_deep_equal_symmetric(a, b): + assert deep_equal(a, b) == deep_equal(b, a) + + +@given(values(eq_divergent=False), values(eq_divergent=False)) +def test_deep_equal_matches_eq_without_floats(a, b): + assert deep_equal(a, b) == (a == b) + + +@pytest.mark.parametrize( + "a,b,expected", + [ + (math.nan, math.nan, True), + (0.0, -0.0, False), + (True, 1, False), + (1, 1.0, False), + ([math.nan], [math.nan], True), + ({"a": math.nan}, {"a": math.nan}, True), + ({0.0: 1}, {-0.0: 1}, False), + ({math.nan}, {math.nan}, True), + ({1, 2}, {1, 3}, False), + ({"a": 1}, {"a": 2}, False), + ], +) +def test_deep_equal_explicit(a, b, expected): + assert deep_equal(a, b) is expected diff --git a/hypothesis/tests/conjecture/test_utils.py b/hypothesis/tests/conjecture/test_utils.py index bfc7e88ce6..03fadf8749 100644 --- a/hypothesis/tests/conjecture/test_utils.py +++ b/hypothesis/tests/conjecture/test_utils.py @@ -201,6 +201,18 @@ def test_many_with_max_size(): assert not many.more() +def test_invert_many_variable_size(): + many = cu.invert_many(min_size=0, max_size=5) + assert many.more() == (True,) + assert many.done() == (False,) + + +def test_invert_many_fixed_size(): + many = cu.invert_many(min_size=3, max_size=3) + assert many.more() == () + assert many.done() == () + + def test_samples_from_a_range_directly(): s = cu.check_sample(range(10**1000), "") assert isinstance(s, range) diff --git a/hypothesis/tests/cover/test_invert.py b/hypothesis/tests/cover/test_invert.py new file mode 100644 index 0000000000..38ad20ef11 --- /dev/null +++ b/hypothesis/tests/cover/test_invert.py @@ -0,0 +1,359 @@ +# This file is part of Hypothesis, which may be found at +# https://github.com/HypothesisWorks/hypothesis/ +# +# Copyright the Hypothesis Authors. +# Individual contributors are listed in AUTHORS.rst and the git log. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, +# v. 2.0. If a copy of the MPL was not distributed with this file, You can +# obtain one at https://mozilla.org/MPL/2.0/. + +import dataclasses +import datetime as dt +import math +import zoneinfo + +import pytest + +from hypothesis import given, settings, strategies as st +from hypothesis.control import BuildContext +from hypothesis.errors import CannotInvert +from hypothesis.internal.conjecture.data import ConjectureData +from hypothesis.internal.conjecture.junkdrawer import deep_equal + +pytestmark = pytest.mark.skipif( + settings().backend == "crosshair", reason="cannot _invert symbolic values" +) + + +def assert_roundtrip(strategy, value): + choices = strategy._invert(value) + data = ConjectureData.for_choices(choices) + with BuildContext(data, is_final=False, wrapped_test=lambda: None): + replayed = data.draw(strategy) + + assert data.misaligned_at is None + assert deep_equal(data.choices, choices) + assert deep_equal(replayed, value) + + +def check_roundtrip_many(strategy, data): + for _ in range(5): + assert_roundtrip(strategy, data.draw(strategy)) + + +@given(st.data()) +def test_integers(data): + min_value = data.draw(st.none() | st.integers()) + max_value = data.draw(st.none() | st.integers()) + if min_value is not None and max_value is not None and min_value > max_value: + min_value, max_value = max_value, min_value + check_roundtrip_many(st.integers(min_value, max_value), data) + + +@given(st.data()) +def test_booleans(data): + check_roundtrip_many(st.booleans(), data) + + +@given(st.data()) +def test_floats(data): + min_value = data.draw(st.none() | st.floats(allow_nan=False)) + max_value = data.draw(st.none() | st.floats(allow_nan=False)) + if min_value is not None and max_value is not None and min_value > max_value: + min_value, max_value = max_value, min_value + bounded = min_value is not None or max_value is not None + allow_nan = False if bounded else data.draw(st.booleans()) + strategy = st.floats(min_value=min_value, max_value=max_value, allow_nan=allow_nan) + check_roundtrip_many(strategy, data) + + +@given(st.data()) +def test_binary(data): + min_size = data.draw(st.integers(0, 20)) + max_size = data.draw(st.integers(min_size, min_size + 20)) + check_roundtrip_many(st.binary(min_size=min_size, max_size=max_size), data) + + +@given(st.data()) +def test_just(data): + value = data.draw(st.integers()) + check_roundtrip_many(st.just(value), data) + + +@given(st.data()) +def test_none(data): + check_roundtrip_many(st.none(), data) + + +@given(st.data()) +def test_sampled_from(data): + elements = data.draw(st.lists(st.integers(), min_size=1)) + check_roundtrip_many(st.sampled_from(elements), data) + + +@given(st.data()) +def test_tuples(data): + n = data.draw(st.integers(0, 5)) + check_roundtrip_many(st.tuples(*[st.integers()] * n), data) + + +@given(st.data()) +def test_one_of(data): + n = data.draw(st.integers(1, 5)) + check_roundtrip_many(st.one_of(*[st.integers()] * n), data) + + +@given(st.data()) +def test_lists(data): + min_size = data.draw(st.integers(0, 5)) + max_size = data.draw(st.integers(min_size, min_size + 10)) + strategy = st.lists(st.integers(), min_size=min_size, max_size=max_size) + check_roundtrip_many(strategy, data) + + +@given(st.data()) +def test_text(data): + alphabet = data.draw(st.none() | st.text(min_size=1)) + min_size = data.draw(st.integers(0, 5)) + max_size = data.draw(st.integers(min_size, min_size + 10)) + kwargs = {"min_size": min_size, "max_size": max_size} + if alphabet is not None: + kwargs["alphabet"] = alphabet + check_roundtrip_many(st.text(**kwargs), data) + + +@given(st.data()) +def test_characters(data): + check_roundtrip_many(st.characters(), data) + + +@given(st.data()) +def test_floats_nan_via_filter(data): + # st.floats(allow_nan=True).filter(math.isnan) is rewritten to NanStrategy. + check_roundtrip_many(st.floats(allow_nan=True).filter(math.isnan), data) + + +@given(st.data()) +def test_permutations(data): + values = data.draw(st.lists(st.integers(), unique=True)) + check_roundtrip_many(st.permutations(values), data) + + +@given(st.data()) +def test_dates(data): + min_value = data.draw(st.dates()) + max_value = data.draw(st.dates(min_value=min_value)) + if min_value == max_value: + max_value = max_value + dt.timedelta(days=1) + check_roundtrip_many(st.dates(min_value=min_value, max_value=max_value), data) + + +@given(st.data()) +def test_times(data): + min_value = data.draw(st.times()) + max_value = data.draw(st.times(min_value=min_value)) + check_roundtrip_many(st.times(min_value=min_value, max_value=max_value), data) + + +@given(st.data()) +def test_datetimes(data): + min_value = data.draw(st.datetimes()) + max_value = data.draw(st.datetimes(min_value=min_value)) + check_roundtrip_many(st.datetimes(min_value=min_value, max_value=max_value), data) + + +@given(st.data()) +def test_timedeltas(data): + min_value = data.draw(st.timedeltas()) + max_value = data.draw(st.timedeltas(min_value=min_value)) + check_roundtrip_many(st.timedeltas(min_value=min_value, max_value=max_value), data) + + +@given(st.data()) +def test_filter(data): + # Bound threshold to the lower half of the range so at least half of all + # values pass the filter (otherwise filter_too_much fires). + lo = data.draw(st.integers(-100, 100)) + hi = data.draw(st.integers(lo, lo + 100)) + threshold = data.draw(st.integers(lo, lo + (hi - lo) // 2)) + check_roundtrip_many(st.integers(lo, hi).filter(lambda x: x >= threshold), data) + + +@dataclasses.dataclass +class _Pair: + x: object + y: object + + +@pytest.mark.parametrize("target", [list, dict, set, tuple, frozenset, int, str, bytes]) +@given(data=st.data()) +def test_builds_zero_arg(data, target): + check_roundtrip_many(st.builds(target), data) + + +@given(st.data()) +def test_builds_dataclass(data): + # For each field, randomly choose positional or kwarg. Once we've gone + # kwarg we can't go back to positional, so seen_kwarg latches. + args = [] + kwargs = {} + seen_kwarg = False + for field in dataclasses.fields(_Pair): + if seen_kwarg or data.draw(st.booleans()): + kwargs[field.name] = st.integers() + seen_kwarg = True + else: + args.append(st.integers()) + check_roundtrip_many(st.builds(_Pair, *args, **kwargs), data) + + +@given(st.data()) +def test_deferred(data): + check_roundtrip_many(st.deferred(lambda: st.integers()), data) + + +@given(st.data()) +def test_recursive(data): + check_roundtrip_many( + st.recursive(st.integers(), lambda c: st.lists(c, max_size=2)), data + ) + + +@pytest.mark.parametrize( + "strategy,value", + [ + (st.floats(allow_nan=True), float("nan")), + (st.integers().filter(lambda x: x % 2 == 0), 4), + (st.sampled_from([1, 2, 3, 4]).filter(lambda x: x > 2), 3), + ], +) +def test_roundtrip_explicit(strategy, value): + assert_roundtrip(strategy, value) + + +@pytest.mark.parametrize( + "strategy,value", + [ + (st.integers(), True), + (st.integers(), 1.0), + (st.integers(), "5"), + (st.floats(), 1), + (st.floats(allow_subnormal=False), 5e-324), + (st.booleans(), 0), + (st.binary(), "abc"), + (st.integers(0, 10), -1), + (st.integers(0, 10), 11), + (st.floats(min_value=0.0, max_value=1.0), 2.0), + (st.floats(allow_nan=False), float("nan")), + (st.binary(min_size=3, max_size=3), b"ab"), + (st.lists(st.integers()), "not a list"), + (st.lists(st.integers(), min_size=3), [1, 2]), + (st.lists(st.integers(), max_size=2), [1, 2, 3]), + (st.lists(st.integers(), unique=True), [1, 2, 3]), + (st.lists(st.integers(), unique=True), object()), + (st.tuples(st.integers(), st.booleans()), (5,)), + (st.tuples(st.integers(), st.booleans()), (5, True, "extra")), + (st.text(), 123), + (st.text(max_size=2), "abc"), + (st.text(alphabet="abc"), "xyz"), + (st.characters(), "ab"), + (st.characters(min_codepoint=ord("a"), max_codepoint=ord("z")), "A"), + (st.floats(allow_nan=True).filter(math.isnan), 1.0), + (st.text(st.text(min_size=1, max_size=1)), "a"), + (st.dates(), "not a date"), + ( + st.dates(min_value=dt.date(2020, 1, 1), max_value=dt.date(2021, 1, 1)), + dt.date(2025, 1, 1), + ), + (st.times(), "not a time"), + (st.times(min_value=dt.time(12, 0)), dt.time(6, 0)), + (st.datetimes(), "not a datetime"), + ( + st.datetimes(max_value=dt.datetime(2020, 1, 1)), + dt.datetime(2025, 1, 1), + ), + # 2024-03-10 02:30 New York is in the imaginary gap + ( + st.datetimes( + allow_imaginary=False, + timezones=st.just(zoneinfo.ZoneInfo("America/New_York")), + ), + dt.datetime( + 2024, 3, 10, 2, 30, tzinfo=zoneinfo.ZoneInfo("America/New_York") + ), + ), + (st.timedeltas(), "not a timedelta"), + (st.timedeltas(max_value=dt.timedelta(days=1)), dt.timedelta(days=10)), + (st.just(42), 41), + (st.sampled_from([1, 2, 3]), 99), + (st.nothing(), 0), + (st.integers().filter(lambda x: x > 0), -5), + (st.integers().filter(lambda x: x % 2 == 0), 3), + (st.one_of(st.integers(), st.booleans()), "string"), + (st.permutations([1, 2, 3]), [4, 1, 2]), + (st.permutations([1, 2, 3]), [1, 2, 4]), + (st.permutations([1, 2, 3]), [1, 2]), + (st.fixed_dictionaries({"a": st.integers()}), object()), + (st.builds(_Pair, x=st.integers(), y=st.integers()), "not a Pair"), + (st.integers().map(str), object()), + (st.lists(st.integers()).map(set), object()), + (st.sets(st.integers()), object()), + (st.frozensets(st.integers()), object()), + (st.dictionaries(st.text(), st.integers()), object()), + ( + st.integers().flatmap( + lambda n: st.lists(st.integers(), min_size=n, max_size=n) + ), + object(), + ), + (st.builds(lambda x, y: x + y, st.integers(), st.integers()), object()), + (st.data(), object()), + (st.runner(), object()), + (st.randoms(), object()), + (st.random_module(), object()), + (st.from_regex(r"abc"), object()), + (st.functions(), object()), + ], +) +def test_uninvertible_raises(strategy, value): + with pytest.raises(CannotInvert): + strategy._invert(value) + + +@pytest.mark.parametrize( + "strategy,value,expected", + [ + (st.lists(st.integers()), [], (False,)), + (st.lists(st.integers(), min_size=3, max_size=3), [1, 2, 3], (1, 2, 3)), + (st.just(42), 42, ()), + (st.sampled_from([7, 7, 7]), 7, (0,)), + (st.one_of(st.just(5), st.just(5)), 5, (0,)), + (st.one_of(st.integers(), st.text()), "hello", (1, "hello")), + (st.lists(st.integers()), [1, 2], (True, 1, True, 2, False)), + (st.lists(st.integers(), min_size=1, max_size=5), [42], (True, 42, False)), + (st.tuples(st.integers(), st.booleans()), (5, True), (5, True)), + (st.tuples(), (), ()), + ( + st.one_of(st.integers(), st.lists(st.integers(), max_size=3)), + [1, 2], + (1, True, 1, True, 2, False), + ), + ( + st.lists(st.lists(st.integers(), max_size=2), max_size=2), + [[1], []], + (True, True, 1, False, True, False, False), + ), + # Fisher-Yates inversion: each draw is the swap target index. + (st.permutations([1, 2, 3]), [1, 2, 3], (0, 1)), + (st.permutations([1, 2, 3]), [3, 1, 2], (2, 2)), + ( + st.dates(min_value=dt.date(2020, 1, 1), max_value=dt.date(2025, 12, 31)), + dt.date(2022, 5, 15), + (2022, 5, 15), + ), + (st.integers().filter(lambda x: x > 0), 5, (5,)), + ], +) +def test_produces_expected_choice_sequence(strategy, value, expected): + assert strategy._invert(value) == expected