Skip to content

Commit e6c01ca

Browse files
New solution
In pytest_collection_modifyitems using the global information being collected for reordering
1 parent a3b3906 commit e6c01ca

File tree

7 files changed

+740
-156
lines changed

7 files changed

+740
-156
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ Ross Lawley
312312
Ruaridh Williamson
313313
Russel Winder
314314
Ryan Wooden
315+
Sadra Barikbin
315316
Saiprasad Kale
316317
Samuel Colvin
317318
Samuel Dion-Girardeau

src/_pytest/fixtures.py

Lines changed: 138 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from typing import Dict
1616
from typing import Generator
1717
from typing import Generic
18+
from typing import Hashable
1819
from typing import Iterable
1920
from typing import Iterator
2021
from typing import List
@@ -146,78 +147,58 @@ def get_scope_node(
146147
assert_never(scope)
147148

148149

149-
# Used for storing artificial fixturedefs for direct parametrization.
150-
name2pseudofixturedef_key = StashKey[Dict[str, "FixtureDef[Any]"]]()
150+
def resolve_unique_values_and_their_indices_in_parametersets(
151+
argnames: Sequence[str],
152+
parametersets: Sequence[ParameterSet],
153+
) -> Tuple[Dict[str, List[object]], List[Tuple[int]]]:
154+
"""Resolve unique values and their indices in parameter sets. The index of a value
155+
is determined by when it appears in the possible values for the first time.
156+
For example, given ``argnames`` and ``parametersets`` below, the result would be:
157+
158+
::
159+
160+
argnames = ["A", "B", "C"]
161+
parametersets = [("a1", "b1", "c1"), ("a1", "b2", "c1"), ("a1", "b3", "c2")]
162+
result[0] = {"A": ["a1"], "B": ["b1", "b2", "b3"], "C": ["c1", "c2"]}
163+
result[1] = [(0, 0, 0), (0, 1, 0), (0, 2, 1)]
164+
165+
result is used in reordering `indirect`ly parametrized with multiple
166+
parameters or directly parametrized tests to keep items using the same fixture or
167+
pseudo-fixture values respectively, close together.
168+
169+
:param argnames:
170+
Argument names passed to ``parametrize()``.
171+
:param parametersets:
172+
The parameter sets, each containing a set of values corresponding
173+
to ``argnames``.
174+
:returns:
175+
Tuple of unique parameter values and their indices in parametersets.
176+
"""
177+
indices = []
178+
argname_value_indices_for_hashable_ones: Dict[str, Dict[object, int]] = defaultdict(dict)
179+
argvalues_count: Dict[str, int] = defaultdict(lambda: 0)
180+
unique_values: Dict[str, List[object]] = defaultdict(list)
181+
for i, argname in enumerate(argnames):
182+
argname_indices = []
183+
for parameterset in parametersets:
184+
value = parameterset.values[i]
185+
try:
186+
argname_indices.append(argname_value_indices_for_hashable_ones[argname][value])
187+
except KeyError: # New unique value
188+
argname_value_indices_for_hashable_ones[argname][value] = argvalues_count[argname]
189+
argname_indices.append(argvalues_count[argname])
190+
argvalues_count[argname] += 1
191+
unique_values[argname].append(value)
192+
except TypeError: # `value` is not hashable
193+
argname_indices.append(argvalues_count[argname])
194+
argvalues_count[argname] += 1
195+
unique_values[argname].append(value)
196+
indices.append(argname_indices)
197+
return unique_values, list(zip(*indices))
151198

152199

153-
def add_funcarg_pseudo_fixture_def(
154-
collector: nodes.Collector, metafunc: "Metafunc", fixturemanager: "FixtureManager"
155-
) -> None:
156-
# This function will transform all collected calls to functions
157-
# if they use direct funcargs (i.e. direct parametrization)
158-
# because we want later test execution to be able to rely on
159-
# an existing FixtureDef structure for all arguments.
160-
# XXX we can probably avoid this algorithm if we modify CallSpec2
161-
# to directly care for creating the fixturedefs within its methods.
162-
if not metafunc._calls[0].funcargs:
163-
# This function call does not have direct parametrization.
164-
return
165-
# Collect funcargs of all callspecs into a list of values.
166-
arg2params: Dict[str, List[object]] = {}
167-
arg2scope: Dict[str, Scope] = {}
168-
for callspec in metafunc._calls:
169-
for argname, argvalue in callspec.funcargs.items():
170-
assert argname not in callspec.params
171-
callspec.params[argname] = argvalue
172-
arg2params_list = arg2params.setdefault(argname, [])
173-
callspec.indices[argname] = len(arg2params_list)
174-
arg2params_list.append(argvalue)
175-
if argname not in arg2scope:
176-
scope = callspec._arg2scope.get(argname, Scope.Function)
177-
arg2scope[argname] = scope
178-
callspec.funcargs.clear()
179-
180-
# Register artificial FixtureDef's so that later at test execution
181-
# time we can rely on a proper FixtureDef to exist for fixture setup.
182-
arg2fixturedefs = metafunc._arg2fixturedefs
183-
for argname, valuelist in arg2params.items():
184-
# If we have a scope that is higher than function, we need
185-
# to make sure we only ever create an according fixturedef on
186-
# a per-scope basis. We thus store and cache the fixturedef on the
187-
# node related to the scope.
188-
scope = arg2scope[argname]
189-
node = None
190-
if scope is not Scope.Function:
191-
node = get_scope_node(collector, scope)
192-
if node is None:
193-
assert scope is Scope.Class and isinstance(
194-
collector, _pytest.python.Module
195-
)
196-
# Use module-level collector for class-scope (for now).
197-
node = collector
198-
if node is None:
199-
name2pseudofixturedef = None
200-
else:
201-
default: Dict[str, FixtureDef[Any]] = {}
202-
name2pseudofixturedef = node.stash.setdefault(
203-
name2pseudofixturedef_key, default
204-
)
205-
if name2pseudofixturedef is not None and argname in name2pseudofixturedef:
206-
arg2fixturedefs[argname] = [name2pseudofixturedef[argname]]
207-
else:
208-
fixturedef = FixtureDef(
209-
fixturemanager=fixturemanager,
210-
baseid="",
211-
argname=argname,
212-
func=get_direct_param_fixture_func,
213-
scope=arg2scope[argname],
214-
params=valuelist,
215-
unittest=False,
216-
ids=None,
217-
)
218-
arg2fixturedefs[argname] = [fixturedef]
219-
if name2pseudofixturedef is not None:
220-
name2pseudofixturedef[argname] = fixturedef
200+
# Used for storing artificial fixturedefs for direct parametrization.
201+
name2pseudofixturedef_key = StashKey[Dict[str, "FixtureDef[Any]"]]()
221202

222203

223204
def getfixturemarker(obj: object) -> Optional["FixtureFunctionMarker"]:
@@ -229,38 +210,58 @@ def getfixturemarker(obj: object) -> Optional["FixtureFunctionMarker"]:
229210
)
230211

