Skip to content

Commit 59cebc9

Browse files
committed
respect forced status in datatree simulate for invalid nodes
this fixes a nasty flaky error that I spent many hours tracking down
1 parent f9de0d2 commit 59cebc9

File tree

3 files changed

+53
-16
lines changed

3 files changed

+53
-16
lines changed

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

+12-9
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@ class BooleanKWargs(TypedDict):
136136
IntegerKWargs, FloatKWargs, StringKWargs, BytesKWargs, BooleanKWargs
137137
]
138138
IRTypeName: TypeAlias = Literal["integer", "string", "boolean", "float", "bytes"]
139-
InvalidAt: TypeAlias = Tuple[IRTypeName, IRKWargsType]
139+
# ir_type, kwargs, forced
140+
InvalidAt: TypeAlias = Tuple[IRTypeName, IRKWargsType, Optional[IRType]]
140141

141142

142143
class ExtraInformation:
@@ -2084,7 +2085,7 @@ def draw_integer(
20842085
)
20852086

20862087
if self.ir_tree_nodes is not None and observe:
2087-
node = self._pop_ir_tree_node("integer", kwargs)
2088+
node = self._pop_ir_tree_node("integer", kwargs, forced=forced)
20882089
if forced is None:
20892090
assert isinstance(node.value, int)
20902091
forced = node.value
@@ -2141,7 +2142,7 @@ def draw_float(
21412142
)
21422143

