Skip to content

Commit 41bb6e8

Browse files
authored
Merge pull request #4266 from tybug/stateful-pprint
Improve pprinting of stateful examples
2 parents 6349b42 + 651df46 commit 41bb6e8

File tree

3 files changed

+161
-11
lines changed

3 files changed

+161
-11
lines changed

hypothesis-python/RELEASE.rst

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
RELEASE_TYPE: patch
2+
3+
Improve the clarity of printing counterexamples in :doc:`stateful testing <stateful>`, by avoiding confusing :class:`~hypothesis.stateful.Bundle` references with equivalent values drawn from a regular strategy.
4+
5+
For example, we now print:
6+
7+
.. code-block: python
8+
9+
a_0 = state.add_to_bundle(a=0)
10+
state.unrelated(value=0)
11+
12+
instead of
13+
14+
.. code-block: python
15+
16+
a_0 = state.add_to_bundle(a=0)
17+
state.unrelated(value=a_0)
18+
19+
if the ``unrelated`` rule draws from a regular strategy such as :func:`~hypothesis.strategies.integers` instead of the ``a`` bundle.

hypothesis-python/src/hypothesis/stateful.py

+18-1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,20 @@
6565
SHOULD_CONTINUE_LABEL = cu.calc_label_from_name("should we continue drawing")
6666

6767

68+
def _is_singleton(obj: object) -> bool:
69+
"""
70+
Returns True if two separately created instances of v will have the same id
71+
(due to interning).
72+
"""
73+
# The range [-5, 256] is a cpython implementation detail. This may not work
74+
# well on other platforms.
75+
if isinstance(obj, int) and -5 <= obj <= 256:
76+
return True
77+
# cpython also interns compile-time strings, but let's just ignore those for
78+
# now.
79+
return isinstance(obj, bool) or obj is None
80+
81+
6882
class _OmittedArgument:
6983
"""Sentinel class to prevent overlapping overloads in type hints. See comments
7084
above the overloads of @rule."""
@@ -395,7 +409,10 @@ def _add_result_to_targets(self, targets, result):
395409
def printer(obj, p, cycle, name=name):
396410
return p.text(name)
397411

398-
self.__printer.singleton_pprinters.setdefault(id(result), printer)
412+
# see
413+
# https://github.com/HypothesisWorks/hypothesis/pull/4266#discussion_r1949619102
414+
if not _is_singleton(result):
415+
self.__printer.singleton_pprinters.setdefault(id(result), printer)
399416
self.names_to_values[name] = result
400417
self.bundles.setdefault(target, []).append(VarReference(name))
401418

hypothesis-python/tests/nocover/test_stateful.py

+124-10
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
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+
import inspect
1112
from collections import namedtuple
1213

1314
import pytest
1415

15-
from hypothesis import settings as Settings
16+
from hypothesis import Phase, settings as Settings, strategies as st
1617
from hypothesis.stateful import (
1718
Bundle,
1819
RuleBasedStateMachine,
@@ -21,7 +22,25 @@
2122
rule,
2223
run_state_machine_as_test,
2324
)
24-
from hypothesis.strategies import booleans, integers, lists
25+
26+
27+
def run_to_notes(TestClass):
28+
TestCase = TestClass.TestCase
29+
# don't add explain phase notes to the error
30+
TestCase.settings = Settings(phases=set(Phase) - {Phase.explain})
31+
try:
32+
TestCase().runTest()
33+
except AssertionError as err:
34+
return err.__notes__
35+
36+
raise RuntimeError("Expected an assertion error")
37+
38+
39+
def assert_runs_to_output(TestClass, output):
40+
# remove the first line, which is always "Falsfying example:"
41+
actual = "\n".join(run_to_notes(TestClass)[1:])
42+
assert actual == inspect.cleandoc(output.strip())
43+
2544

2645
Leaf = namedtuple("Leaf", ("label",))
2746
Split = namedtuple("Split", ("left", "right"))
@@ -30,7 +49,7 @@
3049
class BalancedTrees(RuleBasedStateMachine):
3150
trees = Bundle("BinaryTree")
3251

33-
@rule(target=trees, x=booleans())
52+
@rule(target=trees, x=st.booleans())
3453
def leaf(self, x):
3554
return Leaf(x)
3655

@@ -81,7 +100,7 @@ def is_not_too_deep(self, check):
81100
class RoseTreeStateMachine(RuleBasedStateMachine):
82101
nodes = Bundle("nodes")
83102

84-
@rule(target=nodes, source=lists(nodes))
103+
@rule(target=nodes, source=st.lists(nodes))
85104
def bunch(self, source):
86105
return source
87106