231212

232-
# Parametrized fixture key, helper alias for code below.
233-
_Key = Tuple[object, ...]
213+
@dataclasses.dataclass(frozen=True)
214+
class FixtureArgKey:
215+
argname: str
216+
param_index: Optional[int]
217+
param_value: Optional[Hashable]
218+
scoped_item_path: Optional[Path]
219+
item_cls: Optional[type]
220+
221+
222+
def get_fixture_arg_key(item: nodes.Item, argname: str, scope: Scope) -> FixtureArgKey:
223+
param_index = None
224+
param_value = None
225+
if hasattr(item, 'callspec') and argname in item.callspec.params:
226+
# Fixture is parametrized.
227+
if isinstance(item.callspec.params[argname], Hashable):
228+
param_value = item.callspec.params[argname]
229+
else:
230+
param_index = item.callspec.indices[argname]
234231

232+
if scope is Scope.Session:
233+
scoped_item_path = None
234+
elif scope is Scope.Package:
235+
scoped_item_path = item.path.parent
236+
elif scope in (Scope.Module, Scope.Class):
237+
scoped_item_path = item.path
238+
else:
239+
assert_never(scope)
240+
241+
if scope is Scope.Class and type(item).__name__ != "DoctestItem":
242+
item_cls = item.cls # type: ignore[attr-defined]
243+
else:
244+
item_cls = None
245+
246+
return FixtureArgKey(argname, param_index, param_value, scoped_item_path, item_cls)
247+
235248

