From 6d03e6f74c0ba1ea4d1b3d194d045b3939dd0c94 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Thu, 9 Apr 2026 14:26:01 +0300 Subject: [PATCH 1/2] python: add a DirectParamFixtureDef subclass to mark direct-parametrization helper fixtures No functional changes. This is intended for next commit (wants to skip showing these fixtures in `--fixtures-per-test`). These fixtures were previously called "pseudo fixtures". I took the opportunity to rename them "direct param fixture def", which, while less catchy, is more self-descriptive and less judgmental. --- src/_pytest/python.py | 62 +++++++++++++++++++++++++------------- testing/python/metafunc.py | 2 +- 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 13932e548c5..806e4a22fdf 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -55,6 +55,7 @@ from _pytest.fixtures import _resolve_args_directness from _pytest.fixtures import FixtureDef from _pytest.fixtures import FixtureRequest +from _pytest.fixtures import FixtureValue from _pytest.fixtures import FuncFixtureInfo from _pytest.fixtures import get_scope_node from _pytest.main import Session @@ -1095,8 +1096,7 @@ class CallSpec2: and stored in item.callspec. """ - # arg name -> arg value which will be passed to a fixture or pseudo-fixture - # of the same name. (indirect or direct parametrization respectively) + # arg name -> arg value which will be passed to a fixture of the same name. params: dict[str, object] = dataclasses.field(default_factory=dict) # arg name -> arg index. indices: dict[str, int] = dataclasses.field(default_factory=dict) @@ -1153,8 +1153,30 @@ 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]]]() +class DirectParamFixtureDef(FixtureDef[FixtureValue]): + """A custom FixtureDef for direct parametrization fixtures. + + Each parameter in direct parametrization is desugared to a parametrized + fixture which returns the direct parameterization value as its param. + We use this custom type as a "marker" for this type of FixtureDef, but + usually behaves like any other FixtureDef. + """ + + def __init__(self, *, config: Config, argname: str, scope: Scope) -> None: + super().__init__( + config=config, + baseid="", + argname=argname, + func=get_direct_param_fixture_func, + scope=scope, + params=None, + ids=None, + _ispytest=True, + ) + + +# Used for storing fixturedefs for direct parametrization. +name2directparamfixturedef_key = StashKey[dict[str, DirectParamFixtureDef[object]]]() @final @@ -1333,14 +1355,14 @@ def parametrize( self._params_directness.update(arg_directness) # Add direct parametrizations as fixturedefs to arg2fixturedefs by - # registering artificial "pseudo" FixtureDef's such that later at test + # registering artificial DirectParamFixtureDef's such that later at test # setup time we can rely on FixtureDefs to exist for all argnames. node = None - # For scopes higher than function, a "pseudo" FixtureDef might have + # For scopes higher than function, a DirectParamFixtureDef might have # already been created for the scope. We thus store and cache the - # FixtureDef on the node related to the scope. + # DirectParamFixtureDef on the node related to the scope. if scope_ is Scope.Function: - name2pseudofixturedef = None + name2directparamfixturedef = None else: collector = self.definition.parent assert collector is not None @@ -1357,28 +1379,26 @@ def parametrize( node = collector.session else: assert False, f"Unhandled missing scope: {scope}" - default: dict[str, FixtureDef[Any]] = {} - name2pseudofixturedef = node.stash.setdefault( - name2pseudofixturedef_key, default + default: dict[str, DirectParamFixtureDef[object]] = {} + name2directparamfixturedef = node.stash.setdefault( + name2directparamfixturedef_key, default ) for argname in argnames: if arg_directness[argname] == "indirect": continue - if name2pseudofixturedef is not None and argname in name2pseudofixturedef: - fixturedef = name2pseudofixturedef[argname] + if ( + name2directparamfixturedef is not None + and argname in name2directparamfixturedef + ): + fixturedef = name2directparamfixturedef[argname] else: - fixturedef = FixtureDef( + fixturedef = DirectParamFixtureDef( config=self.config, - baseid="", argname=argname, - func=get_direct_param_fixture_func, scope=scope_, - params=None, - ids=None, - _ispytest=True, ) - if name2pseudofixturedef is not None: - name2pseudofixturedef[argname] = fixturedef + if name2directparamfixturedef is not None: + name2directparamfixturedef[argname] = fixturedef self._arg2fixturedefs[argname] = [fixturedef] # Create the new calls: if we are parametrize() multiple times (by applying the decorator diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index b48de293ede..026589d65f5 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -801,7 +801,7 @@ def func(x, y): metafunc = self.Metafunc(func) metafunc.parametrize("x, y", [("a", "b")], indirect=["x"]) assert metafunc._calls[0].params == dict(x="a", y="b") - # Since `y` is a direct parameter, its pseudo-fixture would + # Since `y` is a direct parameter, its DirectParamFixtureDef would # be registered. assert list(metafunc._arg2fixturedefs.keys()) == ["y"] From eff72f75e6f5b2ed95ba5ff29400f0e237053dc1 Mon Sep 17 00:00:00 2001 From: Warren Date: Sun, 17 Mar 2024 16:24:20 +1100 Subject: [PATCH 2/2] fixtures: exclude direct-param fixtures from --fixtures-per-test output Fix #11295 by excluding from the --fixtures-per-test output any 'pseudo fixture' that results from directy parametrizing a test e.g. with ``@pytest.mark.parametrize``. The justification for removing these fixtures from the report is that a) They are unintuitive. Their appearance in the fixtures-per-test report confuses new users because the fixtures created via ``@pytest.mark.parametrize`` do not confrom to the expectations established in the documentation; namely, that fixtures are - richly reusable - provide setup/teardown features - created via the ``@pytest.fixture` decorator b) They are an internal implementation detail. It is not the explicit goal of the direct parametrization mark to create a fixture; instead, pytest's internals leverages the fixture system to achieve the explicit goal: a succinct batch execution syntax. Consequently, exposing the fixtures that implement the batch execution behaviour reveal more about pytest's internals than they do about the user's own design choices and test dependencies. --- AUTHORS | 1 + changelog/11295.improvement.rst | 1 + src/_pytest/fixtures.py | 46 +++++++++++---- testing/python/show_fixtures_per_test.py | 74 +++++++++++++++++++++++- 4 files changed, 111 insertions(+), 11 deletions(-) create mode 100644 changelog/11295.improvement.rst diff --git a/AUTHORS b/AUTHORS index 2f8e26b2cb1..f3c8d016c28 100644 --- a/AUTHORS +++ b/AUTHORS @@ -495,6 +495,7 @@ Vlad Radziuk Vladyslav Rachek Volodymyr Kochetkov Volodymyr Piskun +Warren Markham Wei Lin Wil Cooley Will Riley diff --git a/changelog/11295.improvement.rst b/changelog/11295.improvement.rst new file mode 100644 index 00000000000..fc29956f04c --- /dev/null +++ b/changelog/11295.improvement.rst @@ -0,0 +1 @@ +Improved output of ``--fixtures-per-test`` by excluding internal-implementation fixtures generated by ``@pytest.mark.parametrize`` and similar. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index f667c40ea78..22d2ecb57bf 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1977,6 +1977,36 @@ def _pretty_fixture_path(invocation_dir: Path, func) -> str: return bestrelpath(invocation_dir, loc) +def _get_fixtures_per_test(test: nodes.Item) -> Iterator[FixtureDef[object]]: + """Returns all fixtures used by the test item except for those created by + direct parametrization and those requested dynamically with + ``request.getfixturevalue``. + + The justification for excluding fixtures created by direct parametrization + is that for users, they are internal implementation detail. + + Dynamically requested fixtures are excluded because they are not known + statically. + """ + from _pytest.python import DirectParamFixtureDef + + # Custom Items may not have _fixtureinfo attribute. + fixture_info: FuncFixtureInfo | None = getattr(test, "_fixtureinfo", None) + if fixture_info is None: + return # pragma: no cover + + # dict key not used in loop but needed for sorting. + for argname, fixturedefs in sorted(fixture_info.name2fixturedefs.items()): + if not fixturedefs: + # Not supposed to be empty, but for safety. + continue # pragma: no cover + # Last item is expected to be the one directly used by the test item. + fixturedef = fixturedefs[-1] + if isinstance(fixturedef, DirectParamFixtureDef): + continue + yield fixturedef + + def _show_fixtures_per_test(config: Config, session: Session) -> None: import _pytest.config @@ -2009,22 +2039,18 @@ def write_fixture(fixture_def: FixtureDef[object]) -> None: tw.line(" no docstring available", red=True) def write_item(item: nodes.Item) -> None: - # Not all items have _fixtureinfo attribute. - info: FuncFixtureInfo | None = getattr(item, "_fixtureinfo", None) - if info is None or not info.name2fixturedefs: + fixturedefs = list(_get_fixtures_per_test(item)) + if not fixturedefs: # This test item does not use any fixtures. return + tw.line() tw.sep("-", f"fixtures used by {item.name}") # TODO: Fix this type ignore. tw.sep("-", f"({get_best_relpath(item.function)})") # type: ignore[attr-defined] - # dict key not used in loop but needed for sorting. - for _, fixturedefs in sorted(info.name2fixturedefs.items()): - assert fixturedefs is not None - if not fixturedefs: - continue - # Last item is expected to be the one used by the test item. - write_fixture(fixturedefs[-1]) + + for fixturedef in fixturedefs: + write_fixture(fixturedef) for session_item in session.items: write_item(session_item) diff --git a/testing/python/show_fixtures_per_test.py b/testing/python/show_fixtures_per_test.py index c860b61e21b..76fc3728267 100644 --- a/testing/python/show_fixtures_per_test.py +++ b/testing/python/show_fixtures_per_test.py @@ -3,7 +3,7 @@ from _pytest.pytester import Pytester -def test_no_items_should_not_show_output(pytester: Pytester) -> None: +def test_should_show_no_output_when_zero_items(pytester: Pytester) -> None: result = pytester.runpytest("--fixtures-per-test") result.stdout.no_fnmatch_line("*fixtures used by*") assert result.ret == 0 @@ -254,3 +254,75 @@ def test_arg1(arg1): " Docstring content that extends into a third paragraph.", ] ) + + +def test_should_not_show_direct_param_fixtures(pytester: Pytester) -> None: + """A direct-param fixture is a helper fixture created as an implementation + detail of direct parametrization. + + These fixtures should not be included in the output because they don't + satisfy user expectations for how fixtures are created and used (#11295). + """ + pytester.makepyfile( + """ + import pytest + + @pytest.mark.parametrize("x", [1]) + def test_pseudo_fixture(x): + pass + """ + ) + result = pytester.runpytest("--fixtures-per-test") + result.stdout.no_fnmatch_line("*fixtures used by*") + assert result.ret == 0 + + +def test_should_show_parametrized_fixtures_used_by_test(pytester: Pytester) -> None: + """A fixture with parameters should be included if it was created using + the @pytest.fixture decorator, including those that are indirectly + parametrized.""" + pytester.makepyfile( + ''' + import pytest + + @pytest.fixture(params=['a', 'b']) + def directly(request): + """parametrized fixture""" + return request.param + + @pytest.fixture + def indirectly(request): + """indirectly parametrized fixture""" + return request.param + + def test_directly_parametrized_fixture(directly): + pass + + @pytest.mark.parametrize("indirectly", ["a", "b"], indirect=True) + def test_indirectly_parametrized_fixture(indirectly): + pass + ''' + ) + result = pytester.runpytest("--fixtures-per-test") + assert result.ret == 0 + + result.stdout.fnmatch_lines( + [ + "*fixtures used by test_directly_parametrized_fixture*", + "*(test_should_show_parametrized_fixtures_used_by_test.py:14)*", + "directly -- test_should_show_parametrized_fixtures_used_by_test.py:4", + " parametrized fixture", + "*fixtures used by test_directly_parametrized_fixture*", + "*(test_should_show_parametrized_fixtures_used_by_test.py:14)*", + "directly -- test_should_show_parametrized_fixtures_used_by_test.py:4", + " parametrized fixture", + "*fixtures used by test_indirectly_parametrized_fixture*", + "*(test_should_show_parametrized_fixtures_used_by_test.py:17)*", + "indirectly -- test_should_show_parametrized_fixtures_used_by_test.py:9", + " indirectly parametrized fixture", + "*fixtures used by test_indirectly_parametrized_fixture*", + "*(test_should_show_parametrized_fixtures_used_by_test.py:17)*", + "indirectly -- test_should_show_parametrized_fixtures_used_by_test.py:9", + " indirectly parametrized fixture", + ] + )