Skip to content

Commit e6406e2

Browse files
committed
Merge remote-tracking branch 'upstream/master' into next
# Conflicts: # hypothesis/RELEASE.rst
2 parents fb43759 + a8ad347 commit e6406e2

12 files changed

Lines changed: 250 additions & 40 deletions

File tree

AUTHORS.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ their individual contributions.
8484
* `Hal Blackburn <https://github.com/h4l>`_
8585
* `Hugo van Kemenade <https://github.com/hugovk>`_
8686
* `Humberto Rocha <https://github.com/humrochagf>`_
87+
* `Ian Hunt-Isaak <https://github.com/ianhi>`_
8788
* `Ilya Lebedev <https://github.com/melevir>`_ (melevir@gmail.com)
8889
* `Israel Fruchter <https://github.com/fruch>`_
8990
* `Ivan Tham <https://github.com/pickfire>`_

hypothesis/RELEASE.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
11
RELEASE_TYPE: minor
22

33
|event|'s ``payload`` is now typed as accepting |Any|, matching its runtime behavior of accepting any string-coercible object.
4+
5+
When Hypothesis detects that your data generation is flaky and raises
6+
|FlakyStrategyDefinition|, the error message now describes *what* differed
7+
between the two runs - such as a different choice type, different constraints,
8+
or drawing more or less data - as well as the stack of strategies being drawn
9+
from, instead of only reporting that generation was inconsistent. In stateful
10+
tests, it also reports the steps leading up to the error.
11+
12+
Thanks to Ian Hunt-Isaak for this improvement!

hypothesis/docs/changelog.rst

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,26 @@ Hypothesis 6.x
2424
6.152.11 - 2026-05-26
2525
---------------------
2626

27-
This patch adds support for recursive forward references in
28-
:func:`~hypothesis.strategies.from_type`, such as
29-
``A = list[Union["A", str]]`` (:issue:`4542`).
30-
Previously, such recursive type aliases would raise a ``ResolutionFailed``
31-
error. Now, Hypothesis can automatically resolve the forward reference
32-
by looking it up in the caller's namespace. This also resolves forward
33-
references inside ``type[...]``, such as ``type["MyClass"]``.
27+
This patch adds support for resolving forward references in |st.from_type|
28+
(:issue:`4542`):
29+
30+
.. code-block:: python
31+
32+
# this now works
33+
A = list[Union["A", str]]
34+
st.from_type(A).example()
35+
36+
# this also now works
37+
s = st.from_type(list["B"])
38+
39+
@dataclass
40+
class B:
41+
v: int
42+
43+
s.example()
44+
45+
Previously, these would raise an error. Now, Hypothesis automatically resolves
46+
the forward reference by looking it up in the caller's namespace.
3447

3548
.. _v6.152.10:
3649

hypothesis/docs/explanation/domain.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,6 @@ The test case distribution remains an active area of research and development, a
4545
* some are statically designed into strategies - for example, |st.integers| upweights range endpoints, and samples from a mixed distribution over integer bit-widths.
4646
* some are dynamic features of the engine - like replaying prior examples with subsections of the input 'cloned' or otherwise altered, for bugs which trigger only when different fields have the same value (which is otherwise exponentially unlikely).
4747
* some vary depending on the code under test - we collect interesting-looking constants from imported source files as seeds for test cases.
48+
* `swarm testing <https://www.cs.utah.edu/~regehr/papers/swarm12.pdf>`__ adds further randomization when choosing which rules to execute in stateful testing.
4849

4950
And as if that wasn't enough, :ref:`alternative backends <alternative-backends>` can radically change the distribution again - for example :pypi:`hypofuzz` uses runtime feedback to modify the distribution of inputs as the test runs, to maximize the rate at which we trigger new behaviors in that particular test and code. If Hypothesis' defaults aren't strong enough, we recommend trying Hypofuzz!

hypothesis/src/hypothesis/errors.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,17 @@
88
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
99
# obtain one at https://mozilla.org/MPL/2.0/.
1010

11+
from collections.abc import Mapping
1112
from datetime import timedelta
12-
from typing import Any, Literal
13+
from typing import TYPE_CHECKING, Any, Literal
1314

1415
from hypothesis.internal.compat import ExceptionGroup
1516

