From 60d5cdba1737a23aa8c3ff68ecdf561a8ecafd97 Mon Sep 17 00:00:00 2001 From: James Prior Date: Wed, 20 Mar 2024 17:11:42 +0000 Subject: [PATCH 1/3] Fix recursive descent nondeterminism --- jsonpath_rfc9535/segments.py | 52 ++++++++++--------- .../utils/nondeterministic_descent.py | 18 +++++-- 2 files changed, 42 insertions(+), 28 deletions(-) diff --git a/jsonpath_rfc9535/segments.py b/jsonpath_rfc9535/segments.py index b286df9..b34c879 100644 --- a/jsonpath_rfc9535/segments.py +++ b/jsonpath_rfc9535/segments.py @@ -69,15 +69,8 @@ class JSONPathRecursiveDescentSegment(JSONPathSegment): def resolve(self, nodes: Iterable[JSONPathNode]) -> Iterable[JSONPathNode]: """Select descendants of each node in _nodes_.""" - # The nondeterministic visitor never generates a pre order traversal, so we - # still use the deterministic visitor 20% of the time, to cover all - # permutations. - # - # XXX: This feels like a bit of a hack. visitor = ( - self._nondeterministic_visit - if self.env.nondeterministic and random.random() < 0.8 # noqa: S311, PLR2004 - else self._visit + self._nondeterministic_visit if self.env.nondeterministic else self._visit ) for node in nodes: @@ -121,20 +114,18 @@ def _children(node: JSONPathNode) -> Iterable[JSONPathNode]: items = list(node.value.items()) random.shuffle(items) for name, val in items: - if isinstance(val, (dict, list)): - yield JSONPathNode( - value=val, - location=node.location + (name,), - root=node.root, - ) + yield JSONPathNode( + value=val, + location=node.location + (name,), + root=node.root, + ) elif isinstance(node.value, list): for i, element in enumerate(node.value): - if isinstance(element, (dict, list)): - yield JSONPathNode( - value=element, - location=node.location + (i,), - root=node.root, - ) + yield JSONPathNode( + value=element, + location=node.location + (i,), + root=node.root, + ) # (node, depth) tuples queue: Deque[Tuple[JSONPathNode, int]] = deque() @@ -143,22 +134,33 @@ def _children(node: JSONPathNode) -> Iterable[JSONPathNode]: queue.extend([(child, 1) for child in _children(root)]) # queue root's children while queue: - _node, depth = queue.popleft() + node, depth = queue.popleft() if depth >= self.env.max_recursion_depth: raise JSONPathRecursionError( "recursion limit exceeded", token=self.token ) - yield _node - + yield node # Visit child nodes now or queue them for later? visit_children = random.choice([True, False]) # noqa: S311 - for child in _children(_node): + for child in _children(node): if visit_children: yield child - queue.extend([(child, depth + 2) for child in _children(child)]) + # Randomly interleave grandchildren into queue + grandchildren = [(child, depth + 2) for child in _children(child)] + + queue = deque( + map( + next, + random.sample( + [iter(queue)] * len(queue) + + [iter(grandchildren)] * len(grandchildren), + len(queue) + len(grandchildren), + ), + ) + ) else: queue.append((child, depth + 1)) diff --git a/jsonpath_rfc9535/utils/nondeterministic_descent.py b/jsonpath_rfc9535/utils/nondeterministic_descent.py index 23cc0c1..d5eebf1 100644 --- a/jsonpath_rfc9535/utils/nondeterministic_descent.py +++ b/jsonpath_rfc9535/utils/nondeterministic_descent.py @@ -127,8 +127,8 @@ def nondeterministic_visit(root: AuxNode) -> Iterable[AuxNode]: use `pre_order_visit` in addition to `nondeterministic_visit` to get all permutations. Or use `all_perms()`. """ - queue: Deque[AuxNode] = deque(root.children) yield root + queue: Deque[AuxNode] = deque(root.children) while queue: _node = queue.popleft() @@ -138,7 +138,20 @@ def nondeterministic_visit(root: AuxNode) -> Iterable[AuxNode]: for child in _node.children: if visit_children: yield child - queue.extend(child.children) + + # Randomly interleave grandchildren into queue + grandchildren = child.children + + queue = deque( + map( + next, + random.sample( + [iter(queue)] * len(queue) + + [iter(grandchildren)] * len(grandchildren), + len(queue) + len(grandchildren), + ), + ) + ) else: queue.append(child) @@ -146,5 +159,4 @@ def nondeterministic_visit(root: AuxNode) -> Iterable[AuxNode]: def all_perms(root: AuxNode) -> List[Tuple[AuxNode, ...]]: """Return a list of valid permutations for the auxiliary tree _root_.""" perms = {tuple(nondeterministic_visit(root)) for _ in range(1000)} - perms.add(tuple(pre_order_visit(root))) return sorted(perms, key=lambda t: str(t)) From e55ea0d0509bb046de3dc43ae852258dbcc42038 Mon Sep 17 00:00:00 2001 From: James Prior Date: Thu, 21 Mar 2024 07:35:14 +0000 Subject: [PATCH 2/3] Add test cases from glyn/jsonpath-nondeterminism --- tests/test_cts_nondeterminism.py | 83 ++++++ tests/test_nondeterminism.py | 474 +++++++++++++++++++++++++++---- 2 files changed, 502 insertions(+), 55 deletions(-) create mode 100644 tests/test_cts_nondeterminism.py diff --git a/tests/test_cts_nondeterminism.py b/tests/test_cts_nondeterminism.py new file mode 100644 index 0000000..91a4b55 --- /dev/null +++ b/tests/test_cts_nondeterminism.py @@ -0,0 +1,83 @@ +"""Test against the JSONPath Compliance Test Suite with nondeterminism enabled. + +The CTS is a submodule located in /tests/cts. After a git clone, run +`git submodule update --init` from the root of the repository. +""" + +import json +import operator +from dataclasses import dataclass +from typing import Any +from typing import List +from typing import Optional +from typing import Tuple + +import pytest + +from jsonpath_rfc9535 import JSONPathEnvironment +from jsonpath_rfc9535 import JSONValue + + +@dataclass +class Case: + name: str + selector: str + document: JSONValue = None + result: Any = None + results: Optional[List[Any]] = None + invalid_selector: Optional[bool] = None + + +def cases() -> List[Case]: + with open("tests/cts/cts.json", encoding="utf8") as fd: + data = json.load(fd) + return [Case(**case) for case in data["tests"]] + + +def valid_cases() -> List[Case]: + return [case for case in cases() if not case.invalid_selector] + + +def nondeterministic_cases() -> List[Case]: + return [case for case in valid_cases() if isinstance(case.results, list)] + + +class MockEnv(JSONPathEnvironment): + nondeterministic = True + + +@pytest.mark.parametrize("case", valid_cases(), ids=operator.attrgetter("name")) +def test_nondeterminism_valid_cases(case: Case) -> None: + assert case.document is not None + env = MockEnv() + rv = env.find(case.selector, case.document).values() + + if case.results is not None: + assert rv in case.results + else: + assert rv == case.result + + +@pytest.mark.parametrize( + "case", nondeterministic_cases(), ids=operator.attrgetter("name") +) +def test_nondeterminism(case: Case) -> None: + """Test that we agree with CTS when it comes to nondeterministic results.""" + assert case.document is not None + assert case.results is not None + + def _result_repr(rv: List[object]) -> Tuple[str, ...]: + """Return a hashable representation of a result list.""" + return tuple([str(value) for value in rv]) + + env = MockEnv() + + # Repeat enough times to has high probability that we've covered all + # valid permutations. + results = { + _result_repr(env.find(case.selector, case.document).values()) + for _ in range(1000) + } + + assert len(results) == len(case.results) + assert results == {_result_repr(result) for result in case.results} diff --git a/tests/test_nondeterminism.py b/tests/test_nondeterminism.py index 91a4b55..ca8d615 100644 --- a/tests/test_nondeterminism.py +++ b/tests/test_nondeterminism.py @@ -1,15 +1,43 @@ -"""Test against the JSONPath Compliance Test Suite with nondeterminism enabled. +"""Test cases derived from https://github.com/glyn/jsonpath-nondeterminism. -The CTS is a submodule located in /tests/cts. After a git clone, run -`git submodule update --init` from the root of the repository. +The test cases in this file are taken from glyn/jsonpath-nondeterminism and +its accompanying blog post, https://underlap.org/testing-non-determinism. The +license for which is included below. + +BSD 3-Clause License + +Copyright (c) 2024, Glyn Normington + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """ -import json +import dataclasses import operator -from dataclasses import dataclass from typing import Any from typing import List -from typing import Optional from typing import Tuple import pytest @@ -18,66 +46,402 @@ from jsonpath_rfc9535 import JSONValue -@dataclass -class Case: - name: str - selector: str - document: JSONValue = None - result: Any = None - results: Optional[List[Any]] = None - invalid_selector: Optional[bool] = None - - -def cases() -> List[Case]: - with open("tests/cts/cts.json", encoding="utf8") as fd: - data = json.load(fd) - return [Case(**case) for case in data["tests"]] - - -def valid_cases() -> List[Case]: - return [case for case in cases() if not case.invalid_selector] - - -def nondeterministic_cases() -> List[Case]: - return [case for case in valid_cases() if isinstance(case.results, list)] +class NondeterministicEnv(JSONPathEnvironment): + nondeterministic = True -class MockEnv(JSONPathEnvironment): - nondeterministic = True +@pytest.fixture() +def env() -> JSONPathEnvironment: + return NondeterministicEnv() -@pytest.mark.parametrize("case", valid_cases(), ids=operator.attrgetter("name")) -def test_nondeterminism_valid_cases(case: Case) -> None: - assert case.document is not None - env = MockEnv() - rv = env.find(case.selector, case.document).values() +@dataclasses.dataclass +class Case: + description: str + query: str + data: JSONValue + want: List[List[JSONValue]] - if case.results is not None: - assert rv in case.results - else: - assert rv == case.result +TEST_CASES: List[Case] = [ + Case( + description="interesting example", + query="$..[*]", + data=[[[1]], [2]], + want=[ + [[[1]], [2], [1], 1, 2], + [[[1]], [2], [1], 2, 1], + ], + ), + Case( + description="explosive example", + query="$..[*]", + data={"a": [5, 3, [{"j": 4}, {"k": 6}]], "o": {"j": 1, "k": 2}}, + want=[ + [ + {"j": 1, "k": 2}, + [5, 3, [{"j": 4}, {"k": 6}]], + 1, + 2, + 5, + 3, + [{"j": 4}, {"k": 6}], + {"j": 4}, + {"k": 6}, + 4, + 6, + ], + [ + {"j": 1, "k": 2}, + [5, 3, [{"j": 4}, {"k": 6}]], + 2, + 1, + 5, + 3, + [{"j": 4}, {"k": 6}], + {"j": 4}, + {"k": 6}, + 4, + 6, + ], + [ + {"j": 1, "k": 2}, + [5, 3, [{"j": 4}, {"k": 6}]], + 5, + 3, + [{"j": 4}, {"k": 6}], + {"j": 4}, + {"k": 6}, + 1, + 2, + 4, + 6, + ], + [ + {"j": 1, "k": 2}, + [5, 3, [{"j": 4}, {"k": 6}]], + 5, + 3, + [{"j": 4}, {"k": 6}], + {"j": 4}, + {"k": 6}, + 2, + 1, + 4, + 6, + ], + [ + {"j": 1, "k": 2}, + [5, 3, [{"j": 4}, {"k": 6}]], + 5, + 3, + [{"j": 4}, {"k": 6}], + {"j": 4}, + {"k": 6}, + 4, + 1, + 2, + 6, + ], + [ + {"j": 1, "k": 2}, + [5, 3, [{"j": 4}, {"k": 6}]], + 5, + 3, + [{"j": 4}, {"k": 6}], + {"j": 4}, + {"k": 6}, + 4, + 2, + 1, + 6, + ], + [ + {"j": 1, "k": 2}, + [5, 3, [{"j": 4}, {"k": 6}]], + 5, + 3, + [{"j": 4}, {"k": 6}], + {"j": 4}, + {"k": 6}, + 4, + 6, + 1, + 2, + ], + [ + {"j": 1, "k": 2}, + [5, 3, [{"j": 4}, {"k": 6}]], + 5, + 3, + [{"j": 4}, {"k": 6}], + {"j": 4}, + {"k": 6}, + 4, + 6, + 2, + 1, + ], + [ + {"j": 1, "k": 2}, + [5, 3, [{"j": 4}, {"k": 6}]], + 5, + 3, + [{"j": 4}, {"k": 6}], + 1, + 2, + {"j": 4}, + {"k": 6}, + 4, + 6, + ], + [ + {"j": 1, "k": 2}, + [5, 3, [{"j": 4}, {"k": 6}]], + 5, + 3, + [{"j": 4}, {"k": 6}], + 2, + 1, + {"j": 4}, + {"k": 6}, + 4, + 6, + ], + [ + [5, 3, [{"j": 4}, {"k": 6}]], + {"j": 1, "k": 2}, + 1, + 2, + 5, + 3, + [{"j": 4}, {"k": 6}], + {"j": 4}, + {"k": 6}, + 4, + 6, + ], + [ + [5, 3, [{"j": 4}, {"k": 6}]], + {"j": 1, "k": 2}, + 2, + 1, + 5, + 3, + [{"j": 4}, {"k": 6}], + {"j": 4}, + {"k": 6}, + 4, + 6, + ], + [ + [5, 3, [{"j": 4}, {"k": 6}]], + {"j": 1, "k": 2}, + 5, + 3, + [{"j": 4}, {"k": 6}], + {"j": 4}, + {"k": 6}, + 1, + 2, + 4, + 6, + ], + [ + [5, 3, [{"j": 4}, {"k": 6}]], + {"j": 1, "k": 2}, + 5, + 3, + [{"j": 4}, {"k": 6}], + {"j": 4}, + {"k": 6}, + 2, + 1, + 4, + 6, + ], + [ + [5, 3, [{"j": 4}, {"k": 6}]], + {"j": 1, "k": 2}, + 5, + 3, + [{"j": 4}, {"k": 6}], + {"j": 4}, + {"k": 6}, + 4, + 1, + 2, + 6, + ], + [ + [5, 3, [{"j": 4}, {"k": 6}]], + {"j": 1, "k": 2}, + 5, + 3, + [{"j": 4}, {"k": 6}], + {"j": 4}, + {"k": 6}, + 4, + 2, + 1, + 6, + ], + [ + [5, 3, [{"j": 4}, {"k": 6}]], + {"j": 1, "k": 2}, + 5, + 3, + [{"j": 4}, {"k": 6}], + {"j": 4}, + {"k": 6}, + 4, + 6, + 1, + 2, + ], + [ + [5, 3, [{"j": 4}, {"k": 6}]], + {"j": 1, "k": 2}, + 5, + 3, + [{"j": 4}, {"k": 6}], + {"j": 4}, + {"k": 6}, + 4, + 6, + 2, + 1, + ], + [ + [5, 3, [{"j": 4}, {"k": 6}]], + {"j": 1, "k": 2}, + 5, + 3, + [{"j": 4}, {"k": 6}], + 1, + 2, + {"j": 4}, + {"k": 6}, + 4, + 6, + ], + [ + [5, 3, [{"j": 4}, {"k": 6}]], + {"j": 1, "k": 2}, + 5, + 3, + [{"j": 4}, {"k": 6}], + 2, + 1, + {"j": 4}, + {"k": 6}, + 4, + 6, + ], + ], + ), + Case( + description="appendix example 1", + query="$[*]", + data={"j": 1, "k": 2}, + want=[ + [1, 2], + [2, 1], + ], + ), + Case( + description="appendix example 2", + query="$[*, *]", + data={"j": 1, "k": 2}, + want=[ + [1, 2, 1, 2], + [1, 2, 2, 1], + [2, 1, 1, 2], + [2, 1, 2, 1], + ], + ), + Case( + description="appendix example 3", + query="$[*][*]", + data={"x": {"a": 1, "b": 2}, "y": {"c": 3, "d": 4}}, + want=[ + [1, 2, 3, 4], + [1, 2, 4, 3], + [2, 1, 3, 4], + [2, 1, 4, 3], + [3, 4, 1, 2], + [3, 4, 2, 1], + [4, 3, 1, 2], + [4, 3, 2, 1], + ], + ), + Case( + description="appendix example 4", + query="$[*][*]", + data={"x": {"a": 1, "b": 2}, "y": [3, 4]}, + want=[ + [1, 2, 3, 4], + [2, 1, 3, 4], + [3, 4, 1, 2], + [3, 4, 2, 1], + ], + ), + Case( + description="appendix example 5", + query="$..[*]", + data={"x": 1, "y": 2}, + want=[ + [1, 2], + [2, 1], + ], + ), + Case( + description="appendix example 6", + query="$..[*]", + data={"x": [1], "y": [2]}, + want=[ + [[1], [2], 1, 2], + [[1], [2], 2, 1], + [[2], [1], 1, 2], + [[2], [1], 2, 1], + ], + ), + Case( + description="appendix example 7", + query="$..[*]", + data={"x": {"a": 1}, "y": [3]}, + want=[ + [{"a": 1}, [3], 1, 3], + [{"a": 1}, [3], 3, 1], + [[3], {"a": 1}, 1, 3], + [[3], {"a": 1}, 3, 1], + ], + ), + Case( + description="appendix example 8", + query="$..[*]", + data={"x": {"a": 1}, "y": {"c": 3}}, + want=[ + [{"a": 1}, {"c": 3}, 1, 3], + [{"a": 1}, {"c": 3}, 3, 1], + [{"c": 3}, {"a": 1}, 1, 3], + [{"c": 3}, {"a": 1}, 3, 1], + ], + ), +] -@pytest.mark.parametrize( - "case", nondeterministic_cases(), ids=operator.attrgetter("name") -) -def test_nondeterminism(case: Case) -> None: - """Test that we agree with CTS when it comes to nondeterministic results.""" - assert case.document is not None - assert case.results is not None - def _result_repr(rv: List[object]) -> Tuple[str, ...]: +@pytest.mark.parametrize("case", TEST_CASES, ids=operator.attrgetter("description")) +def test_nondeterminism(env: JSONPathEnvironment, case: Case) -> None: + def _result_repr(rv: List[Any]) -> Tuple[str, ...]: """Return a hashable representation of a result list.""" return tuple([str(value) for value in rv]) - env = MockEnv() - - # Repeat enough times to has high probability that we've covered all - # valid permutations. + # Repeat enough times so as to have a high probability that we've covered + # all valid permutations. results = { - _result_repr(env.find(case.selector, case.document).values()) - for _ in range(1000) + _result_repr(env.find(case.query, case.data).values()) for _ in range(1000) } - assert len(results) == len(case.results) - assert results == {_result_repr(result) for result in case.results} + assert len(results) == len(case.want) + assert results == {_result_repr(result) for result in case.want} From 2404d2c626fb5e01e7f4fea12302d58f08c65634 Mon Sep 17 00:00:00 2001 From: James Prior Date: Thu, 21 Mar 2024 07:52:06 +0000 Subject: [PATCH 3/3] Tidy --- jsonpath_rfc9535/segments.py | 73 +++++++++++-------- .../utils/nondeterministic_descent.py | 22 +++--- 2 files changed, 51 insertions(+), 44 deletions(-) diff --git a/jsonpath_rfc9535/segments.py b/jsonpath_rfc9535/segments.py index b34c879..b5a8b17 100644 --- a/jsonpath_rfc9535/segments.py +++ b/jsonpath_rfc9535/segments.py @@ -107,59 +107,50 @@ def _visit(self, node: JSONPathNode, depth: int = 1) -> Iterable[JSONPathNode]: def _nondeterministic_visit( self, root: JSONPathNode, - _: int = 1, + depth: int = 1, ) -> Iterable[JSONPathNode]: - def _children(node: JSONPathNode) -> Iterable[JSONPathNode]: - if isinstance(node.value, dict): - items = list(node.value.items()) - random.shuffle(items) - for name, val in items: - yield JSONPathNode( - value=val, - location=node.location + (name,), - root=node.root, - ) - elif isinstance(node.value, list): - for i, element in enumerate(node.value): - yield JSONPathNode( - value=element, - location=node.location + (i,), - root=node.root, - ) - + """Nondeterministic node traversal.""" # (node, depth) tuples queue: Deque[Tuple[JSONPathNode, int]] = deque() - yield root # visit the root node - queue.extend([(child, 1) for child in _children(root)]) # queue root's children + # Visit the root node + yield root + + # Queue root's children + queue.extend([(child, depth) for child in _nondeterministic_children(root)]) while queue: node, depth = queue.popleft() + yield node if depth >= self.env.max_recursion_depth: raise JSONPathRecursionError( "recursion limit exceeded", token=self.token ) - yield node - # Visit child nodes now or queue them for later? + # Randomly choose to visit child nodes now or queue them for later? visit_children = random.choice([True, False]) # noqa: S311 - for child in _children(node): + for child in _nondeterministic_children(node): if visit_children: yield child - # Randomly interleave grandchildren into queue - grandchildren = [(child, depth + 2) for child in _children(child)] + + # Queue grandchildren by randomly interleaving them into the + # queue while maintaining queue and grandchild order. + grandchildren = [ + (child, depth + 2) + for child in _nondeterministic_children(child) + ] queue = deque( - map( - next, - random.sample( + [ + next(n) + for n in random.sample( [iter(queue)] * len(queue) + [iter(grandchildren)] * len(grandchildren), len(queue) + len(grandchildren), - ), - ) + ) + ] ) else: queue.append((child, depth + 1)) @@ -176,3 +167,23 @@ def __eq__(self, __value: object) -> bool: def __hash__(self) -> int: return hash(("..", self.selectors, self.token)) + + +def _nondeterministic_children(node: JSONPathNode) -> Iterable[JSONPathNode]: + """Yield children of _node_ with nondeterministic object/dict iteration.""" + if isinstance(node.value, dict): + items = list(node.value.items()) + random.shuffle(items) + for name, val in items: + yield JSONPathNode( + value=val, + location=node.location + (name,), + root=node.root, + ) + elif isinstance(node.value, list): + for i, element in enumerate(node.value): + yield JSONPathNode( + value=element, + location=node.location + (i,), + root=node.root, + ) diff --git a/jsonpath_rfc9535/utils/nondeterministic_descent.py b/jsonpath_rfc9535/utils/nondeterministic_descent.py index d5eebf1..564fde1 100644 --- a/jsonpath_rfc9535/utils/nondeterministic_descent.py +++ b/jsonpath_rfc9535/utils/nondeterministic_descent.py @@ -121,36 +121,32 @@ def breadth_first_visit(node: AuxNode) -> Iterable[AuxNode]: def nondeterministic_visit(root: AuxNode) -> Iterable[AuxNode]: - """Generate nodes rooted at _node_ from a nondeterministic traversal. - - This tree visitor will never produce nodes in depth-first pre-order, so - use `pre_order_visit` in addition to `nondeterministic_visit` to get all - permutations. Or use `all_perms()`. - """ + """Generate nodes rooted at _node_ from a nondeterministic traversal.""" yield root queue: Deque[AuxNode] = deque(root.children) while queue: _node = queue.popleft() yield _node - # Visit child nodes now or queue them for later? + # Randomly choose to visit child nodes now or queue them for later? visit_children = random.choice([True, False]) for child in _node.children: if visit_children: yield child - # Randomly interleave grandchildren into queue + # Queue grandchildren by randomly interleaving them into the + # queue while maintaining queue and grandchild order. grandchildren = child.children queue = deque( - map( - next, - random.sample( + [ + next(n) + for n in random.sample( [iter(queue)] * len(queue) + [iter(grandchildren)] * len(grandchildren), len(queue) + len(grandchildren), - ), - ) + ) + ] ) else: queue.append(child)