From 09399fed503e3ba99ce8ce4535c6d21428473e0e Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 29 May 2026 21:32:19 +0300 Subject: [PATCH 1/5] fixtures: order fixture override chains by visibility The override chain for a fixture name was determined by the semi-incidental order in which fixturedefs were registered, with an ad-hoc tweak that pushed fixturedefs with no location to the front of the list (the last one is used first). Order the fixturedef list by partial order based on visibility instead: a fixturedef whose visibility is more specific (a descendant in the collection tree) sorts after a more general (ancestor) one, so it takes precedence in the override chain. Fixturedefs with non-comparable visibility keep registration order (last registered wins). The idea is that a fixture that defined closer to the item should take precedence. This generalizes the previous "no location" handling and makes precedence robust for programmatically registered fixtures. The code is complicated by the need to support FixtureDefs with legacy string nodeids rather than Nodes visibility, but these are deprecated and will removed in pytest 10, then we can remove the compat code. Also, comparing Nodes by visibility is not a super fast operation, since we need to look at the actual tree. However, I think overrides are not widely used and when they do, the chains are not very long, so hopefully that's fine. But it can be optimized using some tricks if necessary. Fix #14513 --- changelog/14513.improvement.rst | 5 ++++ src/_pytest/fixtures.py | 50 +++++++++++++++++++++++++++------ testing/python/fixtures.py | 29 +++++++++++++++++++ 3 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 changelog/14513.improvement.rst diff --git a/changelog/14513.improvement.rst b/changelog/14513.improvement.rst new file mode 100644 index 00000000000..5f952165638 --- /dev/null +++ b/changelog/14513.improvement.rst @@ -0,0 +1,5 @@ +The order in which fixture definitions overriding each other are resolved is now determined first by their *visibility* in the collection tree rather than by the order in which they happened to be registered. + +A fixture defined for a more specific node (e.g. a module or an item) now always takes precedence over one with the same name defined for a more general node (e.g. the session), even when the more general one was registered later. +Fixtures with non-comparable visibility keep the existing behavior of "last registered wins". +This change is supposed to only affect plugins which register multiple fixtures programmatically with the same name. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 539648c8c44..5f605734926 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -133,6 +133,32 @@ def get_scope_package( return node.session +def is_visibility_more_specific( + candidate: FixtureDef[Any], other: FixtureDef[Any] +) -> bool: + """Return whether the visibility of ``candidate`` is strictly more specific + than that of ``other``, i.e. ``candidate`` is defined on a strict descendant + in the collection tree of where ``other`` is defined.""" + if candidate.node is None or other.node is None: + # Fallback for fixtures registered with a string nodeid (deprecated) or + # with global visibility (no node). In this case compare baseids, which + # are nodeid prefixes. + # This branch can be removed once baseid deprecation is done (pytest 10). + if candidate.baseid == other.baseid: + return False + if other.baseid == "": + return True + # `candidate.baseid` must continue with a node separator for it to be a + # true descendant. + return candidate.baseid.startswith(other.baseid) and candidate.baseid[ + len(other.baseid) + ] in ("/", ":") + + return ( + candidate.node is not other.node and other.node in candidate.node.iter_parents() + ) + + def get_scope_node(node: nodes.Node, scope: Scope) -> nodes.Node | None: """Get the closest parent node (including self) which matches the given scope. @@ -1948,15 +1974,23 @@ def _register_fixture( ) faclist = self._arg2fixturedefs.setdefault(name, []) - if fixture_def.has_location: - faclist.append(fixture_def) + # Insert the fixturedef into the list while maintaining a partial order + # based on visibility: a fixturedef whose visibility is more specific + # sorts after a more general one, so that it takes precedence in the + # override chain (the last applicable fixturedef in the list is used + # first, see getfixturedefs). + # fixturedefs with the same visibility keep registration order, i.e. the + # last registered wins. + # The order between non-comparable fixturedefs doesn't matter since they + # cannot be visible together. + # The idea is that a fixture that is defined closer to the item should + # take precedence. + for i, existing in enumerate(faclist): + if is_visibility_more_specific(existing, fixture_def): + faclist.insert(i, fixture_def) + break else: - # fixturedefs with no location are at the front - # so this inserts the current fixturedef after the - # existing fixturedefs from external plugins but - # before the fixturedefs provided in conftests. - i = len([f for f in faclist if not f.has_location]) - faclist.insert(i, fixture_def) + faclist.append(fixture_def) if autouse: if node is not None: self._node_autousenames.setdefault(node, []).append(name) diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 9b85e1b388d..9ba372b111f 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -1903,6 +1903,35 @@ def test_hello(self, item, fm): reprec = pytester.inline_run("-s") reprec.assertoutcome(passed=1) + def test_register_fixture_ordered_by_visibility(self, pytester: Pytester) -> None: + """A fixturedef registered for a more specific node takes precedence + over one registered for a more general (ancestor) node, regardless of + the order in which they were registered (#14513).""" + pytester.makeconftest( + """ + import pytest + + @pytest.hookimpl(wrapper=True) + def pytest_collection(session): + result = yield + fm = session._fixturemanager + item = session.items[0] + fm._register_fixture(name="fix", func=lambda: "session1", node=session) + fm._register_fixture(name="fix", func=lambda fix: f"item1-{fix}", node=item) + fm._register_fixture(name="fix", func=lambda fix: f"item2-{fix}", node=item) + fm._register_fixture(name="fix", func=lambda: "session2", node=session) + return result + """ + ) + pytester.makepyfile( + """ + def test(fix): + assert fix == "item2-item1-session2" + """ + ) + reprec = pytester.inline_run() + reprec.assertoutcome(passed=1) + def test_parsefactories_relative_node_ids( self, pytester: Pytester, monkeypatch: MonkeyPatch ) -> None: From b368b3ecec5d989feadc06f70191a8d2eec3af19 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Fri, 29 May 2026 21:38:16 +0300 Subject: [PATCH 2/5] fixtures: deprecate FixtureDef.has_location has_location was used to determine the fixture override order, pushing fixturedefs with no location to the front of the override chain. Now that the override order is determined by the fixtures' visibility in the collection tree, the attribute is no longer needed. Turn it into a property that emits a PytestRemovedIn10Warning, backed by the private _has_location attribute, and document the deprecation. --- changelog/14513.deprecation.rst | 2 ++ doc/en/deprecations.rst | 16 +++++++++++++++- src/_pytest/deprecated.py | 5 +++++ src/_pytest/fixtures.py | 10 +++++++++- testing/deprecated_test.py | 23 +++++++++++++++++++++++ 5 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 changelog/14513.deprecation.rst diff --git a/changelog/14513.deprecation.rst b/changelog/14513.deprecation.rst new file mode 100644 index 00000000000..b5b17f06c06 --- /dev/null +++ b/changelog/14513.deprecation.rst @@ -0,0 +1,2 @@ +The private ``FixtureDef.has_location`` attribute is now deprecated and will be removed in pytest 10. +See :ref:`fixturedef-has-location-deprecated` for details. diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index 6668e7393f3..ac4823c33b3 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -20,7 +20,7 @@ Below is a complete list of all pytest features which are considered deprecated. Passing ``baseid``/``nodeid`` strings to fixture registration APIs ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. deprecated:: 9.2 +.. deprecated:: 9.1 Passing ``baseid`` to :class:`~pytest.FixtureDef` or ``nodeid`` strings to ``FixtureManager._register_fixture`` and ``FixtureManager.parsefactories`` @@ -42,6 +42,20 @@ node-based matching instead of fragile string prefix matching. In pytest 10, the ``baseid`` and ``nodeid`` string parameters will be removed. +.. _fixturedef-has-location-deprecated: + +``FixtureDef.has_location`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 9.1 + +The private ``FixtureDef.has_location`` attribute is deprecated and will be removed in pytest 10. + +It indicated whether a fixture was found from a node or a conftest in the collection tree (as opposed to a non-conftest plugin). +It was used to determine the override order of fixtures, pushing fixtures with "no location" to the front of the override chain (such that they are chosen last). +The override order is now determined by the visibility of the fixtures in the collection tree, making this distinction obsolete. + + .. _console-main: ``pytest.console_main()`` diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index 70640f7efb1..95e75e60f32 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -123,6 +123,11 @@ "Pass node instead for fixture scoping." ) +FIXTUREDEF_HAS_LOCATION_DEPRECATED = PytestRemovedIn10Warning( + "FixtureDef.has_location is deprecated and will be removed in pytest 10. " + "See https://docs.pytest.org/en/stable/deprecations.html#fixturedef-has-location-deprecated" +) + PARSEFACTORIES_NODEID_DEPRECATED = PytestRemovedIn10Warning( "Passing nodeid string to parsefactories is deprecated. " "Use parsefactories(holder=obj, node=node) instead." diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 5f605734926..ca9cc1efecf 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -60,6 +60,7 @@ from _pytest.deprecated import FIXTURE_BASEID_DEPRECATED from _pytest.deprecated import FIXTURE_GETFIXTUREVALUE_DURING_TEARDOWN from _pytest.deprecated import FIXTURE_NODEID_DEPRECATED +from _pytest.deprecated import FIXTUREDEF_HAS_LOCATION_DEPRECATED from _pytest.deprecated import PARSEFACTORIES_NODEID_DEPRECATED from _pytest.deprecated import YIELD_FIXTURE from _pytest.main import Session @@ -1094,7 +1095,9 @@ def __init__( # Whether the fixture was found from a node or a conftest in the # collection tree. Will be false for fixtures defined in non-conftest # plugins. - self.has_location: Final = node is not None or baseid is not None + # + # Deprecated: kept only to back the deprecated ``has_location`` property. + self._has_location: Final = node is not None or baseid is not None # The fixture factory function. self.func: Final = func # The name by which the fixture may be requested. @@ -1129,6 +1132,11 @@ def scope(self) -> ScopeName: """Scope string, one of "function", "class", "module", "package", "session".""" return self._scope.value + @property + def has_location(self) -> bool: + warnings.warn(FIXTUREDEF_HAS_LOCATION_DEPRECATED, stacklevel=2) + return self._has_location + def addfinalizer(self, finalizer: Callable[[], object]) -> None: self._finalizers.append(finalizer) diff --git a/testing/deprecated_test.py b/testing/deprecated_test.py index 5c4c535f979..053215aa8db 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -287,3 +287,26 @@ def test_scoped_invisible(request): ) result = pytester.runpytest("-W", "ignore::pytest.PytestRemovedIn10Warning") result.assert_outcomes(passed=2) + + def test_fixturedef_has_location_deprecated(self, pytester: Pytester) -> None: + """Accessing FixtureDef.has_location warns.""" + pytester.makepyfile( + """ + import pytest + + @pytest.fixture + def fix(): + return 1 + + def test_it(request): + fixturedef = request._fixturemanager.getfixturedefs( + "fix", request._pyfuncitem + )[0] + with pytest.warns( + pytest.PytestRemovedIn10Warning, match="has_location" + ): + assert fixturedef.has_location is True + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=1) From 186a9163c2db596a2c893d83e14e74ec72b2dcd0 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sat, 6 Jun 2026 10:41:31 +0300 Subject: [PATCH 3/5] fixtures: register non-conftest plugin fixturedefs with Session visibility instead of None Let's get rid of the `None` global visibility. Use `Session` which is functionality equivalent instead. - Allows us to remove an annoying special case from the fixture core. - Allows a cleaner public `register_fixture` interface (upcoming). There was previously a distinction between "has location" (e.g. conftest plugins) and "no location" (e.g. external plugins), but it's no longer needed after recent changes. Since initial conftest plugins are always registered after core & external plugins, the override chain order is maintained. --- src/_pytest/fixtures.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index ca9cc1efecf..4775f9ab4ac 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1785,8 +1785,8 @@ def pytest_plugin_registered(self, plugin: _PluggyPlugin, plugin_name: str) -> N # Store conftest for deferred parsing when its Directory is collected. self._pending_conftests[conftest_dir] = plugin else: - # Non-conftest plugins have global visibility (nodeid=None). - self.parsefactories(plugin, None) + # Non-conftest plugins have global visibility. + self.parsefactories(holder=plugin, node=self.session) @hookimpl(wrapper=True) def pytest_make_collect_report( From b50559eeebb9f0cb78e899f14fa6348380c5f6b4 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sat, 6 Jun 2026 11:15:49 +0300 Subject: [PATCH 4/5] fixtures: deprecate global None fixture visibility The nodeid-visibility/baseid deprecation previously allowed None (global) visibility. But this visibility is no longer needed, should use Session visibility instead. So let's include it in the deprecation as well, this way we can have a clean break in pytest 10. --- changelog/14004.deprecation.rst | 1 + doc/en/deprecations.rst | 2 + src/_pytest/fixtures.py | 79 +++++++++++++++++---------------- src/_pytest/python.py | 9 ++-- testing/deprecated_test.py | 2 +- testing/python/metafunc.py | 5 ++- 6 files changed, 54 insertions(+), 44 deletions(-) diff --git a/changelog/14004.deprecation.rst b/changelog/14004.deprecation.rst index 594d943671a..d707b9cc309 100644 --- a/changelog/14004.deprecation.rst +++ b/changelog/14004.deprecation.rst @@ -2,5 +2,6 @@ Passing ``baseid`` to :class:`~pytest.FixtureDef` or ``nodeid`` strings to fixtu Use the ``node`` parameter instead for fixture scoping. This enables more robust node-based matching instead of string prefix matching. +If you've used ``nodeid=None``, pass ``node=session`` instead. This will be removed in pytest 10. diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index ac4823c33b3..7cc58dbd57b 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -39,6 +39,8 @@ node-based matching instead of fragile string prefix matching. fixture_manager.parsefactories(holder=plugin_obj, node=directory_node) fixture_manager._register_fixture(name="fix", func=func, node=directory_node) +The equivalent of passing ``nodeid=None`` (global visibility) is ``node=session``. + In pytest 10, the ``baseid`` and ``nodeid`` string parameters will be removed. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 4775f9ab4ac..5209c0c1ff9 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -32,7 +32,6 @@ from typing import TypeVar import warnings -from .compat import deprecated import _pytest from _pytest import nodes from _pytest._code import getfslineno @@ -41,6 +40,7 @@ from _pytest._code.code import TerminalRepr from _pytest._io import TerminalWriter from _pytest.compat import assert_never +from _pytest.compat import deprecated from _pytest.compat import get_real_func from _pytest.compat import getfuncargnames from _pytest.compat import getimfunc @@ -141,9 +141,8 @@ def is_visibility_more_specific( than that of ``other``, i.e. ``candidate`` is defined on a strict descendant in the collection tree of where ``other`` is defined.""" if candidate.node is None or other.node is None: - # Fallback for fixtures registered with a string nodeid (deprecated) or - # with global visibility (no node). In this case compare baseids, which - # are nodeid prefixes. + # Fallback for fixtures registered with a string nodeid (deprecated). + # In this case compare baseids, which are nodeid prefixes. # This branch can be removed once baseid deprecation is done (pytest 10). if candidate.baseid == other.baseid: return False @@ -1057,26 +1056,27 @@ class FixtureDef(Generic[FixtureValue]): def __init__( self, config: Config, - baseid: str | None, + baseid: str | None | NotSetType, argname: str, func: _FixtureFunc[FixtureValue], scope: Scope | ScopeName | Callable[[str, Config], ScopeName] | None, params: Sequence[object] | None, ids: tuple[object | None, ...] | Callable[[Any], object | None] | None = None, *, - _ispytest: bool = False, + node: nodes.Node | NotSetType = NOTSET, # only used in a deprecationwarning msg, can be removed in pytest9 _autouse: bool = False, - node: nodes.Node | None = None, + _ispytest: bool = False, ) -> None: check_ispytest(_ispytest) - # Emit deprecation warning if baseid string is used when node could be provided. - # baseid=None (global plugins) and baseid="" (synthetic fixtures) are fine. - if baseid and node is None: + # Emit deprecation warning if deprecated baseid string is used. + if node is NOTSET: warnings.warn(FIXTURE_BASEID_DEPRECATED, stacklevel=2) + if baseid is NOTSET: + baseid = None # The node where this fixture was defined, if available. # Used for node-based matching which is more robust than string matching. - self.node: Final = node + self.node: Final = node if node is not NOTSET else None # The "base" node ID for the fixture. # # This is a node ID prefix. A fixture is only available to a node (e.g. @@ -1091,13 +1091,15 @@ def __init__( # # For other plugins, the baseid is the empty string (always matches). # When node is available, baseid is derived from node.nodeid. - self.baseid: Final = node.nodeid if node is not None else (baseid or "") + # + # Deprecated: replaced by ``node``. + self.baseid: Final = node.nodeid if node is not NOTSET else (baseid or "") # Whether the fixture was found from a node or a conftest in the # collection tree. Will be false for fixtures defined in non-conftest # plugins. # # Deprecated: kept only to back the deprecated ``has_location`` property. - self._has_location: Final = node is not None or baseid is not None + self._has_location: Final = node is not NOTSET or baseid is not None # The fixture factory function. self.func: Final = func # The name by which the fixture may be requested. @@ -1251,11 +1253,12 @@ class RequestFixtureDef(FixtureDef[FixtureRequest]): def __init__(self, request: FixtureRequest) -> None: super().__init__( config=request.config, - baseid=None, + baseid=NOTSET, argname="request", func=lambda: request, scope=Scope.Function, params=None, + node=request.node, _ispytest=True, ) self.cached_result = (request, [0], None) @@ -1935,12 +1938,12 @@ def _register_fixture( *, name: str, func: _FixtureFunc[object], - nodeid: str | None = None, + nodeid: str | None | NotSetType = NOTSET, scope: Scope | ScopeName | Callable[[str, Config], ScopeName] = "function", params: Sequence[object] | None = None, ids: tuple[object | None, ...] | Callable[[Any], object | None] | None = None, autouse: bool = False, - node: nodes.Node | None = None, + node: nodes.Node | NotSetType = NOTSET, ) -> None: """Register a fixture @@ -1964,13 +1967,12 @@ def _register_fixture( :param autouse: Whether this is an autouse fixture. """ - # Emit deprecation warning if nodeid string is used when node could be provided. - # nodeid=None (global plugins) is fine. - if nodeid and node is None: + # Emit deprecation warning if nodeid string. + if nodeid is not NOTSET or node is NOTSET: warnings.warn(FIXTURE_NODEID_DEPRECATED, stacklevel=2) fixture_def = FixtureDef( config=self.config, - baseid=nodeid if node is None else None, + baseid=nodeid, argname=name, func=func, scope=scope, @@ -2000,9 +2002,9 @@ def _register_fixture( else: faclist.append(fixture_def) if autouse: - if node is not None: + if node is not NOTSET: self._node_autousenames.setdefault(node, []).append(name) - elif nodeid: + elif nodeid is not NOTSET and nodeid is not None: # Legacy: plugin passed nodeid string without node reference. self._nodeid_autousenames.setdefault(nodeid, []).append(name) else: @@ -2017,6 +2019,9 @@ def parsefactories( raise NotImplementedError() @overload + @deprecated( + "parsefactories(obj, nodeid) is deprecated, use parsefactories(holder=obj, node=node) instead" + ) def parsefactories( self, node_or_obj: object, @@ -2027,8 +2032,8 @@ def parsefactories( @overload def parsefactories( self, - node_or_obj: None = ..., - nodeid: None = ..., + node_or_obj: NotSetType = ..., + nodeid: NotSetType = ..., *, holder: object, node: nodes.Node, @@ -2037,11 +2042,11 @@ def parsefactories( def parsefactories( self, - node_or_obj: nodes.Node | object | None = None, - nodeid: str | NotSetType | None = NOTSET, + node_or_obj: nodes.Node | object | NotSetType = NOTSET, + nodeid: str | None | NotSetType = NOTSET, *, - holder: object | None = None, - node: nodes.Node | None = None, + holder: object | NotSetType = NOTSET, + node: nodes.Node | NotSetType = NOTSET, ) -> None: """Collect fixtures from a collection node or object. @@ -2049,7 +2054,7 @@ def parsefactories( The preferred API uses keyword-only arguments: - ``holder``: The object to scan for fixtures. - - ``node``: The node determining fixture visibility scope. + - ``node``: The node determining fixture visibility. Legacy positional API (translated internally): - ``parsefactories(node)``: Uses node.obj as holder, node for scope. @@ -2057,24 +2062,22 @@ def parsefactories( """ # Translate legacy API to holder/node sources of truth # Either effective_node or effective_nodeid will be set, not both - effective_node: nodes.Node | None = None - effective_nodeid: str | None = None + effective_node: nodes.Node | NotSetType = NOTSET + effective_nodeid: str | None | NotSetType = NOTSET - if holder is not None: + if holder is not NOTSET: # New API: holder and node explicitly provided holderobj = holder effective_node = node - elif node_or_obj is None: + elif node_or_obj is NOTSET: raise TypeError("parsefactories() requires holder or node_or_obj") elif nodeid is not NOTSET: - # Legacy: parsefactories(obj, nodeid) - string-based scoping only - # Only warn if a non-None nodeid string is passed (None means global plugin) - if nodeid is not None: - warnings.warn(PARSEFACTORIES_NODEID_DEPRECATED, stacklevel=2) + # Legacy: parsefactories(obj, nodeid) - string-based scoping only. + warnings.warn(PARSEFACTORIES_NODEID_DEPRECATED, stacklevel=2) holderobj = node_or_obj effective_nodeid = nodeid else: - # Legacy: parsefactories(node) - node has .obj attribute + # parsefactories(node) - node has .obj attribute assert isinstance(node_or_obj, nodes.Node) holderobj = cast(object, node_or_obj.obj) # type: ignore[attr-defined] effective_node = node_or_obj diff --git a/src/_pytest/python.py b/src/_pytest/python.py index ad5a2c6a59b..6e98a7e987c 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -1164,15 +1164,16 @@ class DirectParamFixtureDef(FixtureDef[FixtureValue]): usually behaves like any other FixtureDef. """ - def __init__(self, *, config: Config, argname: str, scope: Scope) -> None: + def __init__(self, *, node: nodes.Node, argname: str, scope: Scope) -> None: super().__init__( - config=config, - baseid="", + config=node.config, + baseid=NOTSET, argname=argname, func=get_direct_param_fixture_func, scope=scope, params=None, ids=None, + node=node, _ispytest=True, ) @@ -1395,7 +1396,7 @@ def parametrize( fixturedef = name2directparamfixturedef[argname] else: fixturedef = DirectParamFixtureDef( - config=self.config, + node=self.definition.session, argname=argname, scope=scope_, ) diff --git a/testing/deprecated_test.py b/testing/deprecated_test.py index 053215aa8db..7114344658f 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -174,7 +174,7 @@ def fix_b(): fm.parsefactories(mod_none, None) nodeid_warns = [x for x in w if "parsefactories" in str(x.message)] - assert len(nodeid_warns) == 1, f"Expected 1 warning, got: {w}" + assert len(nodeid_warns) == 2, f"Expected 2 warning, got: {w}" """ ) pytester.makepyfile( diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index 026589d65f5..96c4819e127 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -10,6 +10,7 @@ import textwrap from typing import Any from typing import cast +from typing import ClassVar import hypothesis from hypothesis import strategies @@ -44,7 +45,9 @@ class FixtureManagerMock: @dataclasses.dataclass class SessionMock: + config: Any _fixturemanager: FixtureManagerMock + nodeid: ClassVar = "" @dataclasses.dataclass class DefinitionMock(python.FunctionDefinition): @@ -55,7 +58,7 @@ class DefinitionMock(python.FunctionDefinition): fixtureinfo: Any = FuncFixtureInfoMock(names) definition: Any = DefinitionMock._create(obj=func, _nodeid="mock::nodeid") definition._fixtureinfo = fixtureinfo - definition.session = SessionMock(FixtureManagerMock({})) + definition.session = SessionMock(config, FixtureManagerMock({})) return python.Metafunc(definition, fixtureinfo, config, _ispytest=True) def test_no_funcargs(self) -> None: From 18721e1d28c641553f42a48afee09322c1a2e67f Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sat, 6 Jun 2026 21:55:09 +0300 Subject: [PATCH 5/5] Add `pytest.register_fixture` Fix #12376. --- changelog/12376.feature.rst | 7 +++++ doc/en/deprecations.rst | 2 +- src/_pytest/fixtures.py | 59 +++++++++++++++++++++++++++++++++++++ src/_pytest/python.py | 8 ++--- src/_pytest/unittest.py | 7 +++-- src/pytest/__init__.py | 2 ++ testing/python/fixtures.py | 9 +++--- 7 files changed, 81 insertions(+), 13 deletions(-) create mode 100644 changelog/12376.feature.rst diff --git a/changelog/12376.feature.rst b/changelog/12376.feature.rst new file mode 100644 index 00000000000..49551b5b2a4 --- /dev/null +++ b/changelog/12376.feature.rst @@ -0,0 +1,7 @@ +Added :func:`pytest.register_fixture()` to register fixtures using an imperative interface. + +This is an advanced function intended for use by plugins. + +Normally, fixtures should be registered declaratively using the :func:`@pytest.fixture ` decorator. +Pytest looks for these fixture definitions during the collection phase and registers them automatically. +For some plugin usecases the declarative interface can be cumbersome or unviable, in which case this imperative interface can be used. diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index 7cc58dbd57b..280b0d94425 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -37,7 +37,7 @@ node-based matching instead of fragile string prefix matching. # Use instead fixture_manager.parsefactories(holder=plugin_obj, node=directory_node) - fixture_manager._register_fixture(name="fix", func=func, node=directory_node) + pytest.register_fixture(name="fix", func=func, node=directory_node) The equivalent of passing ``nodeid=None`` (global visibility) is ``node=session``. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 5209c0c1ff9..9359c07e223 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -2322,3 +2322,62 @@ def _showfixtures_main(config: Config, session: Session) -> None: def write_docstring(tw: TerminalWriter, doc: str, indent: str = " ") -> None: for line in doc.split("\n"): tw.line(indent + line) + + +def register_fixture( + *, + name: str, + func: _FixtureFunc[object], + node: nodes.Node, + scope: Scope | ScopeName | Callable[[str, Config], ScopeName] = "function", + params: Sequence[object] | None = None, + ids: tuple[object | None, ...] | Callable[[Any], object | None] | None = None, + autouse: bool = False, +) -> None: + """Register a fixture imperatively. + + This is an advanced function intended for use by plugins. + + Normally, fixtures should be registered declaratively using the + :func:`@pytest.fixture ` decorator. Pytest looks for these + fixture definitions during the collection phase and registers them + automatically. For some plugin usecases the declarative interface can be + cumbersome or unviable, in which case the imperative interface can be used. + + Fixture registration is expected to happen during the collection phase, and + this is the only sanctioned use. However, to allow for more creative uses, + this is not enforced. But do so at your own risk! + + .. versionadded: 9.1 + + :param name: + The fixture's name. + :param func: + The fixture's implementation function. + :param node: + The visibility of the fixture. + + Only items that are descendents of this node in the collection tree will + be able to request this fixture. You can think of this as the place + where you would put the `@pytest.fixture`. + + For global visibility, pass the :class:`session ` node, + which is the root of the collection tree. + :param scope: + The fixture's scope. + :param params: + The fixture's parametrization params. + :param ids: + The fixture's IDs. + :param autouse: + Whether this is an autouse fixture. + """ + node.session._fixturemanager._register_fixture( + name=name, + func=func, + node=node, + scope=scope, + params=params, + ids=ids, + autouse=autouse, + ) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 6e98a7e987c..45ff185184b 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -591,7 +591,7 @@ def xunit_setup_module_fixture(request) -> Generator[None]: if teardown_module is not None: _call_with_optional_argument(teardown_module, module) - self.session._fixturemanager._register_fixture( + fixtures.register_fixture( # Use a unique name to speed up lookup. name=f"_xunit_setup_module_fixture_{self.obj.__name__}", func=xunit_setup_module_fixture, @@ -627,7 +627,7 @@ def xunit_setup_function_fixture(request) -> Generator[None]: if teardown_function is not None: _call_with_optional_argument(teardown_function, function) - self.session._fixturemanager._register_fixture( + fixtures.register_fixture( # Use a unique name to speed up lookup. name=f"_xunit_setup_function_fixture_{self.obj.__name__}", func=xunit_setup_function_fixture, @@ -807,7 +807,7 @@ def xunit_setup_class_fixture(request) -> Generator[None]: func = getimfunc(teardown_class) _call_with_optional_argument(func, cls) - self.session._fixturemanager._register_fixture( + fixtures.register_fixture( # Use a unique name to speed up lookup. name=f"_xunit_setup_class_fixture_{self.obj.__qualname__}", func=xunit_setup_class_fixture, @@ -841,7 +841,7 @@ def xunit_setup_method_fixture(request) -> Generator[None]: func = getattr(instance, teardown_name) _call_with_optional_argument(func, method) - self.session._fixturemanager._register_fixture( + fixtures.register_fixture( # Use a unique name to speed up lookup. name=f"_xunit_setup_method_fixture_{self.obj.__qualname__}", func=xunit_setup_method_fixture, diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index 84435b3e09f..d5286af1470 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -17,6 +17,7 @@ from typing import TYPE_CHECKING from unittest import TestCase +from _pytest import fixtures import _pytest._code from _pytest._code import ExceptionInfo from _pytest.compat import assert_never @@ -170,7 +171,7 @@ def unittest_setup_class_fixture( cleanup() process_teardown_exceptions() - self.session._fixturemanager._register_fixture( + fixtures.register_fixture( # Use a unique name to speed up lookup. name=f"_unittest_setUpClass_fixture_{cls.__qualname__}", func=unittest_setup_class_fixture, @@ -187,7 +188,7 @@ def unittest_skip_fixture(request: FixtureRequest) -> None: reason = getattr(cls, "__unittest_skip_why__", "") raise skip.Exception(reason, _use_item_location=True) - self.session._fixturemanager._register_fixture( + fixtures.register_fixture( name=f"_unittest_skip_fixture_{cls.__qualname__}", func=unittest_skip_fixture, node=self, @@ -216,7 +217,7 @@ def unittest_setup_method_fixture( if teardown is not None: teardown(self, request.function) - self.session._fixturemanager._register_fixture( + fixtures.register_fixture( # Use a unique name to speed up lookup. name=f"_unittest_setup_method_fixture_{cls.__qualname__}", func=unittest_setup_method_fixture, diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index a74136f77b5..7bce1d55ae3 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -26,6 +26,7 @@ from _pytest.fixtures import FixtureDef from _pytest.fixtures import FixtureLookupError from _pytest.fixtures import FixtureRequest +from _pytest.fixtures import register_fixture from _pytest.fixtures import yield_fixture # type: ignore[deprecated] from _pytest.freeze_support import freeze_includes from _pytest.legacypath import TempdirFactory @@ -177,6 +178,7 @@ "param", "raises", "register_assert_rewrite", + "register_fixture", "set_trace", "skip", "version_tuple", diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 9ba372b111f..dc9474c6fd0 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -1914,12 +1914,11 @@ def test_register_fixture_ordered_by_visibility(self, pytester: Pytester) -> Non @pytest.hookimpl(wrapper=True) def pytest_collection(session): result = yield - fm = session._fixturemanager item = session.items[0] - fm._register_fixture(name="fix", func=lambda: "session1", node=session) - fm._register_fixture(name="fix", func=lambda fix: f"item1-{fix}", node=item) - fm._register_fixture(name="fix", func=lambda fix: f"item2-{fix}", node=item) - fm._register_fixture(name="fix", func=lambda: "session2", node=session) + pytest.register_fixture(name="fix", func=lambda: "session1", node=session) + pytest.register_fixture(name="fix", func=lambda fix: f"item1-{fix}", node=item) + pytest.register_fixture(name="fix", func=lambda fix: f"item2-{fix}", node=item) + pytest.register_fixture(name="fix", func=lambda: "session2", node=session) return result """ )