17+
if TYPE_CHECKING:
18+
from hypothesis.internal.conjecture.choice import ChoiceConstraintsT
19+
else:
20+
ChoiceConstraintsT = Mapping
21+
1622

1723
class HypothesisException(Exception):
1824
"""Generic parent class for exceptions thrown by Hypothesis."""
@@ -85,6 +91,13 @@ def __init__(self, reason, interesting_origins=None):
8591
self._interesting_origins = interesting_origins
8692

8793

94+
def _render_constraints(show: Mapping[str, object], other: Mapping[str, object]) -> str:
95+
assert show.keys() == other.keys()
96+
return ", ".join(
97+
f"{k}={'...' if v == other[k] else repr(v)}" for k, v in show.items()
98+
)
99+
100+
88101
class FlakyStrategyDefinition(Flaky):
89102
"""
90103
This function appears to cause inconsistent data generation.
@@ -99,6 +112,39 @@ class FlakyStrategyDefinition(Flaky):
99112
See also the :doc:`flaky failures tutorial </tutorial/flaky>`.
100113
"""
101114

115+
_BASE_MESSAGE = (
116+
"Inconsistent data generation! Data generation behaved differently "
117+
"between test cases. Is your data generation depending on external "
118+
"state?"
119+
)
120+
121+
@classmethod
122+
def with_detail(cls, detail: str) -> "FlakyStrategyDefinition":
123+
return cls(f"{cls._BASE_MESSAGE}\n\n{detail}")
124+
125+
@classmethod
126+
def from_mismatch(
127+
cls,
128+
expected_type: str,
129+
expected_constraints: ChoiceConstraintsT,
130+
actual_type: str,
131+
actual_constraints: ChoiceConstraintsT,
132+
) -> "FlakyStrategyDefinition":
133+
if actual_type != expected_type:
134+
detail = (
135+
"The second test case drew a different type of value than the first.\n"
136+
f" first: {expected_type}\n"
137+
f" second: {actual_type}\n"
138+
)
139+
else:
140+
detail = (
141+
f"The second test case drew type {actual_type} with different constraints "
142+
"than the first.\n"
143+
f" first: {_render_constraints(expected_constraints, actual_constraints)}\n"
144+
f" second: {_render_constraints(actual_constraints, expected_constraints)}\n"
145+
)
146+
return cls.with_detail(detail)
147+
102148

103149
class _WrappedBaseException(Exception):
104150
"""Used internally for wrapping BaseExceptions as components of FlakyFailure."""

