Skip to content

Commit d4dbe77

Browse files
authored
Merge pull request #12409 from bluetech/reorder-items-perf
fixtures: fix catastrophic performance problem in `reorder_items`
2 parents 3433c7a + e89d23b commit d4dbe77

File tree

3 files changed

+95
-64
lines changed

3 files changed

+95
-64
lines changed

changelog/12355.bugfix.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix possible catastrophic performance slowdown on a certain parametrization pattern involving many higher-scoped parameters.

src/_pytest/fixtures.py

+75-64
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@
2121
from typing import Iterable
2222
from typing import Iterator
2323
from typing import List
24+
from typing import Mapping
2425
from typing import MutableMapping
2526
from typing import NoReturn
2627
from typing import Optional
28+
from typing import OrderedDict
2729
from typing import overload
2830
from typing import Sequence
2931
from typing import Set
@@ -76,8 +78,6 @@
7678

7779

7880
if TYPE_CHECKING:
79-
from typing import Deque
80-
8181
from _pytest.main import Session
8282
from _pytest.python import CallSpec2
8383
from _pytest.python import Function
@@ -161,6 +161,12 @@ def getfixturemarker(obj: object) -> Optional["FixtureFunctionMarker"]:
161161
)
162162

163163

164+
# Algorithm for sorting on a per-parametrized resource setup basis.
165+
# It is called for Session scope first and performs sorting
166+
# down to the lower scopes such as to minimize number of "high scope"
167+
# setups and teardowns.
168+
169+
164170
@dataclasses.dataclass(frozen=True)
165171
class FixtureArgKey:
166172
argname: str
@@ -169,97 +175,95 @@ class FixtureArgKey:
169175
item_cls: Optional[type]
170176

171177

172-
def get_parametrized_fixture_keys(
178+
_V = TypeVar("_V")
179+
OrderedSet = Dict[_V, None]
180+
181+
182+
def get_parametrized_fixture_argkeys(
173183
item: nodes.Item, scope: Scope
174184
) -> Iterator[FixtureArgKey]:
175185
"""Return list of keys for all parametrized arguments which match
176186
the specified scope."""
177187
assert scope is not Scope.Function
188+
178189
try:
179190
callspec: CallSpec2 = item.callspec # type: ignore[attr-defined]
180191
except AttributeError:
181192
return
193+
194+
item_cls = None
195+
if scope is Scope.Session:
196+
scoped_item_path = None
197+
elif scope is Scope.Package:
198+
# Package key = module's directory.
199+
scoped_item_path = item.path.parent
200+
elif scope is Scope.Module:
201+
scoped_item_path = item.path
202+
elif scope is Scope.Class:
203+
scoped_item_path = item.path
204+
item_cls = item.cls # type: ignore[attr-defined]
205+
else:
206+
assert_never(scope)
207+
182208
for argname in callspec.indices:
183209
if callspec._arg2scope[argname] != scope:
184210
continue
185-
186-
item_cls = None
187-
if scope is Scope.Session:
188-
scoped_item_path = None
189-
elif scope is Scope.Package:
190-
# Package key = module's directory.
191-
scoped_item_path = item.path.parent
192-
elif scope is Scope.Module:
193-
scoped_item_path = item.path
194-
elif scope is Scope.Class:
195-
scoped_item_path = item.path
196-
item_cls = item.cls # type: ignore[attr-defined]
197-
else:
198-
assert_never(scope)
199-
200211
param_index = callspec.indices[argname]
201212
yield FixtureArgKey(argname, param_index, scoped_item_path, item_cls)
202213

203214

204-
# Algorithm for sorting on a per-parametrized resource setup basis.
205-
# It is called for Session scope first and performs sorting
206-
# down to the lower scopes such as to minimize number of "high scope"
207-
# setups and teardowns.
208-
209-
210215
def reorder_items(items: Sequence[nodes.Item]) -> List[nodes.Item]:
211-
argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[FixtureArgKey, None]]] = {}
212-
items_by_argkey: Dict[Scope, Dict[FixtureArgKey, Deque[nodes.Item]]] = {}
216+
argkeys_by_item: Dict[Scope, Dict[nodes.Item, OrderedSet[FixtureArgKey]]] = {}
217+
items_by_argkey: Dict[
218+
Scope, Dict[FixtureArgKey, OrderedDict[nodes.Item, None]]
219+
] = {}
213220
for scope in HIGH_SCOPES:
214-
scoped_argkeys_cache = argkeys_cache[scope] = {}
215-
scoped_items_by_argkey = items_by_argkey[scope] = defaultdict(deque)
221+
scoped_argkeys_by_item = argkeys_by_item[scope] = {}
222+
scoped_items_by_argkey = items_by_argkey[scope] = defaultdict(OrderedDict)
216223
for item in items:
217-
keys = dict.fromkeys(get_parametrized_fixture_keys(item, scope), None)
218-
if keys:
219-
scoped_argkeys_cache[item] = keys
220-
for key in keys:
221-
scoped_items_by_argkey[key].append(item)
222-
items_dict = dict.fromkeys(items, None)
224+
argkeys = dict.fromkeys(get_parametrized_fixture_argkeys(item, scope))
225+
if argkeys:
226+
scoped_argkeys_by_item[item] = argkeys
227+
for argkey in argkeys:
228+
scoped_items_by_argkey[argkey][item] = None
229+
230+
items_set = dict.fromkeys(items)
223231
return list(
224-
reorder_items_atscope(items_dict, argkeys_cache, items_by_argkey, Scope.Session)
232+
reorder_items_atscope(
233+
items_set, argkeys_by_item, items_by_argkey, Scope.Session
234+
)
225235
)
226236

