Skip to content

Commit 37acc4f

Browse files
authored
Merge pull request #13789 from bluetech/fixtures-closure-visibility-3
fixtures: fix closure computation with indirect override chains
2 parents 288d573 + 3f831e6 commit 37acc4f

File tree

2 files changed

+169
-23
lines changed

2 files changed

+169
-23
lines changed

src/_pytest/fixtures.py

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1643,29 +1643,44 @@ def getfixtureclosure(
16431643
fixturenames_closure = list(initialnames)
16441644

16451645
arg2fixturedefs: dict[str, Sequence[FixtureDef[Any]]] = {}
1646-
lastlen = -1
1647-
while lastlen != len(fixturenames_closure):
1648-
lastlen = len(fixturenames_closure)
1649-
for argname in fixturenames_closure:
1650-
if argname in ignore_args:
1651-
continue
1652-
if argname in arg2fixturedefs:
1653-
continue
1646+
1647+
# Track the index for each fixture name in the simulated stack.
1648+
# Needed for handling override chains correctly, similar to _get_active_fixturedef.
1649+
# Using negative indices: -1 is the most specific (last), -2 is second to last, etc.
1650+
current_indices: dict[str, int] = {}
1651+
1652+
def process_argname(argname: str) -> None:
1653+
# Optimization: already processed this argname.
1654+
if current_indices.get(argname) == -1:
1655+
return
1656+
1657+
if argname not in fixturenames_closure:
1658+
fixturenames_closure.append(argname)
1659+
1660+
if argname in ignore_args:
1661+
return
1662+
1663+
fixturedefs = arg2fixturedefs.get(argname)
1664+
if not fixturedefs:
16541665
fixturedefs = self.getfixturedefs(argname, parentnode)
1655-
if fixturedefs:
1656-
arg2fixturedefs[argname] = fixturedefs
1657-
1658-
# Add dependencies from this fixture.
1659-
# If it overrides a fixture with the same name and requests
1660-
# it, also add dependencies from the overridden fixtures in
1661-
# the chain. See also similar dealing in _get_active_fixturedef().
1662-
for fixturedef in reversed(fixturedefs): # pragma: no cover
1663-
for arg in fixturedef.argnames:
1664-
if arg not in fixturenames_closure:
1665-
fixturenames_closure.append(arg)
1666-
if argname not in fixturedef.argnames:
1667-
# Overrides, but doesn't request super.
1668-
break
1666+
if not fixturedefs:
1667+
# Fixture not defined or not visible (will error during runtest).
1668+
return
1669+
arg2fixturedefs[argname] = fixturedefs
1670+
1671+
index = current_indices.get(argname, -1)
1672+
if -index > len(fixturedefs):
1673+
# Exhausted the override chain (will error during runtest).
1674+
return
1675+
fixturedef = fixturedefs[index]
1676+
1677+
current_indices[argname] = index - 1
1678+
for dep in fixturedef.argnames:
1679+
process_argname(dep)
1680+
current_indices[argname] = index
1681+
1682+
for name in initialnames:
1683+
process_argname(name)
16691684

16701685
def sort_by_scope(arg_name: str) -> Scope:
16711686
try:

testing/python/fixtures.py

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5146,7 +5146,6 @@ def test_something(self, request, app):
51465146
result.assert_outcomes(passed=1)
51475147

51485148

5149-
@pytest.mark.xfail(reason="not currently handled correctly")
51505149
def test_fixture_closure_with_overrides_and_intermediary(pytester: Pytester) -> None:
51515150
"""Test that an item's static fixture closure properly includes transitive
51525151
dependencies through overridden fixtures (#13773).
@@ -5234,3 +5233,135 @@ def test_something(self, request, app):
52345233
)
52355234
result = pytester.runpytest("-v")
52365235
result.assert_outcomes(passed=1)
5236+
5237+
5238+
def test_fixture_closure_handles_circular_dependencies(pytester: Pytester) -> None:
5239+
"""Test that getfixtureclosure properly handles circular dependencies.
5240+
5241+
The test will error in the runtest phase due to the fixture loop,
5242+
but the closure computation still completes.
5243+
"""
5244+
pytester.makepyfile(
5245+
"""
5246+
import pytest
5247+
5248+
# Direct circular dependency.
5249+
@pytest.fixture
5250+
def fix_a(fix_b): pass
5251+
5252+
@pytest.fixture
5253+
def fix_b(fix_a): pass
5254+
5255+
# Indirect circular dependency through multiple fixtures.
5256+
@pytest.fixture
5257+
def fix_x(fix_y): pass
5258+
5259+
@pytest.fixture
5260+
def fix_y(fix_z): pass
5261+
5262+
@pytest.fixture
5263+
def fix_z(fix_x): pass
5264+
5265+
def test_circular_deps(fix_a, fix_x):
5266+
pass
5267+
"""
5268+
)
5269+
items, _hookrec = pytester.inline_genitems()
5270+
assert isinstance(items[0], Function)
5271+
assert items[0].fixturenames == ["fix_a", "fix_x", "fix_b", "fix_y", "fix_z"]
5272+
5273+
5274+
def test_fixture_closure_handles_diamond_dependencies(pytester: Pytester) -> None:
5275+
"""Test that getfixtureclosure properly handles diamond dependencies."""
5276+
pytester.makepyfile(
5277+
"""
5278+
import pytest
5279+
5280+
@pytest.fixture
5281+
def db(): pass
5282+
5283+
@pytest.fixture
5284+
def user(db): pass
5285+
5286+
@pytest.fixture
5287+
def session(db): pass
5288+
5289+
@pytest.fixture
5290+
def app(user, session): pass
5291+
5292+
def test_diamond_deps(request, app):
5293+
assert request.node.fixturenames == ["request", "app", "user", "db", "session"]
5294+
assert request.fixturenames == ["request", "app", "user", "db", "session"]
5295+
"""
5296+
)
5297+
result = pytester.runpytest("-v")
5298+
result.assert_outcomes(passed=1)
5299+
5300+
5301+
def test_fixture_closure_with_complex_override_and_shared_deps(
5302+
pytester: Pytester,
5303+
) -> None:
5304+
"""Test that shared dependencies in override chains are processed only once."""
5305+
pytester.makeconftest(
5306+
"""
5307+
import pytest
5308+
5309+
@pytest.fixture
5310+
def db(): pass
5311+
5312+
@pytest.fixture
5313+
def cache(): pass
5314+
5315+
@pytest.fixture
5316+
def settings(): pass
5317+
5318+
@pytest.fixture
5319+
def app(db, cache, settings): pass
5320+
"""
5321+
)
5322+
pytester.makepyfile(
5323+
"""
5324+
import pytest
5325+
5326+
# Override app, but also directly use cache and settings.
5327+
# This creates multiple paths to the same fixtures.
5328+
@pytest.fixture
5329+
def app(app, cache, settings): pass
5330+
5331+
class TestClass:
5332+
# Another override that uses both app and cache.
5333+
@pytest.fixture
5334+
def app(self, app, cache): pass
5335+
5336+
def test_shared_deps(self, request, app):
5337+
assert request.node.fixturenames == ["request", "app", "db", "cache", "settings"]
5338+
"""
5339+
)
5340+
result = pytester.runpytest("-v")
5341+
result.assert_outcomes(passed=1)
5342+
5343+
5344+
def test_fixture_closure_with_parametrize_ignore(pytester: Pytester) -> None:
5345+
"""Test that getfixtureclosure properly handles parametrization argnames
5346+
which override a fixture."""
5347+
pytester.makepyfile(
5348+
"""
5349+
import pytest
5350+
5351+
@pytest.fixture
5352+
def fix1(fix2): pass
5353+
5354+
@pytest.fixture
5355+
def fix2(fix3): pass
5356+
5357+
@pytest.fixture
5358+
def fix3(): pass
5359+
5360+
@pytest.mark.parametrize('fix2', ['2'])
5361+
def test_it(request, fix1):
5362+
assert request.node.fixturenames == ["request", "fix1", "fix2"]
5363+
assert request.fixturenames == ["request", "fix1", "fix2"]
5364+
"""
5365+
)
5366+
result = pytester.runpytest("-v")
5367+
result.assert_outcomes(passed=1)

0 commit comments

Comments
 (0)