236-
def get_parametrized_fixture_keys(item: nodes.Item, scope: Scope) -> Iterator[_Key]:
249+
def get_fixture_keys(item: nodes.Item, scope: Scope) -> Iterator[FixtureArgKey]:
237250
"""Return list of keys for all parametrized arguments which match
238251
the specified scope."""
239252
assert scope is not Scope.Function
240-
try:
241-
callspec = item.callspec # type: ignore[attr-defined]
242-
except AttributeError:
243-
pass
244-
else:
245-
cs: CallSpec2 = callspec
246-
# cs.indices.items() is random order of argnames. Need to
253+
if hasattr(item, '_fixtureinfo'):
247254
# sort this so that different calls to
248-
# get_parametrized_fixture_keys will be deterministic.
249-
for argname, param_index in sorted(cs.indices.items()):
250-
if cs._arg2scope[argname] != scope:
255+
# get_fixture_keys will be deterministic.
256+
for argname, fixture_def in sorted(item._fixtureinfo.name2fixturedefs.items()):
257+
# In the case item is parametrized on the `argname` with
258+
# a scope, it overrides that of the fixture.
259+
if hasattr(item, 'callspec') and argname in item.callspec._arg2scope:
260+
if item.callspec._arg2scope[argname] != scope:
261+
continue
262+
elif fixture_def[-1]._scope != scope:
251263
continue
252-
if scope is Scope.Session:
253-
key: _Key = (argname, param_index)
254-
elif scope is Scope.Package:
255-
key = (argname, param_index, item.path.parent)
256-
elif scope is Scope.Module:
257-
key = (argname, param_index, item.path)
258-
elif scope is Scope.Class:
259-
item_cls = item.cls # type: ignore[attr-defined]
260-
key = (argname, param_index, item.path, item_cls)
261-
else:
262-
assert_never(scope)
263-
yield key
264+
yield get_fixture_arg_key(item, argname, scope)
264265

265266

266267
# Algorithm for sorting on a per-parametrized resource setup basis.
@@ -270,44 +271,66 @@ def get_parametrized_fixture_keys(item: nodes.Item, scope: Scope) -> Iterator[_K
270271

271272

272273
def reorder_items(items: Sequence[nodes.Item]) -> List[nodes.Item]:
273-
argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[_Key, None]]] = {}
274-
items_by_argkey: Dict[Scope, Dict[_Key, Deque[nodes.Item]]] = {}
274+
argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[FixtureArgKey, None]]] = {}
275+
items_by_argkey: Dict[Scope, Dict[FixtureArgKey, Deque[nodes.Item]]] = {}
275276
for scope in HIGH_SCOPES:
276-
d: Dict[nodes.Item, Dict[_Key, None]] = {}
277+
d: Dict[nodes.Item, Dict[FixtureArgKey, None]] = {}
277278
argkeys_cache[scope] = d
278-
item_d: Dict[_Key, Deque[nodes.Item]] = defaultdict(deque)
279+
item_d: Dict[FixtureArgKey, Deque[nodes.Item]] = defaultdict(deque)
279280
items_by_argkey[scope] = item_d
280281
for item in items:
281-
keys = dict.fromkeys(get_parametrized_fixture_keys(item, scope), None)
282+
keys = dict.fromkeys(get_fixture_keys(item, scope), None)
282283
if keys:
283284
d[item] = keys
284285
for key in keys:
285286
item_d[key].append(item)
286287
items_dict = dict.fromkeys(items, None)
287-
return list(
288+
reordered_items = list(
288289
reorder_items_atscope(items_dict, argkeys_cache, items_by_argkey, Scope.Session)
289290
)
291+
for scope in reversed(HIGH_SCOPES):
292+
for key in items_by_argkey[scope]:
293+
last_item_dependent_on_key = items_by_argkey[scope][key].pop()
294+
fixturedef = last_item_dependent_on_key._fixtureinfo.name2fixturedefs[key.argname][-1]
295+
if fixturedef.is_pseudo:
296+
continue
297+
last_item_dependent_on_key.teardown = functools.partial(
298+
lambda other_finalizers, new_finalizer: [finalizer() for finalizer in (new_finalizer, other_finalizers)],
299+
last_item_dependent_on_key.teardown,
300+
functools.partial(fixturedef.finish, last_item_dependent_on_key._request)
301+
)
302+
return reordered_items
290303

291304

292305
def fix_cache_order(
293306
item: nodes.Item,
294-
argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[_Key, None]]],
295-
items_by_argkey: Dict[Scope, Dict[_Key, "Deque[nodes.Item]"]],
307+
argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[FixtureArgKey, None]]],
308+
items_by_argkey: Dict[Scope, Dict[FixtureArgKey, "Deque[nodes.Item]"]],
309+
ignore: Set[Optional[FixtureArgKey]],
310+
current_scope: Scope
296311
) -> None:
297312
for scope in HIGH_SCOPES:
313+
if current_scope < scope:
314+
continue
298315
for key in argkeys_cache[scope].get(item, []):
316+
if key in ignore:
317+
continue
299318
items_by_argkey[scope][key].appendleft(item)
319+
# Make sure last dependent item on a key
320+
# remains updated while reordering.
321+
if items_by_argkey[scope][key][-1] == item:
322+
items_by_argkey[scope][key].pop()
300323

