Skip to content

Commit ddd4ef1

Browse files
committed
Use sorting by hashes to shuffle items
Fixes #210.
1 parent efee844 commit ddd4ef1

File tree

3 files changed

+70
-31
lines changed

3 files changed

+70
-31
lines changed

HISTORY.rst

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22
History
33
=======
44

5+
* Rework shuffling algorithm to use hashing. This means that running a subset
6+
of tests with the same seed will now produce the same ordering as running the
7+
full set of tests. This allows narrowing down ordering-related failures.
8+
9+
Thanks to Tom Grainger for the suggestion in `Issue #210
10+
<https://github.com/pytest-dev/pytest-randomly/issues/210>`__.
11+
512
3.9.0 (2021-08-12)
613
------------------
714

@@ -24,7 +31,7 @@ History
2431
* Fix deprecation warning from importlib-metadata 3.9.0+.
2532

2633
Thanks to Dominic Davis-Foster for report in `Issue #333
27-
<https://github.com/pytest-dev/pytest-randomly/issue/333>`__.
34+
<https://github.com/pytest-dev/pytest-randomly/issues/333>`__.
2835

2936
* Stop distributing tests to reduce package size. Tests are not intended to be
3037
run outside of the tox setup in the repository. Repackagers can use GitHub's

src/pytest_randomly/__init__.py

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import sys
55
from itertools import groupby
66
from types import ModuleType
7-
from typing import Any, Callable, Dict, List, Optional, Type, TypeVar, Union
7+
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, TypeVar, Union
88

99
from _pytest.config import Config
1010
from _pytest.config.argparsing import Parser
@@ -134,7 +134,7 @@ def pytest_configure_node(self, node: Item) -> None:
134134
entrypoint_reseeds: Optional[List[Callable[[int], None]]] = None
135135

136136

137-
def _reseed(config: Config, offset: int = 0) -> None:
137+
def _reseed(config: Config, offset: int = 0) -> int:
138138
global entrypoint_reseeds
139139
seed = config.getoption("randomly_seed") + offset
140140
if seed not in random_states:
@@ -164,6 +164,8 @@ def _reseed(config: Config, offset: int = 0) -> None:
164164
for reseed in entrypoint_reseeds:
165165
reseed(seed)
166166

167+
return seed
168+
167169

168170
def _truncate_seed_for_numpy(seed: int) -> int:
169171
seed = abs(seed)
@@ -199,17 +201,26 @@ def pytest_collection_modifyitems(config: Config, items: List[Item]) -> None:
199201
if not config.getoption("randomly_reorganize"):
200202
return
201203

202-
_reseed(config)
204+
seed = _reseed(config)
203205

204-
module_items: List[List[Item]] = [
205-
list(group) for _key, group in groupby(items, _get_module)
206-
]
206+
modules_items: List[Tuple[Optional[ModuleType], List[Item]]] = []
207+
for module, group in groupby(items, _get_module):
208+
modules_items.append(
209+
(
210+
module,
211+
_shuffle_by_class(list(group), seed),
212+
)
213+
)
207214

208-
for sub_items in module_items:
209-
sub_items[:] = shuffle_by_class(sub_items)
210-
random.shuffle(module_items)
215+
def _module_key(module_item: Tuple[Optional[ModuleType], List[Item]]) -> bytes:
216+
module, _items = module_item
217+
if module is None:
218+
return _md5(f"{seed}::None")
219+
return _md5(f"{seed}::{module.__name__}")
211220

212-
items[:] = reduce_list_of_lists(module_items)
221+
modules_items.sort(key=_module_key)
222+
223+
items[:] = reduce_list_of_lists([subitems for module, subitems in modules_items])
213224

214225

215226
def _get_module(item: Item) -> Optional[ModuleType]:
@@ -219,16 +230,28 @@ def _get_module(item: Item) -> Optional[ModuleType]:
219230
return None
220231

221232

222-
def shuffle_by_class(items: List[Item]) -> List[Item]:
223-
class_items: List[List[Item]] = [
224-
list(group) for _key, group in groupby(items, _get_cls)
225-
]
233+
def _shuffle_by_class(items: List[Item], seed: int) -> List[Item]:
234+
klasses_items: List[Tuple[Optional[Type[Any]], List[Item]]] = []
226235

227-
for sub_items in class_items:
228-
random.shuffle(sub_items)
229-
random.shuffle(class_items)
236+
for klass, group in groupby(items, _get_cls):
237+
klass_items = [(_md5(f"{seed}::{item.nodeid}"), item) for item in group]
238+
klass_items.sort()
239+
klasses_items.append(
240+
(
241+
klass,
242+
[item for _key, item in klass_items],
243+
)
244+
)
230245

231-
return reduce_list_of_lists(class_items)
246+
def _cls_key(klass_items: Tuple[Optional[Type[Any]], List[Item]]) -> bytes:
247+
klass, items = klass_items
248+
if klass is None:
249+
return _md5(f"{seed}::None")
250+
return _md5(f"{seed}::{klass.__module__}.{klass.__qualname__}")
251+
252+
klasses_items.sort(key=_cls_key)
253+
254+
return reduce_list_of_lists([subitems for klass, subitems in klasses_items])
232255

