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.
4 changes: 4 additions & 0 deletions hypothesis/src/hypothesis/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ class ChoiceTooLarge(HypothesisException):
"""An internal error raised by choice_from_index."""


class CannotInvert(HypothesisException):
"""Internal error raised by SearchStrategy._invert."""


class Flaky(_Trimmable):
"""
Base class for indeterministic failures. Usually one of the more
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
31 changes: 30 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 CannotInvert, 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,15 @@ 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 CannotInvert(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 +236,21 @@ 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 CannotInvert(f"{value!r} is not a list")
if not (self.min_size <= len(value) <= self.max_size):
raise CannotInvert(
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: tuple[ChoiceT, ...] = ()
for v in value:
seq += elements.more() + self.element_strategy._invert(v)
seq += elements.done()
Comment thread
Liam-DeVoe marked this conversation as resolved.
Outdated
return seq

def __repr__(self) -> str:
return (
f"{self.__class__.__name__}({self.element_strategy!r}, "
Expand Down Expand Up @@ -290,6 +315,10 @@ def __init__(
self.keys = keys
self.tuple_suffixes = tuple_suffixes

def _invert(self, value: Any) -> tuple[ChoiceT, ...]:
# very annoying to invert
raise CannotInvert(f"cannot invert {self!r}")
Comment thread
Liam-DeVoe marked this conversation as resolved.
Outdated

def do_draw(self, data: ConjectureData) -> list[Ex]:
if self.element_strategy.is_empty:
assert self.min_size == 0
Expand Down
45 changes: 45 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,7 @@
should_note,
)
from hypothesis.errors import (
CannotInvert,
HypothesisSideeffectWarning,
HypothesisWarning,
InvalidArgument,
Expand All @@ -81,6 +83,7 @@
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.utils import (
calc_label_from_callable,
Expand Down Expand Up @@ -1067,6 +1070,26 @@ 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 CannotInvert(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 CannotInvert(f"cannot invert {self!r}")

def do_draw(self, data: ConjectureData) -> Ex:
context = current_build_context()
arg_labels: ArgLabelsT = {}
Expand Down Expand Up @@ -1915,6 +1938,28 @@ 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 CannotInvert(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 current[j] == target:
Comment thread
Liam-DeVoe marked this conversation as resolved.
Outdated
seq.append(j)
current[i], current[j] = current[j], current[i]
break
else:
raise CannotInvert(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 current[-1] != value[-1]:
raise CannotInvert(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 CannotInvert, 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 CannotInvert(f"{value!r} is not a datetime")
naive = value.replace(tzinfo=None)
if not (self.min_value <= naive <= self.max_value):
raise CannotInvert(
f"{value!r} outside [{self.min_value!r}, {self.max_value!r}]"
)
if not self.allow_imaginary and datetime_does_not_exist(value):
raise CannotInvert(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(
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 CannotInvert(f"{value!r} is not a time")
naive = value.replace(tzinfo=None)
if not (self.min_value <= naive <= self.max_value):
raise CannotInvert(
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 CannotInvert(f"{value!r} is not a date")
if not (self.min_value <= value <= self.max_value):
raise CannotInvert(
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 CannotInvert(f"{value!r} is not a timedelta")
if not (self.min_value <= value <= self.max_value):
raise CannotInvert(
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)
12 changes: 12 additions & 0 deletions hypothesis/src/hypothesis/strategies/_internal/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, NoReturn

from hypothesis.errors import CannotInvert
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 @@ -63,6 +65,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 self._transform(self.value) != value:
Comment thread
Liam-DeVoe marked this conversation as resolved.
Outdated
raise CannotInvert(f"{value!r} is not produced by {self!r}")
return ()


@defines_strategy(eager=True)
def just(value: T) -> SearchStrategy[T]:
Expand Down Expand Up @@ -134,5 +141,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 CannotInvert(f"{value!r} is not a bool")
return (value,)

def __repr__(self) -> str:
return "booleans()"
Loading