Skip to content

Commit a155b5f

Browse files
author
Sylvain MARIE
committed
Improved fix for #374 modding #376 to integrate the latest mod from pytest. It has not been tested against pytest<9
1 parent f030486 commit a155b5f

16 files changed

Lines changed: 453 additions & 55 deletions

File tree

noxfile.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,26 +53,32 @@ class Folders:
5353
ENVS = {
5454
# python 3.14
5555
(PY314, "pytest-latest"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": ""}},
56+
(PY314, "pytest8.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<9"}},
5657
(PY314, "pytest7.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<8"}},
5758
(PY314, "pytest6.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<7"}},
5859
# python 3.13
5960
(PY313, "pytest-latest"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": ""}},
61+
(PY313, "pytest8.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<9"}},
6062
(PY313, "pytest7.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<8"}},
6163
(PY313, "pytest6.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<7"}},
6264
# python 3.12
6365
(PY312, "pytest-latest"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": ""}},
66+
(PY312, "pytest8.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<9"}},
6467
(PY312, "pytest7.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<8"}},
6568
(PY312, "pytest6.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<7"}},
6669
# python 3.11
6770
# We'll run 'pytest-latest' this last for coverage
71+
(PY311, "pytest8.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<9"}},
6872
(PY311, "pytest7.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<8"}},
6973
(PY311, "pytest6.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<7"}},
7074
# python 3.10
7175
(PY310, "pytest-latest"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": ""}},
76+
(PY310, "pytest8.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<9"}},
7277
(PY310, "pytest7.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<8"}},
7378
(PY310, "pytest6.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<7"}},
7479
# python 3.9
7580
(PY39, "pytest-latest"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": ""}},
81+
(PY39, "pytest8.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<9"}},
7682
(PY39, "pytest7.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<8"}},
7783
(PY39, "pytest6.x"): {"coverage": False, "pkg_specs": {"pip": ">19", "pytest": "<7"}},
7884
# IMPORTANT: this should be last so that the folder docs/reports is not deleted afterwards

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[build-system]
22
requires = [
3-
"setuptools>=39.2",
3+
"setuptools>=39.2,<82", # setuptools v82 does not have pkg_resources anymore GH#
44
"setuptools_scm",
55
"wheel"
66
]

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ classifiers =
2424
Programming Language :: Python :: 3.10
2525
Programming Language :: Python :: 3.11
2626
Programming Language :: Python :: 3.12
27+
Programming Language :: Python :: 3.13
2728
Programming Language :: Python :: 3.14
2829
Framework :: Pytest
2930

src/pytest_cases/plugin.py