21432144
if self.ir_tree_nodes is not None and observe:
2144-
node = self._pop_ir_tree_node("float", kwargs)
2145+
node = self._pop_ir_tree_node("float", kwargs, forced=forced)
21452146
if forced is None:
21462147
assert isinstance(node.value, float)
21472148
forced = node.value
@@ -2183,7 +2184,7 @@ def draw_string(
21832184
},
21842185
)
21852186
if self.ir_tree_nodes is not None and observe:
2186-
node = self._pop_ir_tree_node("string", kwargs)
2187+
node = self._pop_ir_tree_node("string", kwargs, forced=forced)
21872188
if forced is None:
21882189
assert isinstance(node.value, str)
21892190
forced = node.value
@@ -2219,7 +2220,7 @@ def draw_bytes(
22192220
kwargs: BytesKWargs = self._pooled_kwargs("bytes", {"size": size})
22202221

22212222
if self.ir_tree_nodes is not None and observe:
2222-
node = self._pop_ir_tree_node("bytes", kwargs)
2223+
node = self._pop_ir_tree_node("bytes", kwargs, forced=forced)
22232224
if forced is None:
22242225
assert isinstance(node.value, bytes)
22252226
forced = node.value
@@ -2261,7 +2262,7 @@ def draw_boolean(
22612262
kwargs: BooleanKWargs = self._pooled_kwargs("boolean", {"p": p})
22622263

22632264
if self.ir_tree_nodes is not None and observe:
2264-
node = self._pop_ir_tree_node("boolean", kwargs)
2265+
node = self._pop_ir_tree_node("boolean", kwargs, forced=forced)
22652266
if forced is None:
22662267
assert isinstance(node.value, bool)
22672268
forced = node.value
@@ -2302,7 +2303,9 @@ def _pooled_kwargs(self, ir_type, kwargs):
23022303
POOLED_KWARGS_CACHE[key] = kwargs
23032304
return kwargs
23042305

2305-
def _pop_ir_tree_node(self, ir_type: IRTypeName, kwargs: IRKWargsType) -> IRNode:
2306+
def _pop_ir_tree_node(
2307+
self, ir_type: IRTypeName, kwargs: IRKWargsType, *, forced: Optional[IRType]
2308+
) -> IRNode:
23062309
assert self.ir_tree_nodes is not None
23072310

23082311
if self._node_index == len(self.ir_tree_nodes):
@@ -2321,7 +2324,7 @@ def _pop_ir_tree_node(self, ir_type: IRTypeName, kwargs: IRKWargsType) -> IRNode
23212324
# (in fact, it is possible that giving up early here results in more time
23222325
# for useful shrinks to run).
23232326
if node.ir_type != ir_type:
2324-
invalid_at = (ir_type, kwargs)
2327+
invalid_at = (ir_type, kwargs, forced)
23252328
self.invalid_at = invalid_at
23262329
self.observer.mark_invalid(invalid_at)
23272330
self.mark_invalid(f"(internal) want a {ir_type} but have a {node.ir_type}")
@@ -2330,7 +2333,7 @@ def _pop_ir_tree_node(self, ir_type: IRTypeName, kwargs: IRKWargsType) -> IRNode
23302333
# that is allowed by the expected kwargs, then we can coerce this node
23312334
# into an aligned one by using its value. It's unclear how useful this is.
23322335
if not ir_value_permitted(node.value, node.ir_type, kwargs):
2333-
invalid_at = (ir_type, kwargs)
2336+
invalid_at = (ir_type, kwargs, forced)
23342337
self.invalid_at = invalid_at
23352338
self.observer.mark_invalid(invalid_at)
23362339
self.mark_invalid(f"(internal) got a {ir_type} but outside the valid range")

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

+4-4
Original file line numberDiff line numberDiff line change
@@ -840,8 +840,8 @@ def simulate_test_function(self, data):
840840
tree. This will likely change in future."""
841841
node = self.root
842842

843-
def draw(ir_type, kwargs, *, forced=None):
844-
if ir_type == "float" and forced is not None:
843+
def draw(ir_type, kwargs, *, forced=None, convert_forced=True):
844+
if ir_type == "float" and forced is not None and convert_forced:
845845
forced = int_to_float(forced)
846846

847847
draw_func = getattr(data, f"draw_{ir_type}")
@@ -866,9 +866,9 @@ def draw(ir_type, kwargs, *, forced=None):
866866
data.conclude_test(t.status, t.interesting_origin)
867867
elif node.transition is None:
868868
if node.invalid_at is not None:
869-
(ir_type, kwargs) = node.invalid_at
869+
(ir_type, kwargs, forced) = node.invalid_at
870870
try:
871-
draw(ir_type, kwargs)
871+
draw(ir_type, kwargs, forced=forced, convert_forced=False)
872872
except StopTest:
873873
if data.invalid_at is not None:
874874
raise

hypothesis-python/tests/conjecture/test_data_tree.py

+37-3
Original file line numberDiff line numberDiff line change
@@ -621,8 +621,8 @@ def test_datatree_repr(bool_kwargs, int_kwargs):
621621
)
622622

623623

624-
def _draw(data, node):
625-
return getattr(data, f"draw_{node.ir_type}")(**node.kwargs)
624+
def _draw(data, node, *, forced=None):
625+
return getattr(data, f"draw_{node.ir_type}")(**node.kwargs, forced=forced)
626626

627627

628628
@given(ir_nodes(), ir_nodes())
@@ -641,7 +641,7 @@ def test_misaligned_nodes_after_valid_draw(node, misaligned_node):
641641
tree.simulate_test_function(data)
642642
assert data.status is Status.INVALID
643643

644-
assert data.invalid_at == (node.ir_type, node.kwargs)
644+
assert data.invalid_at == (node.ir_type, node.kwargs, None)
645645

646646

647647
@given(ir_nodes(was_forced=False), ir_nodes(was_forced=False))
@@ -709,3 +709,37 @@ def test_simulate_non_invalid_conclude_is_unseen_behavior(node, misaligned_node)
709709
tree.simulate_test_function(data)
710710

711711
assert data.status is Status.OVERRUN
712+
713+
714+
@given(ir_nodes(), ir_nodes())
715+
@settings(suppress_health_check=[HealthCheck.too_slow])
716+
def test_simulating_inherits_invalid_forced_status(node, misaligned_node):
717+
assume(misaligned_node.ir_type != node.ir_type)
718+
719+
# we have some logic in DataTree.simulate_test_function to "peak ahead" and
720+
# make sure it simulates invalid nodes correctly. But if it does so without
721+
# respecting whether the invalid node was forced or not, and this simulation
722+
# is observed by an observer, this can cause flaky errors later due to a node
723+
# going from unforced to forced.
724+
725+
tree = DataTree()
726+
727+
def test_function(ir_nodes):
728+
data = ConjectureData.for_ir_tree(ir_nodes, observer=tree.new_observer())
729+
_draw(data, node)
730+
_draw(data, node, forced=node.value)
731+
732+
# (1) set up a misaligned node at index 1
733+
with pytest.raises(StopTest):
734+
test_function([node, misaligned_node])
735+
736+
# (2) simulate an aligned tree. the datatree peaks ahead here using invalid_at
737+
# due to (1).
738+
data = ConjectureData.for_ir_tree([node, node], observer=tree.new_observer())
739+
with pytest.raises(PreviouslyUnseenBehaviour):
740+
tree.simulate_test_function(data)
741+
742+
# (3) run the same aligned tree without simulating. this uses the actual test
743+
# function's draw and forced value. This would flaky error if it did not match
744+
# what the datatree peaked ahead with in (2).
745+
test_function([node, node])

0 commit comments

Comments
 (0)