hypothesis/src/hypothesis/internal/conjecture/data.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from hypothesis.errors import (
3131
CannotProceedScopeT,
3232
ChoiceTooLarge,
33+
FlakyStrategyDefinition,
3334
Frozen,
3435
InvalidArgument,
3536
StopTest,
@@ -1202,7 +1203,15 @@ def draw(
12021203
self.start_span(label=label)
12031204
try:
12041205
if not at_top_level:
1205-
return unwrapped.do_draw(self)
1206+
try:
1207+
return unwrapped.do_draw(self)
1208+
except FlakyStrategyDefinition as err:
1209+
# Record the strategy stack as the error unwinds, so that an
1210+
# inconsistent-generation failure is explained in terms of the
1211+
# strategies being drawn from, not just the choice sequence.
1212+
# The top-level draw adds its own "while generating ..." note.
1213+
add_note(err, f"while drawing from {strategy!r}")
1214+
raise
12061215
assert start_time is not None
12071216
key = observe_as or f"generate:unlabeled_{len(self.draw_times)}"
12081217
try:

hypothesis/src/hypothesis/internal/conjecture/datatree.py

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,6 @@ class PreviouslyUnseenBehaviour(HypothesisException):
5353
pass
5454

5555

56-
_FLAKY_STRAT_MSG = (
57-
"Inconsistent data generation! Data generation behaved differently "
58-
"between different runs. Is your data generation depending on external "
59-
"state?"
60-
)
61-
62-
6356
EMPTY: frozenset[int] = frozenset()
6457

6558

@@ -442,7 +435,7 @@ def mark_forced(self, i: int) -> None:
442435
self.__forced = set()
443436
self.__forced.add(i)
444437

445-
def split_at(self, i: int) -> None:
438+
def split_at(self, i: int, *, new_value: object = None) -> None:
446439
"""
447440
Splits the tree so that it can incorporate a decision at the draw call
448441
corresponding to the node at position i.
@@ -451,7 +444,11 @@ def split_at(self, i: int) -> None:
451444
"""
452445

453446
if i in self.forced:
454-
raise FlakyStrategyDefinition(_FLAKY_STRAT_MSG)
447+
raise FlakyStrategyDefinition.with_detail(
448+
f"The {self.choice_types[i]} value was forced to "
449+
f"{self.values[i]!r} in the first run, but the second run "
450+
f"drew {new_value!r}.\n"
451+
)
455452

456453
assert not self.is_exhausted
457454

@@ -1050,17 +1047,25 @@ def draw_value(
10501047
choice_type != node.choice_types[i]
10511048
or constraints != node.constraints[i]
10521049
):
1053-
raise FlakyStrategyDefinition(_FLAKY_STRAT_MSG)
1050+
raise FlakyStrategyDefinition.from_mismatch(
1051+
node.choice_types[i],
1052+
node.constraints[i],
1053+
choice_type,
1054+
constraints,
1055+
)
10541056
# Note that we don't check whether a previously
10551057
# forced value is now free. That will be caught
10561058
# if we ever split the node there, but otherwise
10571059
# may pass silently. This is acceptable because it
10581060
# means we skip a hash set lookup on every
10591061
# draw and that's a pretty niche failure mode.
10601062
if was_forced and i not in node.forced:
1061-
raise FlakyStrategyDefinition(_FLAKY_STRAT_MSG)
1063+
raise FlakyStrategyDefinition.with_detail(
1064+
f"The {choice_type} value was forced to a specific value "
1065+
f"but was not forced on the first run.\n"
1066+
)
10621067
if value != node.values[i]:
1063-
node.split_at(i)
1068+
node.split_at(i, new_value=value)
10641069
assert i == len(node.values)
10651070
new_node = TreeNode()
10661071
assert isinstance(node.transition, Branch)
@@ -1095,19 +1100,26 @@ def draw_value(
10951100
compute_max_children(choice_type, constraints) == 1
10961101
and not was_forced
10971102
):
1098-
node.split_at(i)
1103+
node.split_at(i, new_value=value)
10991104
assert isinstance(node.transition, Branch)
11001105
self._current_node = node.transition.children[value]
11011106
self._index_in_current_node = 0
11021107
elif isinstance(trans, Conclusion):
11031108
assert trans.status != Status.OVERRUN
11041109
# We tried to draw where history says we should have
11051110
# stopped
1106-
raise FlakyStrategyDefinition(_FLAKY_STRAT_MSG)
1111+
raise FlakyStrategyDefinition.with_detail(
1112+
"The second run drew more data than the first run.\n"
1113+
)
11071114
else:
11081115
assert isinstance(trans, Branch), trans
11091116
if choice_type != trans.choice_type or constraints != trans.constraints:
1110-
raise FlakyStrategyDefinition(_FLAKY_STRAT_MSG)
1117+
raise FlakyStrategyDefinition.from_mismatch(
1118+
trans.choice_type,
1119+
trans.constraints,
1120+
choice_type,
1121+
constraints,
1122+
)
11111123
try:
11121124
self._current_node = trans.children[value]
11131125
except KeyError:
@@ -1127,7 +1139,10 @@ def kill_branch(self) -> None:
11271139
self._current_node.transition is not None
11281140
and not isinstance(self._current_node.transition, Killed)
11291141
):
1130-
raise FlakyStrategyDefinition(_FLAKY_STRAT_MSG)
1142+
raise FlakyStrategyDefinition.with_detail(
1143+
"The second run stopped drawing earlier than the first run, "
1144+
"which continued to draw more data.\n"
1145+
)
11311146

11321147
if self._current_node.transition is None:
11331148
self._current_node.transition = Killed(TreeNode())
@@ -1148,7 +1163,10 @@ def conclude_test(
11481163
node = self._current_node
11491164

11501165
if i < len(node.values) or isinstance(node.transition, Branch):
1151-
raise FlakyStrategyDefinition(_FLAKY_STRAT_MSG)
1166+
raise FlakyStrategyDefinition.with_detail(
1167+
"The second run stopped drawing earlier than the first run, "
1168+
"which continued to draw more data.\n"
1169+
)
11521170

11531171
new_transition = Conclusion(status, interesting_origin)
11541172

hypothesis/src/hypothesis/internal/conjecture/engine.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from hypothesis.errors import (
2828
BackendCannotProceed,
2929
FlakyBackendFailure,
30+
FlakyStrategyDefinition,
3031
HypothesisException,
3132
InvalidArgument,
3233
StopTest,
@@ -562,8 +563,15 @@ def _backend_cannot_proceed(
562563
interrupted = True
563564
data.freeze()
564565
return
565-
except BaseException:
566+
except BaseException as err:
566567
data.freeze()
568+
if isinstance(err, FlakyStrategyDefinition) and data._stateful_repr_parts:
569+
# In a stateful test, surface the steps leading up to the
570+
# inconsistency.
571+
report(
572+
"Steps leading up to this error:\n"
573+
+ "\n".join(f" {s}" for s in data._stateful_repr_parts)
574+
)
567575
if self.settings.backend != "hypothesis":
568576
try:
569577
realize_choices(data, for_failure=True)

hypothesis/src/hypothesis/stateful.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,7 @@ def run_state_machine(data):
132132
def output(s):
133133
if print_steps:
134134
report(s)
135-
if observability_enabled():
136-
cd._stateful_repr_parts.append(s)
135+
cd._stateful_repr_parts.append(s)
137136

138137
try:
139138
output(f"state = {machine.__class__.__name__}()")

hypothesis/tests/conjecture/test_data_tree.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ def test_concluding_at_prefix_is_flaky():
226226
data.conclude_test(Status.INTERESTING)
227227

228228
data = ConjectureData.for_choices([], observer=tree.new_observer())
229-
with pytest.raises(Flaky):
229+
with pytest.raises(Flaky, match="stopped drawing earlier"):
230230
data.conclude_test(Status.INVALID)
231231

232232

@@ -250,7 +250,7 @@ def test_changing_n_bits_is_flaky_in_prefix():
250250
data.conclude_test(Status.INTERESTING)
251251

252252
data = ConjectureData.for_choices((1,), observer=tree.new_observer())
253-
with pytest.raises(Flaky):
253+
with pytest.raises(Flaky, match="different constraints"):
254254
data.draw_integer(0, 3)
255255

256256

@@ -264,7 +264,7 @@ def test_changing_n_bits_is_flaky_in_branch():
264264
data.conclude_test(Status.INTERESTING)
265265

266266
data = ConjectureData.for_choices((1,), observer=tree.new_observer())
267-
with pytest.raises(Flaky):
267+
with pytest.raises(Flaky, match="different constraints"):
268268
data.draw_integer(0, 3)
269269

270270

@@ -278,7 +278,7 @@ def test_extending_past_conclusion_is_flaky():
278278
data = ConjectureData.for_choices((True, False), observer=tree.new_observer())
279279
data.draw_boolean()
280280

281-
with pytest.raises(Flaky):
281+
with pytest.raises(Flaky, match="more data"):
282282
data.draw_boolean()
283283

284284

@@ -291,7 +291,7 @@ def test_changing_to_forced_is_flaky():
291291

292292
data = ConjectureData.for_choices((True, False), observer=tree.new_observer())
293293

294-
with pytest.raises(Flaky):
294+
with pytest.raises(Flaky, match="was not forced on the first run"):
295295
data.draw_boolean(forced=True)
296296

297297

@@ -304,10 +304,22 @@ def test_changing_value_of_forced_is_flaky():
304304

305305
data = ConjectureData.for_choices((True, False), observer=tree.new_observer())
306306

307-
with pytest.raises(Flaky):
307+
with pytest.raises(Flaky, match=r"forced to True.*drew False"):
308308
data.draw_boolean(forced=False)
309309

310310

311+
def test_drawing_different_type_is_flaky():
312+
tree = DataTree()
313+
data = ConjectureData.for_choices((1,), observer=tree.new_observer())
314+
data.draw_integer(0, 1)
315+
with pytest.raises(StopTest):
316+
data.conclude_test(Status.INTERESTING)
317+
318+
data = ConjectureData.for_choices((True,), observer=tree.new_observer())
319+
with pytest.raises(Flaky, match="different type"):
320+
data.draw_boolean()
321+
322+
311323
def test_does_not_truncate_if_unseen():
312324
tree = DataTree()
313325
nodes = (1, 2, 3, 4)

0 commit comments

Comments
 (0)