227237

228-
def fix_cache_order(
229-
item: nodes.Item,
230-
argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[FixtureArgKey, None]]],
231-
items_by_argkey: Dict[Scope, Dict[FixtureArgKey, "Deque[nodes.Item]"]],
232-
) -> None:
233-
for scope in HIGH_SCOPES:
234-
for key in argkeys_cache[scope].get(item, []):
235-
items_by_argkey[scope][key].appendleft(item)
236-
237-
238238
def reorder_items_atscope(
239-
items: Dict[nodes.Item, None],
240-
argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[FixtureArgKey, None]]],
241-
items_by_argkey: Dict[Scope, Dict[FixtureArgKey, "Deque[nodes.Item]"]],
239+
items: OrderedSet[nodes.Item],
240+
argkeys_by_item: Mapping[Scope, Mapping[nodes.Item, OrderedSet[FixtureArgKey]]],
241+
items_by_argkey: Mapping[
242+
Scope, Mapping[FixtureArgKey, OrderedDict[nodes.Item, None]]
243+
],
242244
scope: Scope,
243-
) -> Dict[nodes.Item, None]:
245+
) -> OrderedSet[nodes.Item]:
244246
if scope is Scope.Function or len(items) < 3:
245247
return items
246-
ignore: Set[Optional[FixtureArgKey]] = set()
247-
items_deque = deque(items)
248-
items_done: Dict[nodes.Item, None] = {}
248+
249249
scoped_items_by_argkey = items_by_argkey[scope]
250-
scoped_argkeys_cache = argkeys_cache[scope]
250+
scoped_argkeys_by_item = argkeys_by_item[scope]
251+
252+
ignore: Set[FixtureArgKey] = set()
253+
items_deque = deque(items)
254+
items_done: OrderedSet[nodes.Item] = {}
251255
while items_deque:
252-
no_argkey_group: Dict[nodes.Item, None] = {}
256+
no_argkey_items: OrderedSet[nodes.Item] = {}
253257
slicing_argkey = None
254258
while items_deque:
255259
item = items_deque.popleft()
256-
if item in items_done or item in no_argkey_group:
260+
if item in items_done or item in no_argkey_items:
257261
continue
258262
argkeys = dict.fromkeys(
259-
(k for k in scoped_argkeys_cache.get(item, []) if k not in ignore), None
263+
k for k in scoped_argkeys_by_item.get(item, ()) if k not in ignore
260264
)
261265
if not argkeys:
262-
no_argkey_group[item] = None
266+
no_argkey_items[item] = None
263267
else:
264268
slicing_argkey, _ = argkeys.popitem()
265269
# We don't have to remove relevant items from later in the
@@ -268,16 +272,23 @@ def reorder_items_atscope(
268272
i for i in scoped_items_by_argkey[slicing_argkey] if i in items
269273
]
270274
for i in reversed(matching_items):
271-
fix_cache_order(i, argkeys_cache, items_by_argkey)
272275
items_deque.appendleft(i)
276+
# Fix items_by_argkey order.
277+
for other_scope in HIGH_SCOPES:
278+
other_scoped_items_by_argkey = items_by_argkey[other_scope]
279+
for argkey in argkeys_by_item[other_scope].get(i, ()):
280+
other_scoped_items_by_argkey[argkey][i] = None
281+
other_scoped_items_by_argkey[argkey].move_to_end(
282+
i, last=False
283+
)
273284
break
274-
if no_argkey_group:
275-
no_argkey_group = reorder_items_atscope(
276-
no_argkey_group, argkeys_cache, items_by_argkey, scope.next_lower()
285+
if no_argkey_items:
286+
reordered_no_argkey_items = reorder_items_atscope(
287+
no_argkey_items, argkeys_by_item, items_by_argkey, scope.next_lower()
277288
)
278-
for item in no_argkey_group:
279-
items_done[item] = None
280-
ignore.add(slicing_argkey)
289+
items_done.update(reordered_no_argkey_items)
290+
if slicing_argkey is not None:
291+
ignore.add(slicing_argkey)
281292
return items_done
282293

283294

testing/python/fixtures.py

+19
Original file line numberDiff line numberDiff line change
@@ -2219,6 +2219,25 @@ def test_check():
22192219
reprec = pytester.inline_run("-s")
22202220
reprec.assertoutcome(passed=2)
22212221

2222+
def test_reordering_catastrophic_performance(self, pytester: Pytester) -> None:
2223+
"""Check that a certain high-scope parametrization pattern doesn't cause
2224+
a catasrophic slowdown.
2225+
2226+
Regression test for #12355.
2227+
"""
2228+
pytester.makepyfile("""
2229+
import pytest
2230+
2231+
params = tuple("abcdefghijklmnopqrstuvwxyz")
2232+
@pytest.mark.parametrize(params, [range(len(params))] * 3, scope="module")
2233+
def test_parametrize(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v, w, x, y, z):
2234+
pass
2235+
""")
2236+
2237+
result = pytester.runpytest()
2238+
2239+
result.assert_outcomes(passed=3)
2240+
22222241

22232242
class TestFixtureMarker:
22242243
def test_parametrize(self, pytester: Pytester) -> None:

0 commit comments

Comments
 (0)