301324

302325
def reorder_items_atscope(
303326
items: Dict[nodes.Item, None],
304-
argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[_Key, None]]],
305-
items_by_argkey: Dict[Scope, Dict[_Key, "Deque[nodes.Item]"]],
327+
argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[FixtureArgKey, None]]],
328+
items_by_argkey: Dict[Scope, Dict[FixtureArgKey, "Deque[nodes.Item]"]],
306329
scope: Scope,
307330
) -> Dict[nodes.Item, None]:
308331
if scope is Scope.Function or len(items) < 3:
309332
return items
310-
ignore: Set[Optional[_Key]] = set()
333+
ignore: Set[Optional[FixtureArgKey]] = set()
311334
items_deque = deque(items)
312335
items_done: Dict[nodes.Item, None] = {}
313336
scoped_items_by_argkey = items_by_argkey[scope]
@@ -332,7 +355,7 @@ def reorder_items_atscope(
332355
i for i in scoped_items_by_argkey[slicing_argkey] if i in items
333356
]
334357
for i in reversed(matching_items):
335-
fix_cache_order(i, argkeys_cache, items_by_argkey)
358+
fix_cache_order(i, argkeys_cache, items_by_argkey, ignore, scope)
336359
items_deque.appendleft(i)
337360
break
338361
if no_argkey_group:
@@ -345,10 +368,6 @@ def reorder_items_atscope(
345368
return items_done
346369

347370

348-
def get_direct_param_fixture_func(request: "FixtureRequest") -> Any:
349-
return request.param
350-
351-
352371
@dataclasses.dataclass
353372
class FuncFixtureInfo:
354373
__slots__ = ("argnames", "initialnames", "names_closure", "name2fixturedefs")
@@ -891,7 +910,7 @@ def fail_fixturefunc(fixturefunc, msg: str) -> NoReturn:
891910

892911

893912
def call_fixture_func(
894-
fixturefunc: "_FixtureFunc[FixtureValue]", request: FixtureRequest, kwargs
913+
fixturefunc: "_FixtureFunc[FixtureValue]", request: SubRequest, kwargs
895914
) -> FixtureValue:
896915
if is_generator(fixturefunc):
897916
fixturefunc = cast(
@@ -963,6 +982,7 @@ def __init__(
963982
ids: Optional[
964983
Union[Tuple[Optional[object], ...], Callable[[Any], Optional[object]]]
965984
] = None,
985+
is_pseudo: bool = False,
966986
) -> None:
967987
self._fixturemanager = fixturemanager
968988
# The "base" node ID for the fixture.
@@ -1014,6 +1034,9 @@ def __init__(
10141034
self.cached_result: Optional[_FixtureCachedResult[FixtureValue]] = None
10151035
self._finalizers: List[Callable[[], object]] = []
10161036

1037+
# Whether fixture is a pseudo-fixture made in direct parametrizations.
1038+
self.is_pseudo = is_pseudo
1039+
10171040
@property
10181041
def scope(self) -> "_ScopeName":
10191042
"""Scope string, one of "function", "class", "module", "package", "session"."""
@@ -1572,6 +1595,9 @@ def get_parametrize_mark_argnames(mark: Mark) -> Sequence[str]:
15721595
# another fixture, while requesting the super fixture, keep going
15731596
# in case the super fixture is parametrized (#1953).
15741597
for fixturedef in reversed(fixture_defs):
1598+
# Skip pseudo-fixtures
1599+
if fixturedef.is_pseudo:
1600+
continue
15751601
# Fixture is parametrized, apply it and stop.
15761602
if fixturedef.params is not None:
15771603
metafunc.parametrize(

src/_pytest/main.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -665,9 +665,10 @@ def perform_collect( # noqa: F811
665665
self.items.extend(self.genitems(node))
666666

667667
self.config.pluginmanager.check_pending()
668-
hook.pytest_collection_modifyitems(
669-
session=self, config=self.config, items=items
670-
)
668+
if genitems:
669+
hook.pytest_collection_modifyitems(
670+
session=self, config=self.config, items=items
671+
)
671672
finally:
672673
hook.pytest_collection_finish(session=self)
673674

0 commit comments

Comments
 (0)