diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index b0ce620c13a..75ebeca9100 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -337,33 +337,6 @@ class FuncFixtureInfo: # sequence is ordered from furthest to closes to the function. name2fixturedefs: dict[str, Sequence[FixtureDef[Any]]] - def prune_dependency_tree(self) -> None: - """Recompute names_closure from initialnames and name2fixturedefs. - - Can only reduce names_closure, which means that the new closure will - always be a subset of the old one. The order is preserved. - - This method is needed because direct parametrization may shadow some - of the fixtures that were included in the originally built dependency - 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() - # Argname may be something not included in the original names_closure, - # in which case we ignore it. This currently happens with pseudo - # FixtureDefs which wrap 'get_direct_param_fixture_func(request)'. - # So they introduce the new dependency 'request' which might have - # been missing in the original tree (closure). - 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) - class FixtureRequest(abc.ABC): """The type of the ``request`` fixture. @@ -968,7 +941,6 @@ def _eval_scope_callable( return result -@final class FixtureDef(Generic[FixtureValue]): """A container for a fixture definition. @@ -1136,6 +1108,26 @@ def __repr__(self) -> str: return f"" +class IdentityFixtureDef(FixtureDef[FixtureValue]): + def __init__( + self, + config: Config, + argname: str, + scope: Scope | _ScopeName | Callable[[str, Config], _ScopeName] | None, + *, + _ispytest: bool = False, + ): + super().__init__( + config, + "", + argname, + lambda request: request.param, + scope, + None, + _ispytest=_ispytest, + ) + + def resolve_fixture_function( fixturedef: FixtureDef[FixtureValue], request: FixtureRequest ) -> _FixtureFunc[FixtureValue]: @@ -1567,10 +1559,11 @@ def getfixtureinfo( initialnames = deduplicate_names(autousenames, usefixturesnames, argnames) direct_parametrize_args = _get_direct_parametrize_args(node) - - names_closure, arg2fixturedefs = self.getfixtureclosure( + arg2fixturedefs: dict[str, Sequence[FixtureDef[Any]]] = {} + names_closure = self.getfixtureclosure( parentnode=node, initialnames=initialnames, + arg2fixturedefs=arg2fixturedefs, ignore_args=direct_parametrize_args, ) @@ -1622,30 +1615,32 @@ def getfixtureclosure( self, parentnode: nodes.Node, initialnames: tuple[str, ...], + arg2fixturedefs: dict[str, Sequence[FixtureDef[Any]]], ignore_args: AbstractSet[str], - ) -> tuple[list[str], dict[str, Sequence[FixtureDef[Any]]]]: + ) -> list[str]: # Collect the closure of all fixtures, starting with the given - # fixturenames as the initial set. As we have to visit all - # factory definitions anyway, we also return an arg2fixturedefs - # mapping so that the caller can reuse it and does not have - # to re-discover fixturedefs again for each fixturename + # initialnames containing function arguments, `usefixture` markers + # and `autouse` fixtures as the initial set. As we have to visit all + # factory definitions anyway, we also populate arg2fixturedefs mapping + # for the args missing therein so that the caller can reuse it and does + # not have 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]]] = {} lastlen = -1 while lastlen != len(fixturenames_closure): lastlen = len(fixturenames_closure) for argname in fixturenames_closure: if argname in ignore_args: continue - if argname in arg2fixturedefs: - continue - fixturedefs = self.getfixturedefs(argname, parentnode) - if fixturedefs: - arg2fixturedefs[argname] = fixturedefs - + if argname not in arg2fixturedefs: + fixturedefs = self.getfixturedefs(argname, parentnode) + if fixturedefs: + arg2fixturedefs[argname] = fixturedefs + else: + fixturedefs = arg2fixturedefs[argname] + if fixturedefs and not isinstance(fixturedefs[-1], IdentityFixtureDef): # Add dependencies from this fixture. # If it overrides a fixture with the same name and requests # it, also add dependencies from the overridden fixtures in @@ -1667,7 +1662,7 @@ def sort_by_scope(arg_name: str) -> Scope: return fixturedefs[-1]._scope fixturenames_closure.sort(key=sort_by_scope, reverse=True) - return fixturenames_closure, arg2fixturedefs + return fixturenames_closure def pytest_generate_tests(self, metafunc: Metafunc) -> None: """Generate new tests based on parametrized fixtures used by the given metafunc""" diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 3f9da026799..c1d1e2feb0b 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -51,10 +51,10 @@ from _pytest.config import hookimpl from _pytest.config.argparsing import Parser from _pytest.deprecated import check_ispytest -from _pytest.fixtures import FixtureDef -from _pytest.fixtures import FixtureRequest +from _pytest.fixtures import _get_direct_parametrize_args from _pytest.fixtures import FuncFixtureInfo from _pytest.fixtures import get_scope_node +from _pytest.fixtures import IdentityFixtureDef from _pytest.main import Session from _pytest.mark import ParameterSet from _pytest.mark.structures import _HiddenParam @@ -448,8 +448,6 @@ def _genfunctions(self, name: str, funcobj) -> Iterator[Function]: definition = FunctionDefinition.from_parent(self, name=name, callobj=funcobj) fixtureinfo = definition._fixtureinfo - # pytest_generate_tests impls call metafunc.parametrize() which fills - # metafunc._calls, the outcome of the hook. metafunc = Metafunc( definition=definition, fixtureinfo=fixtureinfo, @@ -458,24 +456,34 @@ def _genfunctions(self, name: str, funcobj) -> Iterator[Function]: module=module, _ispytest=True, ) - methods = [] + + def prune_dependency_tree_if_test_is_directly_parametrized( + metafunc: Metafunc, + ) -> None: + # Direct (those with `indirect=False`) parametrizations taking place in + # module/class-specific `pytest_generate_tests` hooks, a.k.a dynamic direct + # parametrizations using `metafunc.parametrize`, may have shadowed some + # fixtures, making some fixtures no longer reachable. Update the dependency + # tree to reflect what the item really needs. + # Note that direct parametrizations using `@pytest.mark.parametrize` have + # already been considered into making the closure using the `ignore_args` + # arg to `getfixtureclosure`. + if metafunc._has_direct_parametrization: + metafunc._update_dependency_tree() + + methods = [prune_dependency_tree_if_test_is_directly_parametrized] if hasattr(module, "pytest_generate_tests"): methods.append(module.pytest_generate_tests) if cls is not None and hasattr(cls, "pytest_generate_tests"): methods.append(cls().pytest_generate_tests) + + # pytest_generate_tests impls call metafunc.parametrize() which fills + # metafunc._calls, the outcome of the hook. self.ihook.pytest_generate_tests.call_extra(methods, dict(metafunc=metafunc)) if not metafunc._calls: yield Function.from_parent(self, name=name, fixtureinfo=fixtureinfo) else: - metafunc._recompute_direct_params_indices() - # Direct parametrizations taking place in module/class-specific - # `metafunc.parametrize` calls may have shadowed some fixtures, so make sure - # we update what the function really needs a.k.a its fixture closure. Note that - # direct parametrizations using `@pytest.mark.parametrize` have already been considered - # into making the closure using `ignore_args` arg to `getfixtureclosure`. - fixtureinfo.prune_dependency_tree() - for callspec in metafunc._calls: subname = f"{name}[{callspec.id}]" if callspec._idlist else name yield Function.from_parent( @@ -1108,12 +1116,8 @@ def id(self) -> str: return "-".join(self._idlist) -def get_direct_param_fixture_func(request: FixtureRequest) -> Any: - return request.param - - # Used for storing pseudo fixturedefs for direct parametrization. -name2pseudofixturedef_key = StashKey[dict[str, FixtureDef[Any]]]() +name2pseudofixturedef_key = StashKey[dict[str, IdentityFixtureDef[Any]]]() @final @@ -1162,6 +1166,9 @@ def __init__( self._params_directness: dict[str, Literal["indirect", "direct"]] = {} + # Whether it's ever been directly parametrized, i.e. with `indirect=False`. + self._has_direct_parametrization = False + def parametrize( self, argnames: str | Sequence[str], @@ -1308,24 +1315,21 @@ def parametrize( node = collector.session else: assert False, f"Unhandled missing scope: {scope}" - default: dict[str, FixtureDef[Any]] = {} + default: dict[str, IdentityFixtureDef[Any]] = {} name2pseudofixturedef = node.stash.setdefault( name2pseudofixturedef_key, default ) for argname in argnames: if arg_directness[argname] == "indirect": continue + self._has_direct_parametrization = True if name2pseudofixturedef is not None and argname in name2pseudofixturedef: fixturedef = name2pseudofixturedef[argname] else: - fixturedef = FixtureDef( - config=self.config, - baseid="", + fixturedef = IdentityFixtureDef( + config=self.definition.config, argname=argname, - func=get_direct_param_fixture_func, scope=scope_, - params=None, - ids=None, _ispytest=True, ) if name2pseudofixturedef is not None: @@ -1485,11 +1489,17 @@ def _validate_if_using_arg_names( pytrace=False, ) - def _recompute_direct_params_indices(self) -> None: - for argname, param_type in self._params_directness.items(): - if param_type == "direct": - for i, callspec in enumerate(self._calls): - callspec.indices[argname] = i + def _update_dependency_tree(self) -> None: + definition = self.definition + assert definition.parent is not None + fm = definition.parent.session._fixturemanager + fixture_closure = fm.getfixtureclosure( + parentnode=definition, + initialnames=definition._fixtureinfo.initialnames, + arg2fixturedefs=definition._fixtureinfo.name2fixturedefs, + ignore_args=_get_direct_parametrize_args(definition), + ) + definition._fixtureinfo.names_closure[:] = fixture_closure def _find_parametrized_scope( diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 10941c3f9e3..b9fc2c3b64e 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -4791,10 +4791,200 @@ def test_fixt(custom): assert result.ret == ExitCode.TESTS_FAILED +def test_fixture_info_after_dynamic_parametrize(pytester: Pytester) -> None: + """ + Item dependency tree should get prunned before `FixtureManager::pytest_generate_tests` + hook implementation because it attempts to parametrize on the fixtures in the + fixture closure. + """ + pytester.makeconftest( + """ + import pytest + + @pytest.fixture(scope='session', params=[0, 1]) + def fixture1(request): + pass + + @pytest.fixture(scope='session') + def fixture2(fixture1): + pass + + @pytest.fixture(scope='session', params=[2, 3]) + def fixture3(request, fixture2): + pass + """ + ) + pytester.makepyfile( + """ + import pytest + def pytest_generate_tests(metafunc): + metafunc.parametrize("fixture2", [4, 5], scope='session') + + @pytest.fixture(scope='session') + def fixture2(fixture3): + pass + + def test(fixture2): + assert fixture2 in (4, 5) + """ + ) + res = pytester.runpytest() + res.assert_outcomes(passed=2) + + +def test_reordering_after_dynamic_parametrize(pytester: Pytester): + """Make sure that prunning dependency tree takes place correctly, regarding from + reordering's viewpoint.""" + pytester.makepyfile( + """ + import pytest + + def pytest_generate_tests(metafunc): + if metafunc.definition.name == "test_0": + metafunc.parametrize("fixture2", [0]) + + @pytest.fixture(scope='module', params=[0]) + def fixture1(): + pass + + @pytest.fixture(scope='module') + def fixture2(fixture1): + pass + + def test_0(fixture2): + pass + + def test_1(): + pass + + def test_2(fixture1): + pass + """ + ) + result = pytester.runpytest("--collect-only") + result.stdout.fnmatch_lines( + [ + "*test_0*", + "*test_1*", + "*test_2*", + ], + consecutive=True, + ) + + +def test_request_shouldnt_be_in_closure_after_pruning_dep_tree_when_its_not_in_initial_closure( + pytester: Pytester, +): + """Make sure that fixture `request` doesn't show up in the closure after prunning dependency + tree when it has not been there beforehand. + """ + pytester.makepyfile( + """ + import pytest + + def pytest_generate_tests(metafunc): + metafunc.parametrize("arg", [0]) + + @pytest.fixture() + def fixture(): + pass + + def test(fixture, arg): + pass + """ + ) + result = pytester.runpytest("--setup-show") + result.stdout.re_match_lines( + [ + r".+test\[0\] \(fixtures used: arg, fixture\)\.", + ], + ) + + +def test_dont_recompute_dependency_tree_if_no_direct_dynamic_parametrize( + pytester: Pytester, +): + """We should not update item's dependency tree when there's no direct dynamic + parametrization, i.e. `metafunc.parametrize(indirect=False)`s in module/class specific + `pytest_generate_tests` hooks, for the sake of efficiency. + """ + pytester.makeconftest( + """ + import pytest + from _pytest.config import hookimpl + from unittest.mock import Mock + + original_method = None + + @hookimpl(trylast=True) + def pytest_sessionstart(session): + global original_method + original_method = session._fixturemanager.getfixtureclosure + session._fixturemanager.getfixtureclosure = Mock(wraps=original_method) + + @hookimpl(tryfirst=True) + def pytest_sessionfinish(session, exitstatus): + global original_method + session._fixturemanager.getfixtureclosure = original_method + """ + ) + pytester.makepyfile( + """ + import pytest + + def pytest_generate_tests(metafunc): + if metafunc.definition.name == "test_0": + metafunc.parametrize("fixture", [0]) + + if metafunc.definition.name == "test_4": + metafunc.parametrize("fixture", [0], indirect=True) + + + @pytest.fixture(scope='module') + def fixture(): + pass + + def test_0(fixture): + pass + + def test_1(): + pass + + @pytest.mark.parametrize("fixture", [0]) + def test_2(fixture): + pass + + @pytest.mark.parametrize("fixture", [0], indirect=True) + def test_3(fixture): + pass + + def test_4(fixture): + pass + + @pytest.fixture + def fm(request): + yield request._fixturemanager + + def test(fm): + calls = fm.getfixtureclosure.call_args_list + assert len(calls) == 7 + assert calls[0].kwargs["parentnode"].nodeid.endswith("test_0") + assert calls[1].kwargs["parentnode"].nodeid.endswith("test_0") + assert calls[2].kwargs["parentnode"].nodeid.endswith("test_1") + assert calls[3].kwargs["parentnode"].nodeid.endswith("test_2") + assert calls[4].kwargs["parentnode"].nodeid.endswith("test_3") + assert calls[5].kwargs["parentnode"].nodeid.endswith("test_4") + assert calls[6].kwargs["parentnode"].nodeid.endswith("test") + """ + ) + reprec = pytester.runpytest() + reprec.assert_outcomes(passed=6) + + def test_deduplicate_names() -> None: items = deduplicate_names("abacd") assert items == ("a", "b", "c", "d") - items = deduplicate_names((*items, "g", "f", "g", "e", "b")) + items = deduplicate_names(items, ("g", "f", "g", "e", "b")) assert items == ("a", "b", "c", "d", "g", "f", "e") diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index 20ccacf4b73..8ddb4a9bdaa 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -55,6 +55,7 @@ class DefinitionMock(python.FunctionDefinition): fixtureinfo: Any = FuncFixtureInfoMock(names) definition: Any = DefinitionMock._create(obj=func, _nodeid="mock::nodeid") definition._fixtureinfo = fixtureinfo + definition.config = config definition.session = SessionMock(FixtureManagerMock({})) return python.Metafunc(definition, fixtureinfo, config, _ispytest=True) diff --git a/testing/python/show_fixtures_per_test.py b/testing/python/show_fixtures_per_test.py index c860b61e21b..78091eb797a 100644 --- a/testing/python/show_fixtures_per_test.py +++ b/testing/python/show_fixtures_per_test.py @@ -1,6 +1,7 @@ from __future__ import annotations from _pytest.pytester import Pytester +import pytest def test_no_items_should_not_show_output(pytester: Pytester) -> None: @@ -254,3 +255,39 @@ def test_arg1(arg1): " Docstring content that extends into a third paragraph.", ] ) + + +@pytest.mark.xfail( + reason="python.py::show_fixtures_per_test uses arg2fixturedefs instead of fixtureclosure" +) +def test_should_not_show_fixtures_pruned_after_dynamic_parametrization( + pytester: Pytester, +) -> None: + pytester.makepyfile( + """ + import pytest + + @pytest.fixture + def fixture1(): + pass + + @pytest.fixture + def fixture2(fixture1): + pass + + @pytest.fixture + def fixture3(fixture2): + pass + + def pytest_generate_tests(metafunc): + metafunc.parametrize("fixture3", [0]) + + def test(fixture3): + pass + """ + ) + result = pytester.runpytest("--fixtures-per-test") + result.stdout.re_match_lines( + [r"-+ fixtures used by test\[0\] -+", r"-+ \(.+\) -+", r"fixture3 -- .+"], + consecutive=True, + )