233256

234257
def _get_cls(item: Item) -> Optional[Type[Any]]:
@@ -245,6 +268,12 @@ def reduce_list_of_lists(lists: List[List[T]]) -> List[T]:
245268
return new_list
246269

247270

271+
def _md5(string: str) -> bytes:
272+
hasher = hashlib.md5()
273+
hasher.update(string.encode())
274+
return hasher.digest()
275+
276+
248277
if have_faker:
249278

250279
@fixture(autouse=True)

tests/test_pytest_randomly.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -240,10 +240,10 @@ def test_it():
240240

241241
out.assert_outcomes(passed=4, failed=0)
242242
assert out.outlines[8:12] == [
243+
"test_b.py::test_it PASSED",
244+
"test_a.py::test_it PASSED",
243245
"test_d.py::test_it PASSED",
244246
"test_c.py::test_it PASSED",
245-
"test_a.py::test_it PASSED",
246-
"test_b.py::test_it PASSED",
247247
]
248248

249249

@@ -260,10 +260,10 @@ def test_it():
260260

261261
out.assert_outcomes(passed=4, failed=0)
262262
assert out.outlines[8:12] == [
263+
"test_b.py::test_it PASSED",
264+
"test_a.py::test_it PASSED",
263265
"test_d.py::test_it PASSED",
264266
"test_c.py::test_it PASSED",
265-
"test_a.py::test_it PASSED",
266-
"test_b.py::test_it PASSED",
267267
]
268268

269269

@@ -300,9 +300,9 @@ def test_d(self):
300300
out.assert_outcomes(passed=4, failed=0)
301301
assert out.outlines[8:12] == [
302302
"test_one.py::D::test_d PASSED",
303+
"test_one.py::B::test_b PASSED",
303304
"test_one.py::C::test_c PASSED",
304305
"test_one.py::A::test_a PASSED",
305-
"test_one.py::B::test_b PASSED",
306306
]
307307

308308

@@ -331,10 +331,10 @@ def test_d(self):
331331

332332
out.assert_outcomes(passed=4, failed=0)
333333
assert out.outlines[8:12] == [
334-
"test_one.py::T::test_d PASSED",
335334
"test_one.py::T::test_c PASSED",
336-
"test_one.py::T::test_a PASSED",
337335
"test_one.py::T::test_b PASSED",
336+
"test_one.py::T::test_a PASSED",
337+
"test_one.py::T::test_d PASSED",
338338
]
339339

340340

@@ -360,10 +360,10 @@ def test_d():
360360

361361
out.assert_outcomes(passed=4, failed=0)
362362
assert out.outlines[8:12] == [
363-
"test_one.py::test_d PASSED",
364363
"test_one.py::test_c PASSED",
365364
"test_one.py::test_a PASSED",
366365
"test_one.py::test_b PASSED",
366+
"test_one.py::test_d PASSED",
367367
]
368368

369369

@@ -394,10 +394,10 @@ def test_d():
394394

395395
out.assert_outcomes(passed=4, failed=0)
396396
assert out.outlines[8:12] == [
397-
"test_one.py::test_d PASSED",
398397
"test_one.py::test_c PASSED",
399398
"test_one.py::test_a PASSED",
400399
"test_one.py::test_b PASSED",
400+
"test_one.py::test_d PASSED",
401401
]
402402

403403

@@ -419,7 +419,7 @@ def bar():
419419
return 9002
420420
"""
421421
)
422-
args = ["-v", "--doctest-modules", "--randomly-seed=5"]
422+
args = ["-v", "--doctest-modules", "--randomly-seed=1"]
423423

424424
out = ourtestdir.runpytest(*args)
425425
out.assert_outcomes(passed=2)
@@ -432,6 +432,8 @@ def bar():
432432
def test_it_works_with_the_simplest_test_items(ourtestdir):
433433
ourtestdir.makepyfile(
434434
conftest="""
435+
import sys
436+
435437
import pytest
436438
437439
@@ -463,7 +465,8 @@ def pytest_collect_file(path, parent):
463465
items=[
464466
NoOpItem.from_parent(
465467
name=str(path) + "1",
466-
parent=parent, module="foo"
468+
parent=parent,
469+
module=sys.modules[__name__],
467470
),
468471
NoOpItem.from_parent(
469472
name=str(path) + "2",
@@ -492,7 +495,7 @@ def test_doctests_in_txt_files_reordered(ourtestdir):
492495
0
493496
"""
494497
)
495-
args = ["-v", "--randomly-seed=1"]
498+
args = ["-v", "--randomly-seed=2"]
496499

497500
out = ourtestdir.runpytest(*args)
498501
out.assert_outcomes(passed=2)

0 commit comments

Comments
 (0)