Skip to content
3 changes: 3 additions & 0 deletions hypothesis/RELEASE.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
RELEASE_TYPE: patch

Add an internal helper for use in future work.
18 changes: 18 additions & 0 deletions hypothesis/src/hypothesis/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions hypothesis/src/hypothesis/internal/conjecture/junkdrawer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]],
Expand Down
14 changes: 14 additions & 0 deletions hypothesis/src/hypothesis/internal/conjecture/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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


Expand Down
34 changes: 33 additions & 1 deletion hypothesis/src/hypothesis/strategies/_internal/collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
)
Comment on lines +95 to +99

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about the experience of debugging "why doesn't this invert???", I think we might want to use an explicit loop here, so that we can exc.add_note(f"at {idx=} of {value!r}, strategy={self}"); raise and thus see the trace through the tree of strategies to the specific problem.

Annoying now, I know, but I think investing in the pattern and maybe a helper fn will pay off later.


def calc_is_empty(self, recur: RecurT) -> bool:
return any(recur(e) for e in self.element_strategies)

Expand Down Expand Up @@ -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}, "
Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions hypothesis/src/hypothesis/strategies/_internal/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
# obtain one at https://mozilla.org/MPL/2.0/.

import codecs
import dataclasses
import enum
import math
import operator
Expand Down Expand Up @@ -60,6 +61,8 @@
should_note,
)
from hypothesis.errors import (
CannotInvertYet,
DefinitelyCannotInvert,
HypothesisSideeffectWarning,
HypothesisWarning,
InvalidArgument,
Expand All @@ -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,
Expand Down Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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]]:
Expand Down
65 changes: 64 additions & 1 deletion hypothesis/src/hypothesis/strategies/_internal/datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
raise DefinitelyCannotInvert(f"{value!r} is an imaginary datetime")
raise DefinitelyCannotInvert(f"{value!r} is an imaginary datetime, but allow_imaginary=False")

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(
Expand Down Expand Up @@ -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.
Comment on lines +266 to +267

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am confused by this comment.

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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions hypothesis/src/hypothesis/strategies/_internal/deferred.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
4 changes: 4 additions & 0 deletions hypothesis/src/hypothesis/strategies/_internal/lazy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Loading
Loading