Lines changed: 129 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ class FixtureClosureNode(object):
122122
123123
"""
124124
__slots__ = 'parent', 'fixture_defs_mgr', \
125-
'fixture_defs', 'split_fixture_name', 'split_fixture_alternatives', 'children'
125+
'fixture_defs', '_current_indices', 'split_fixture_name', 'split_fixture_alternatives', 'children'
126126

127127
def __init__(self,
128128
fixture_defs_mgr=None, # type: FixtureDefsCache
@@ -138,8 +138,12 @@ def __init__(self,
138138
self.fixture_defs_mgr = fixture_defs_mgr
139139
self.parent = parent_node
140140

141+
# This is a temp variable used during closure construction
142+
self._current_indices = None # type: dict[str, int]
143+
141144
# these will be set after closure has been built
142145
self.fixture_defs = None # type: OrderedDict
146+
# Note: fixture_defs contains dependencies of a node, with their definitions.
143147
self.split_fixture_name = None # type: str
144148
self.split_fixture_alternatives = []
145149
# we do not use a dict any more as several children can use the same union value (doubled unions)
@@ -238,6 +242,7 @@ def build_closure(self,
238242
ignore_args=()
239243
):
240244
"""
245+
This method is for the root node only
241246
Updates this Node with the fixture names provided as argument.
242247
Fixture names and definitions will be stored in self.fixture_defs.
243248
@@ -250,8 +255,41 @@ def build_closure(self,
250255
to "direct parametrization"
251256
:return:
252257
"""
258+
assert self.parent is None, "This should only be called on the root node, use _build_closure otherwise"
253259
self._build_closure(self.fixture_defs_mgr, initial_fixture_names, ignore_args=ignore_args)
254260

261+
# We can now remove the temporary "current indices" from all nodes
262+
self._clean_current_indices()
263+
264+
def _clean_current_indices(self):
265+
"""Clean `self._current_indices` from all nodes. This variable is only used to remember the current fixture
266+
overridden variants used while walking the dependencies. Once closure is built it is useless."""
267+
for c in self.children:
268+
c._clean_current_indices()
269+
self._current_indices = None
270+
271+
@property
272+
def root(self):
273+
"""Return the root of the fixture closure tree"""
274+
node = self
275+
while node.parent is not None:
276+
node = node.parent
277+
return node
278+
279+
def _get_current_index(self, argname, default=None):
280+
"""Equivalent of pytest 9 current_index.get(argname, default) but performed recursively till the top of the tree."""
281+
assert self._current_indices is not None, "should never happen by design"
282+
idx = self._current_indices.get(argname) # without the default as we need to know if this worked
283+
if idx is not None:
284+
# Found
285+
return idx
286+
elif self.parent is not None:
287+
# Not found - Maybe the parent has it
288+
return self.parent._get_current_index(argname, default)
289+
else:
290+
# Root reached and not found, return the default
291+
return default
292+
255293
def is_closure_built(self):
256294
return self.fixture_defs is not None
257295

@@ -284,63 +322,97 @@ def _build_closure(self,
284322
if self.fixture_defs is None:
285323
self.fixture_defs = OrderedDict()
286324

287-
# -- then for all pending, add them with their dependencies
288-
pending_fixture_names = list(initial_fixture_names)
289-
while len(pending_fixture_names) > 0:
290-
fixname = pending_fixture_names.pop(0)
325+
# -- then for all fixture names, add them with their dependencies
291326

292-
# if the fixture is already known in this node or above, do not care
293-
if self.already_knows_fixture(fixname):
294-
continue
327+
# From Pytest9 on, see https://github.com/pytest-dev/pytest/pull/13789/changes
328+
# Track the index for each fixture name in the simulated stack.
329+
# Needed for handling override chains correctly, similar to _get_active_fixturedef.
330+
# Using negative indices: -1 is the most specific (last), -2 is second to last, etc.
331+
self._current_indices = {}
295332

296-
# new ignore_args option in pytest 4.6+. Not really a fixture but a test function parameter, it seems.
297-
if fixname in ignore_args:
298-
self.add_required_fixture(fixname, None)
299-
continue
333+
def process_argname(argname: str) -> None:
334+
# Optimization: if this version of the fixture name was already processed in this node or parent chain,
335+
# do not care.
336+
idx = self._get_current_index(argname)
337+
if idx == -1:
338+
return
339+
elif idx is None:
340+
# Fixture was not processed OR has been registered without any fixture def
341+
# Check the latter
342+
if self.already_knows_fixture(argname):
343+
return
344+
345+
# Add fixture name to the closure :
346+
# The equivalent of this section from pytest code will be done after the 'ignore_args' below for two reasons
347+
# - in case of fixture 'unions' we will not add them the same way
348+
# - in our closure tree nodes, the closure is not only containing the names but also the definitions (it is
349+
# a combination of fixturenames_closure and arg2fixturedefs)
350+
#
351+
# if argname not in fixturenames_closure:
352+
# fixturenames_closure.append(argname)
353+
354+
# New ignore_args option in pytest 4.6+. Not really a fixture but a test function parameter, it seems.
355+
if argname in ignore_args:
356+
self.add_required_fixture(argname, None) # do not store any fixture def for it
357+
return
300358

301-
# else grab the fixture definition(s) for this fixture name for this test node id
302-
fixturedefs = fixture_defs_mgr.get_fixture_defs(fixname)
359+
# Finally the main processing
360+
# (a) Grab the fixture definition(s) for this fixture name for this test node id
361+
fixturedefs = fixture_defs_mgr.get_fixture_defs(argname)
303362
if not fixturedefs:
304-
# fixture without definition: add it. This can happen with e.g. "requests", etc.
305-
self.add_required_fixture(fixname, None)
306-
continue
307-
else:
308-
# the actual definition is the last one
309-
_fixdef = fixturedefs[-1]
310-
_params = _fixdef.params
311-
312-
if _params is not None and is_fixture_union_params(_params):
313-
# create an UNION fixture
314-
315-
# transform the _params into a list of names
316-
alternative_f_names = UnionFixtureAlternative.to_list_of_fixture_names(_params)
363+
# Fixture not defined or not visible - This can happen with e.g. "requests", etc. - add it.
364+
self.add_required_fixture(argname, None) # do not store any fixture def for it
365+
return
317366

318-
# TO DO if only one name, simplify ? >> No, we leave such "optimization" to the end user
367+
# (b) Get the index of the override fixture definition version we need to manage.
368+
# Start with the last one (-1) = the one that will actually be used, but it may require some of the other
369+
# definitions because of a complex dependency chain.
370+
index = self._get_current_index(argname, -1)
371+
if -index > len(fixturedefs):
372+
# Exhausted the override chain (will error during runtest).
373+
return
374+
# Now grab that version of the fixture definition
375+
fixturedef = fixturedefs[index]
376+
377+
# (c) introspect parameters to check: Is this fixture parametrized with a "union" of fixtures ?
378+
_params = fixturedef.params
379+
if _params is None or not is_fixture_union_params(_params):
380+
# No : this is a standard fixture. Do the same as in pytest
381+
382+
# This is the place where we finally add the fixture name to the closure
383+
# if argname not in fixturenames_closure:
384+
# fixturenames_closure.append(argname)
385+
self.add_required_fixture(argname, fixturedefs)
386+
387+
# Now process all fixture dependencies, but in their analysis we will consider the "previous" override
388+
# so that (I guess) we can handle the situation a[overridden] -> b -> a[original] -> c
389+
self._current_indices[argname] = index - 1
390+
for dependency in fixturedef.argnames:
391+
process_argname(dependency)
392+
self._current_indices[argname] = index
393+
else:
394+
# This is a 'Union'-parametrized fixture - do not add it yet
395+
# It requires to split the current closure node into two branches before continuing the analysis
396+
# Indeed some fixture dependencies will be needed in some of the branches, while some others not.
319397

320-
# if there are direct dependencies that are not the union members, add them to pending
321-
non_member_dependencies = [f for f in _fixdef.argnames if f not in alternative_f_names]
322-
# currently we only have 'requests' in this list but future impl of fixture_union may act otherwise
323-
pending_fixture_names += non_member_dependencies
398+
# transform the _params into a list of names
399+
alternative_f_names = UnionFixtureAlternative.to_list_of_fixture_names(_params)
324400

325-
# propagate WITH the pending
326-
self.split_and_build(fixture_defs_mgr, fixname, fixturedefs, alternative_f_names,
327-
pending_fixture_names, ignore_args=ignore_args)
401+
# TO DO if only one name, simplify ? >> No, we leave such "optimization" to the end user
328402

329-
# empty the pending because all of them have been propagated on all children with their dependencies
330-
pending_fixture_names = []
331-
continue
403+
# if there are direct dependencies that are not the union members, add them to pending
404+
non_member_dependencies = [f for f in fixturedef.argnames if f not in alternative_f_names]
405+
# currently we only have 'requests' in this list but future impl of fixture_union may act otherwise
332406

333-
else:
334-
# normal fixture
335-
self.add_required_fixture(fixname, fixturedefs)
407+
# propagate WITH all non-member dependencies
408+
# but handle the situation where the union fixture was an override of another fixture (nasty!)
409+
self._current_indices[argname] = index - 1
410+
self.split_and_build(fixture_defs_mgr, argname, fixturedefs, alternative_f_names,
411+
non_member_dependencies, ignore_args=ignore_args)
412+
self._current_indices[argname] = index
336413

337-
# add all dependencies in the to do list
338-
dependencies = _fixdef.argnames
339-
# - append: was pytest default
340-
# pending_fixture_names += dependencies
341-
# - prepend: makes much more sense
342-
pending_fixture_names = list(dependencies) + pending_fixture_names
343-
continue
414+
for name in initial_fixture_names:
415+
process_argname(name)
344416

345417
# ------ tools to add new fixture names during closure construction
346418

@@ -405,7 +477,8 @@ def add_required_fixture(self, new_fixture_name, new_fixture_defs):
405477
if self.already_knows_fixture(new_fixture_name):
406478
return
407479
elif not self.has_split():
408-
# add_required_fixture locally
480+
# Add required fixture to the dependencies of this node
481+
# (fixture_defs is the dict of dependencies + their defs for this node)
409482
if new_fixture_name not in self.fixture_defs:
410483
self.fixture_defs[new_fixture_name] = new_fixture_defs
411484
else:
@@ -424,10 +497,10 @@ def split_and_build(self,
424497
""" Declares that this node contains a union with alternatives (child nodes=subtrees) """
425498

426499
if self.has_split():
427-
raise ValueError("This should not happen anymore")
428-
# # propagate the split on the children: split each of them
429-
# for n in self.children:
430-
# n.split_and_build(fm, nodeid, split_fixture_name, split_fixture_defs, alternative_fixture_names)
500+
# Propagate the split on the children: split each of them
501+
for n in self.children:
502+
n.split_and_build(fixture_defs_mgr, split_fixture_name, split_fixture_defs,
503+
alternative_fixture_names, pending_fixtures_list, ignore_args)
431504
else:
432505
# add the split (union) name to known fixtures
433506
self.add_required_fixture(split_fixture_name, split_fixture_defs)
@@ -779,7 +852,9 @@ def _getfixtureclosure(fm, fixturenames, parentnode, ignore_args=()):
779852
# (2) now let's do it by ourselves to support fixture unions
780853
_init_fixnames, super_closure, arg2fixturedefs = create_super_closure(fm, parentnode, fixturenames, ignore_args)
781854

782-
# Compare with the previous behaviour TODO remove when in 'production' ?
855+
# Compare with the previous behaviour.
856+
# NOTE: do not remove these asserts when in 'production', as this proved effective to detect bugs related with
857+
# pytest modifications such as GH#374
783858
# NOTE different order happens all the time because of our "prepend" strategy in the closure building
784859
# which makes much more sense/intuition than pytest default
785860
assert set(super_closure) == set(ref_fixturenames)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
def test_fixture_closure_handles_circular_dependencies(pytester) -> None:
2+
"""Test that getfixtureclosure properly handles circular dependencies.
3+
4+
This test is a copy of the test in pytest 9.
5+
See https://github.com/pytest-dev/pytest/pull/13789/changes
6+
Note that the order in the fixture closure is slightly different due to the
7+
8+
The test will error in the runtest phase due to the fixture loop,
9+
but the closure computation still completes.
10+
"""
11+
pytester.makepyfile(
12+
"""
13+
import pytest
14+
15+
# Direct circular dependency.
16+
@pytest.fixture
17+
def fix_a(fix_b): pass
18+
19+
@pytest.fixture
20+
def fix_b(fix_a): pass
21+
22+
# Indirect circular dependency through multiple fixtures.
23+
@pytest.fixture
24+
def fix_x(fix_y): pass
25+
26+
@pytest.fixture
27+
def fix_y(fix_z): pass
28+
29+
@pytest.fixture
30+
def fix_z(fix_x): pass
31+
32+
def test_circular_deps(fix_a, fix_x):
33+
pass
34+
"""
35+
)
36+
items, _hookrec = pytester.inline_genitems()
37+
# assert isinstance(items[0], Function)
38+
fixnames = list(items[0].fixturenames)
39+
while fixnames[0] in ('event_loop_policy', 'environment'):
40+
fixnames.pop(0)
41+
# Note that the order changes with respect to the one in pytest :
42+
# We have to go depth-first
43+
# assert fixnames == ["fix_a", "fix_x", "fix_b", "fix_y", "fix_z"]
44+
assert fixnames == ["fix_a", "fix_b", "fix_x", "fix_y", "fix_z"]
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# META
2+
# {'passed': 2, 'skipped': 0, 'failed': 0}
3+
# END META
4+
5+
import pytest
6+
7+
8+
@pytest.fixture
9+
def db():
10+
return 2
11+
12+
13+
@pytest.fixture
14+
def app(db):
15+
return 1 + db
16+
17+
18+
# See https://github.com/pytest-dev/pytest/issues/13773
19+
# Issue occurred in collection with Pytest 9+
20+
21+
22+
class TestOverrideWithParent:
23+
# Overrides module-level app, doesn't request `db` directly, only transitively.
24+
@pytest.fixture
25+
def app(self, app):
26+
return 1 + app
27+
28+
def test_something(self, app):
29+
assert app == 4 # not 3: intermediate fixture was indeed used
30+
31+
32+
class TestOverrideWithoutParent:
33+
# Overrides module-level app, doesn't request `db` at all.
34+
@pytest.fixture
35+
def app(self):
36+
return 1
37+
38+
def test_something(self, app):
39+
assert app == 1 # not 3 not 4

0 commit comments

Comments
 (0)