Skip to content

Commit 8ff380a

Browse files
authored
Merge pull request #4752 from Zac-HD/claude/blissful-hypatia-cw4TU
Vary iteration order of fixed_dictionaries output
2 parents 644dada + c1bfa4a commit 8ff380a

7 files changed

Lines changed: 39 additions & 22 deletions

File tree

hypothesis/RELEASE.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
RELEASE_TYPE: minor
2+
3+
:func:`~hypothesis.strategies.fixed_dictionaries` now varies the iteration
4+
order of the dicts it generates, rather than always placing the required keys
5+
first, to help find bugs in code which is sensitive to key order
6+
(:issue:`3906`). If you need a stable order, we recommend using `fixed_dictionaries(...).map(stable_sort_function)` or similar.

hypothesis/src/hypothesis/internal/conjecture/utils.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,19 @@ def identity(v: T) -> T:
7777
return v
7878

7979

80+
def fisher_yates_shuffle(data: "ConjectureData", ls: list[T]) -> None:
81+
"""Shuffle ``ls`` in place, drawing from ``data``.
82+
83+
Reversed Fisher-Yates shuffle: swap each element with itself or with a
84+
later element. This shrinks i==j for each element, i.e. towards no change,
85+
so a shuffled sequence shrinks back to its original order. We don't
86+
consider the last element as it's always a no-op.
87+
"""
88+
for i in range(len(ls) - 1):
89+
j = data.draw_integer(i, len(ls) - 1)
90+
ls[i], ls[j] = ls[j], ls[i]
91+
92+
8093
def check_sample(
8194
values: type[enum.Enum] | Sequence[T], strategy_name: str
8295
) -> Sequence[T]:

hypothesis/src/hypothesis/strategies/_internal/collections.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -396,11 +396,11 @@ def __init__(
396396
def do_draw(self, data: ConjectureData) -> dict[Any, Any]:
397397
context = current_build_context()
398398
arg_labels: ArgLabelsT = {}
399-
value = type(self.mapping)()
399+
pairs: list[tuple[Any, Any]] = []
400400

401401
for key, strategy in self.mapping.items():
402402
with context.track_arg_label(str(key)) as arg_label:
403-
value[key] = data.draw(strategy)
403+
pairs.append((key, data.draw(strategy)))
404404
arg_labels |= arg_label
405405

406406
if self.optional is not None:
@@ -416,9 +416,14 @@ def do_draw(self, data: ConjectureData) -> dict[Any, Any]:
416416
remaining[-1], remaining[j] = remaining[j], remaining[-1]
417417
key = remaining.pop()
418418
with context.track_arg_label(str(key)) as arg_label:
419-
value[key] = data.draw(self.optional[key])
419+
pairs.append((key, data.draw(self.optional[key])))
420420
arg_labels |= arg_label
421421

422+
# Vary the dict's iteration order (#3906). We shuffle after choosing
423+
# the optional keys, so only order varies, not the set of keys.
424+
cu.fisher_yates_shuffle(data, pairs)
425+
value = type(self.mapping)(pairs)
426+
422427
if arg_labels:
423428
context.known_object_printers[IDKey(value)].append(
424429
_fixeddict_pprinter(arg_labels, self.mapping)

hypothesis/src/hypothesis/strategies/_internal/core.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
calc_label_from_name,
8888
check_sample,
8989
combine_labels,
90+
fisher_yates_shuffle,
9091
identity,
9192
)
9293
from hypothesis.internal.entropy import get_seeder_and_restorer
@@ -1942,13 +1943,8 @@ def __init__(self, values):
19421943
self.values = values
19431944

19441945
def do_draw(self, data):
1945-
# Reversed Fisher-Yates shuffle: swap each element with itself or with
1946-
# a later element. This shrinks i==j for each element, i.e. to no
1947-
# change. We don't consider the last element as it's always a no-op.
19481946
result = list(self.values)
1949-
for i in range(len(result) - 1):
1950-
j = data.draw_integer(i, len(result) - 1)
1951-
result[i], result[j] = result[j], result[i]
1947+
fisher_yates_shuffle(data, result)
19521948
return result
19531949

19541950

hypothesis/src/hypothesis/vendor/pretty.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -975,8 +975,8 @@ def inner(obj: dict, p: RepresentationPrinter, cycle: bool) -> None:
975975
return p.text("{...}")
976976

977977
get = lambda k: _get_slice_comment(p, arg_labels, k)
978-
# Preserve mapping key order, then any optional keys (deduped)
979-
keys = list(dict.fromkeys(k for k in [*mapping, *obj] if k in obj))
978+
# Print in the dict's actual (possibly permuted) iteration order.
979+
keys = list(obj)
980980
has_comments = any(get(k) for k in keys)
981981

982982
with p.group(indent=4, open="{", close=""):

hypothesis/tests/cover/test_simple_collections.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
# obtain one at https://mozilla.org/MPL/2.0/.
1010

1111
from collections import OrderedDict
12-
from random import Random
1312

1413
import pytest
1514

@@ -28,7 +27,7 @@
2827
tuples,
2928
)
3029

31-
from tests.common.debug import assert_simple_property, find_any, minimal
30+
from tests.common.debug import find_any, minimal
3231
from tests.common.utils import flaky
3332

3433

@@ -79,13 +78,11 @@ def test_sets_are_size_bounded(xs):
7978
assert 2 <= len(xs) <= 10
8079

8180

82-
def test_ordered_dictionaries_preserve_keys():
83-
r = Random()
84-
keys = list(range(100))
85-
r.shuffle(keys)
86-
assert_simple_property(
87-
fixed_dictionaries(OrderedDict([(k, booleans()) for k in keys])),
88-
lambda x: list(x.keys()) == keys,
81+
def test_fixed_dictionaries_vary_key_order():
82+
keys = list(range(10))
83+
find_any(
84+
fixed_dictionaries({k: booleans() for k in keys}),
85+
lambda x: list(x.keys()) != keys,
8986
)
9087

9188

hypothesis/tests/nocover/test_testdecorators.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,6 @@ def bad_test():
5959

6060

6161
@given(data=st.data(), keys=st.lists(st.integers(), unique=True))
62-
def test_fixed_dict_preserves_iteration_order(data, keys):
62+
def test_fixed_dict_preserves_keys(data, keys):
6363
d = data.draw(st.fixed_dictionaries({k: st.none() for k in keys}))
64-
assert all(a == b for a, b in zip(keys, d, strict=True)), f"{keys=}, {d.keys()=}"
64+
assert set(d) == set(keys)

0 commit comments

Comments
 (0)