@@ -149,7 +168,7 @@ def __init__(self):
149168
# achieve "swarming" by by just restricting the alphabet for single byte
150169
# decisions, which is a thing the underlying conjecture engine will
151170
# happily do on its own without knowledge of the rule structure.
152-
@rule(move=integers(0, 255))
171+
@rule(move=st.integers(0, 255))
153172
def ladder(self, move):
154173
self.seen.add(move)
155174
assert len(self.seen) <= 15
@@ -213,29 +232,29 @@ class TestMyStatefulMachine(MyStatefulMachine.TestCase):
213232
def test_multiple_precondition_bug():
214233
# See https://github.com/HypothesisWorks/hypothesis/issues/2861
215234
class MultiplePreconditionMachine(RuleBasedStateMachine):
216-
@rule(x=integers())
235+
@rule(x=st.integers())
217236
def good_method(self, x):
218237
pass
219238

220239
@precondition(lambda self: True)
221240
@precondition(lambda self: False)
222-
@rule(x=integers())
241+
@rule(x=st.integers())
223242
def bad_method_a(self, x):
224243
raise AssertionError("This rule runs, even though it shouldn't.")
225244

226245
@precondition(lambda self: False)
227246
@precondition(lambda self: True)
228-
@rule(x=integers())
247+
@rule(x=st.integers())
229248
def bad_method_b(self, x):
230249
raise AssertionError("This rule might be skipped for the wrong reason.")
231250

232251
@precondition(lambda self: True)
233-
@rule(x=integers())
252+
@rule(x=st.integers())
234253
@precondition(lambda self: False)
235254
def bad_method_c(self, x):
236255
raise AssertionError("This rule runs, even though it shouldn't.")
237256

238-
@rule(x=integers())
257+
@rule(x=st.integers())
239258
@precondition(lambda self: True)
240259
@precondition(lambda self: False)
241260
def bad_method_d(self, x):
@@ -266,3 +285,98 @@ def bad_invariant_d(self):
266285
raise AssertionError("This invariant runs, even though it shouldn't.")
267286

268287
run_state_machine_as_test(MultiplePreconditionMachine)
288+
289+
290+
class UnrelatedCall(RuleBasedStateMachine):
291+
a = Bundle("a")
292+
293+
def __init__(self):
294+
super().__init__()
295+
self.calls = set()
296+
297+
@rule(target=a, a=st.integers())
298+
def add_a(self, a):
299+
self.calls.add("add")
300+
return a
301+
302+
@rule(v=a)
303+
def f(self, v):
304+
self.calls.add("f")
305+
306+
@precondition(lambda self: "add" in self.calls)
307+
@rule(value=st.integers())
308+
def unrelated(self, value):
309+
self.calls.add("unrelated")
310+
311+
@rule()
312+
def invariant(self):
313+
# force all three calls to be made in a particular order (with the
314+
# `unrelated` precondition) so we always shrink to a particular counterexample.
315+
assert len(self.calls) != 3
316+
317+
318+
def test_unrelated_rule_does_not_use_var_reference_repr():
319+
# we are specifically looking for state.unrelated(value=0) not being replaced
320+
# with state.unrelated(value=a_0). The `unrelated` rule is drawing from
321+
# st.integers, not a bundle, so the values should not be conflated even if
322+
# they're both 0.
323+
assert_runs_to_output(
324+
UnrelatedCall,
325+
"""
326+
state = UnrelatedCall()
327+
a_0 = state.add_a(a=0)
328+
state.f(v=a_0)
329+
state.unrelated(value=0)
330+
state.invariant()
331+
state.teardown()
332+
""",
333+
)
334+
335+
336+
class SourceSameAsTarget(RuleBasedStateMachine):
337+
values = Bundle("values")
338+
339+
@rule(target=values, value=st.lists(values))
340+
def f(self, value):
341+
assert len(value) == 0
342+
return value
343+
344+
345+
class SourceSameAsTargetUnclearOrigin(RuleBasedStateMachine):
346+
values = Bundle("values")
347+
348+
def __init__(self):
349+
super().__init__()
350+
self.called = False
351+
352+
@rule(target=values, value=st.just([]) | st.lists(values))
353+
def f(self, value):
354+
assert not self.called
355+
# ensure we get two calls to f before failing. In the minimal failing
356+
# example, both will be from st.just([]).
357+
self.called = True
358+
return value
359+
360+
361+
def test_replaces_when_same_id():
362+
assert_runs_to_output(
363+
SourceSameAsTarget,
364+
f"""
365+
state = {SourceSameAsTarget.__name__}()
366+
values_0 = state.f(value=[])
367+
state.f(value=[values_0])
368+
state.teardown()
369+
""",
370+
)
371+
372+
373+
def test_doesnt_replace_when_different_id():
374+
assert_runs_to_output(
375+
SourceSameAsTargetUnclearOrigin,
376+
f"""
377+
state = {SourceSameAsTargetUnclearOrigin.__name__}()
378+
values_0 = state.f(value=[])
379+
state.f(value=[])
380+
state.teardown()
381+
""",
382+
)

0 commit comments

Comments
 (0)