Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/14248.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed direct parametrization causing the static fixture closure (as reflected in :data:`request.fixturenames <pytest.FixtureRequest.fixturenames>`) to omit fixtures that are requested transitively from overridden fixtures.
110 changes: 68 additions & 42 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,54 @@ def reorder_items_atscope(
return items_done


def traverse_fixture_closure(
initialnames: Iterable[str],
*,
getfixturedefs: Callable[[str], Sequence[FixtureDef[Any]] | None],
) -> Iterator[str]:
"""Statically traverse the fixture dependency closure in DFS order starting
from initialnames, yielding all requested fixture names (argnames).

Each argname is only yielded once.
"""
# Track the index for each fixture name in the simulated stack.
# Needed for handling override chains correctly, similar to
# FixtureRequest._get_active_fixturedef.
# Using negative indices: -1 is the most specific (last), -2 is second to
# last, etc.
current_indices: dict[str, int] = {}

def process_argname(argname: str) -> Iterator[str]:
index = current_indices.get(argname)

# Optimization: already processed this argname.
if index == -1:
return

# Only yield each argname once.
if index is None:
yield argname
current_indices[argname] = -1

fixturedefs = getfixturedefs(argname)
if not fixturedefs:
return

index = current_indices.get(argname, -1)
if -index > len(fixturedefs):
# Exhausted the override chain (will error during runtest).
return
fixturedef = fixturedefs[index]

current_indices[argname] = index - 1
for dep in fixturedef.argnames:
yield from process_argname(dep)
current_indices[argname] = index

for argname in initialnames:
yield from process_argname(argname)


@dataclasses.dataclass(frozen=True)
class FuncFixtureInfo:
"""Fixture-related information for a fixture-requesting item (e.g. test
Expand Down Expand Up @@ -342,16 +390,13 @@ def prune_dependency_tree(self) -> None:
tree. In this way the dependency tree can get pruned, and the closure
of argnames may get reduced.
"""
closure: set[str] = set()
working_set = set(self.initialnames)
while working_set:
argname = working_set.pop()
if argname not in closure and argname in self.names_closure:
closure.add(argname)
if argname in self.name2fixturedefs:
working_set.update(self.name2fixturedefs[argname][-1].argnames)

self.names_closure[:] = sorted(closure, key=self.names_closure.index)
closure = set(
traverse_fixture_closure(
self.initialnames,
getfixturedefs=self.name2fixturedefs.get,
)
)
self.names_closure[:] = (name for name in self.names_closure if name in closure)


class FixtureRequest(abc.ABC):
Expand Down Expand Up @@ -1704,47 +1749,20 @@ def getfixtureclosure(
# to re-discover fixturedefs again for each fixturename
# (discovering matching fixtures for a given name/node is expensive).

fixturenames_closure = list(initialnames)

arg2fixturedefs: dict[str, Sequence[FixtureDef[Any]]] = {}

# Track the index for each fixture name in the simulated stack.
# Needed for handling override chains correctly, similar to _get_active_fixturedef.
# Using negative indices: -1 is the most specific (last), -2 is second to last, etc.
current_indices: dict[str, int] = {}

def process_argname(argname: str) -> None:
# Optimization: already processed this argname.
if current_indices.get(argname) == -1:
return

if argname not in fixturenames_closure:
fixturenames_closure.append(argname)

def getfixturedefs(argname: str) -> Sequence[FixtureDef[Any]] | None:
if argname in ignore_args:
return
return None

fixturedefs = arg2fixturedefs.get(argname)
if not fixturedefs:
fixturedefs = self.getfixturedefs(argname, parentnode)
if not fixturedefs:
# Fixture not defined or not visible (will error during runtest).
return
return None
arg2fixturedefs[argname] = fixturedefs

index = current_indices.get(argname, -1)
if -index > len(fixturedefs):
# Exhausted the override chain (will error during runtest).
return
fixturedef = fixturedefs[index]

current_indices[argname] = index - 1
for dep in fixturedef.argnames:
process_argname(dep)
current_indices[argname] = index

for name in initialnames:
process_argname(name)
return fixturedefs

def sort_by_scope(arg_name: str) -> Scope:
try:
Expand All @@ -1754,7 +1772,15 @@ def sort_by_scope(arg_name: str) -> Scope:
else:
return fixturedefs[-1]._scope

fixturenames_closure.sort(key=sort_by_scope, reverse=True)
fixturenames_closure = sorted(
traverse_fixture_closure(
initialnames,
getfixturedefs=getfixturedefs,
),
key=sort_by_scope,
reverse=True,
)

return fixturenames_closure, arg2fixturedefs

def pytest_generate_tests(self, metafunc: Metafunc) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ def b(a):


def test(b, request):
assert request.fixturenames == ["b", "request", "a", "dynamic"]
assert request.fixturenames == ["b", "a", "request", "dynamic"]
142 changes: 104 additions & 38 deletions testing/python/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -1178,7 +1178,7 @@ def test_function(request, farg):
def test_request_fixturenames_dynamic_fixture(self, pytester: Pytester) -> None:
"""Regression test for #3057"""
pytester.copy_example("fixtures/test_getfixturevalue_dynamic.py")
result = pytester.runpytest()
result = pytester.runpytest("-vv")
result.stdout.fnmatch_lines(["*1 passed*"])

def test_setupdecorator_and_xunit(self, pytester: Pytester) -> None:
Expand Down Expand Up @@ -2424,30 +2424,43 @@ def test_parametrization_setup_teardown_ordering(self, pytester: Pytester) -> No
pytester.makepyfile(
"""
import pytest

values = []

def pytest_generate_tests(metafunc):
if metafunc.cls is None:
assert metafunc.function is test_finish
if metafunc.cls is not None:
metafunc.parametrize("item", [1,2], scope="class")
class TestClass(object):

class TestClass:
@pytest.fixture(scope="class", autouse=True)
def addteardown(self, item, request):
def setup_teardown(self, item):
values.append("setup-%d" % item)
request.addfinalizer(lambda: values.append("teardown-%d" % item))
yield
values.append("teardown-%d" % item)

def test_step1(self, item):
values.append("step1-%d" % item)

def test_step2(self, item):
values.append("step2-%d" % item)

def test_finish():
print(values)
assert values == ["setup-1", "step1-1", "step2-1", "teardown-1",
"setup-2", "step1-2", "step2-2", "teardown-2",]
"""
assert values == [
"setup-1",
"step1-1",
"step2-1",
"teardown-1",
"setup-2",
"step1-2",
"step2-2",
"teardown-2",
]
"""
)
reprec = pytester.inline_run("-s")
reprec.assertoutcome(passed=5)
result = pytester.inline_run("-vv")
result.assertoutcome(passed=5)

def test_ordering_autouse_before_explicit(self, pytester: Pytester) -> None:
pytester.makepyfile(
Expand Down Expand Up @@ -4484,62 +4497,74 @@ def test_func(m1):
request = TopRequest(items[0], _ispytest=True)
assert request.fixturenames == "m1 f1".split()

def test_func_closure_with_native_fixtures(
self, pytester: Pytester, monkeypatch: MonkeyPatch
) -> None:
"""Sanity check that verifies the order returned by the closures and the actual fixture execution order:
The execution order may differ because of fixture inter-dependencies.
"""
monkeypatch.setattr(pytest, "FIXTURE_ORDER", [], raising=False)
def test_func_closure_with_native_fixtures(self, pytester: Pytester) -> None:
"""Sanity check that verifies the order returned by the closures and the
actual fixture execution order: the execution order may differ because
of fixture inter-dependencies."""
pytester.makepyfile(
"""
import pytest

FIXTURE_ORDER = pytest.FIXTURE_ORDER
fixture_order = []

@pytest.fixture(scope="session")
def s1():
FIXTURE_ORDER.append('s1')
fixture_order.append("s1")

@pytest.fixture(scope="package")
def p1():
FIXTURE_ORDER.append('p1')
fixture_order.append("p1")

@pytest.fixture(scope="module")
def m1():
FIXTURE_ORDER.append('m1')
fixture_order.append("m1")

@pytest.fixture(scope='session')
@pytest.fixture(scope="session")
def my_tmp_path_factory():
FIXTURE_ORDER.append('my_tmp_path_factory')
fixture_order.append("my_tmp_path_factory")

@pytest.fixture
def my_tmp_path(my_tmp_path_factory):
FIXTURE_ORDER.append('my_tmp_path')
fixture_order.append("my_tmp_path")

@pytest.fixture
def f1(my_tmp_path):
FIXTURE_ORDER.append('f1')
fixture_order.append("f1")

@pytest.fixture
def f2():
FIXTURE_ORDER.append('f2')

def test_foo(f1, p1, m1, f2, s1): pass
fixture_order.append("f2")

def test_foo(f1, p1, m1, f2, s1):
# Actual fixture execution differs from static order: dependent
# fixtures must be created first ("my_tmp_path").
assert fixture_order == [
"my_tmp_path_factory",
"s1",
"p1",
"m1",
"my_tmp_path",
"f1",
"f2",
]
"""
)
items, _ = pytester.inline_genitems()
assert isinstance(items[0], Function)
request = TopRequest(items[0], _ispytest=True)
# order of fixtures based on their scope and position in the parameter list
assert (
request.fixturenames
== "s1 my_tmp_path_factory p1 m1 f1 f2 my_tmp_path".split()
)
pytester.runpytest()
# actual fixture execution differs: dependent fixtures must be created first ("my_tmp_path")
FIXTURE_ORDER = pytest.FIXTURE_ORDER # type: ignore[attr-defined]
assert FIXTURE_ORDER == "s1 my_tmp_path_factory p1 m1 my_tmp_path f1 f2".split()
# Static order of fixtures based on their scope and position in the
# parameter list.
assert request.fixturenames == [
"my_tmp_path_factory",
"s1",
"p1",
"m1",
"f1",
"my_tmp_path",
"f2",
]
result = pytester.runpytest("-vv")
result.assert_outcomes(passed=1)

def test_func_closure_module(self, pytester: Pytester) -> None:
pytester.makepyfile(
Expand Down Expand Up @@ -5451,6 +5476,47 @@ def test_something(self, request, app):
result.assert_outcomes(passed=1)


def test_fixture_closure_with_overrides_and_parametrization(pytester: Pytester) -> None:
"""Test that an item's static fixture closure properly includes transitive
dependencies through overridden fixtures (#13773) when also including
parametrization (#14248)."""
pytester.makeconftest(
"""
import pytest

@pytest.fixture
def db(): pass

@pytest.fixture
def app(db): pass
"""
)
pytester.makepyfile(
"""
import pytest

# Overrides conftest-level `app` and requests it.
@pytest.fixture
def app(app): pass

class TestClass:
# Overrides module-level `app` and requests it.
@pytest.fixture
def app(self, app): pass

@pytest.mark.parametrize("a", [1])
def test_something(self, request, app, a):
# Both dynamic and static fixture closures should include 'db'.
assert 'db' in request.fixturenames
assert 'db' in request.node.fixturenames
# No dynamic dependencies, should be equal.
assert set(request.fixturenames) == set(request.node.fixturenames)
"""
)
result = pytester.runpytest("-v")
result.assert_outcomes(passed=1)


def test_fixture_closure_with_broken_override_chain(pytester: Pytester) -> None:
"""Test that an item's static fixture closure properly includes transitive
dependencies through overridden fixtures (#13773).
Expand Down Expand Up @@ -5527,7 +5593,7 @@ def test_circular_deps(fix_a, fix_x):
)
items, _hookrec = pytester.inline_genitems()
assert isinstance(items[0], Function)
assert items[0].fixturenames == ["fix_a", "fix_x", "fix_b", "fix_y", "fix_z"]
assert items[0].fixturenames == ["fix_a", "fix_b", "fix_x", "fix_y", "fix_z"]


def test_fixture_closure_handles_diamond_dependencies(pytester: Pytester) -> None:
Expand Down