Skip to content
Open
9 changes: 9 additions & 0 deletions changelog/14004.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Fixed conftest.py fixture scoping when ``testpaths`` points outside ``rootdir``.

Previously, fixtures from nested conftest.py files would incorrectly leak to sibling directories
when using a relative ``testpaths`` like ``../tests/sdk``.

Conftest fixtures are now parsed during Directory collection, using the Directory node for
proper scoping. Additionally, nodeids for paths outside ``rootdir`` are now computed more
meaningfully: paths in site-packages use a ``site://`` prefix, nearby paths use relative
paths with ``..`` components, and far-away paths use absolute paths.
7 changes: 7 additions & 0 deletions changelog/14004.deprecation.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Passing ``baseid`` to :class:`~pytest.FixtureDef` or ``nodeid`` strings to fixture registration
APIs is now deprecated.

Use the ``node`` parameter instead for fixture scoping. This enables more robust node-based
matching instead of string prefix matching.

This will be removed in pytest 10.
3 changes: 3 additions & 0 deletions changelog/14098.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Added :func:`nodes.norm_sep` helper to normalize path separators to forward slashes for nodeid compatibility.

This handles both native OS separators and Windows backslashes in cross-platform data.
170 changes: 131 additions & 39 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
from _pytest.config import _PluggyPlugin
from _pytest.config import Config
from _pytest.config import ExitCode
from _pytest.config import hookimpl
from _pytest.config.argparsing import Parser
from _pytest.deprecated import check_ispytest
from _pytest.deprecated import YIELD_FIXTURE
Expand All @@ -66,6 +67,7 @@
from _pytest.scope import _ScopeName
from _pytest.scope import HIGH_SCOPES
from _pytest.scope import Scope
from _pytest.warning_types import PytestRemovedIn10Warning
from _pytest.warning_types import PytestWarning


Expand All @@ -77,6 +79,7 @@
from _pytest.python import CallSpec2
from _pytest.python import Function
from _pytest.python import Metafunc
from _pytest.reports import CollectReport


# The value of the fixture -- return/yield of the fixture function (type variable).
Expand Down Expand Up @@ -970,8 +973,21 @@ def __init__(
_ispytest: bool = False,
# only used in a deprecationwarning msg, can be removed in pytest9
_autouse: bool = False,
node: nodes.Node | None = None,
) -> 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:
warnings.warn(
"Passing baseid to FixtureDef is deprecated. "
"Pass node instead for fixture scoping.",
PytestRemovedIn10Warning,
stacklevel=2,
)
# 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
# The "base" node ID for the fixture.
#
# This is a node ID prefix. A fixture is only available to a node (e.g.
Expand All @@ -985,11 +1001,12 @@ def __init__(
# directory path relative to the rootdir.
#
# For other plugins, the baseid is the empty string (always matches).
self.baseid: Final = baseid or ""
# When node is available, baseid is derived from node.nodeid.
self.baseid: Final = node.nodeid if node is not None 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.
self.has_location: Final = baseid is not None
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.
Expand All @@ -1000,7 +1017,7 @@ def __init__(
scope = _eval_scope_callable(scope, argname, config)
if isinstance(scope, str):
scope = Scope.from_user(
scope, descr=f"Fixture '{func.__name__}'", where=baseid
scope, descr=f"Fixture '{func.__name__}'", where=self.baseid
)
self._scope: Final = scope
# If the fixture is directly parametrized, the parameter values.
Expand Down Expand Up @@ -1580,6 +1597,9 @@ def __init__(self, session: Session) -> None:
self._nodeid_autousenames: Final[dict[str, list[str]]] = {
"": self.config.getini("usefixtures"),
}
# Pending conftest modules waiting to be parsed when their Directory is collected.
# Maps directory path -> conftest plugin module.
self._pending_conftests: Final[dict[Path, object]] = {}
session.config.pluginmanager.register(self, "funcmanage")

def getfixtureinfo(
Expand Down Expand Up @@ -1621,31 +1641,46 @@ def getfixtureinfo(
def pytest_plugin_registered(self, plugin: _PluggyPlugin, plugin_name: str) -> None:
# Fixtures defined in conftest plugins are only visible to within the
# conftest's directory. This is unlike fixtures in non-conftest plugins
# which have global visibility. So for conftests, construct the base
# nodeid from the plugin name (which is the conftest path).
# which have global visibility. Conftest fixtures are deferred until
# their Directory is collected, so we can use the Directory's nodeid.
if plugin_name and plugin_name.endswith("conftest.py"):
# Note: we explicitly do *not* use `plugin.__file__` here -- The
# difference is that plugin_name has the correct capitalization on
# case-insensitive systems (Windows) and other normalization issues
# (issue #11816).
conftestpath = absolutepath(plugin_name)
try:
nodeid = str(conftestpath.parent.relative_to(self.config.rootpath))
except ValueError:
nodeid = ""
if nodeid == ".":
nodeid = ""
if os.sep != nodes.SEP:
nodeid = nodeid.replace(os.sep, nodes.SEP)
conftest_dir = conftestpath.parent
# Store conftest for deferred parsing when its Directory is collected.
self._pending_conftests[conftest_dir] = plugin
else:
nodeid = None

self.parsefactories(plugin, nodeid)
# Non-conftest plugins have global visibility (nodeid=None).
self.parsefactories(plugin, None)

@hookimpl(wrapper=True)
def pytest_make_collect_report(
self, collector: nodes.Collector
) -> Generator[None, CollectReport, CollectReport]:
# For Directory collectors, conftest modules are loaded during collection.
# After collection, we parse any conftest fixtures that were registered
# for this Directory. This ensures fixtures are scoped to the Directory's nodeid.
result = yield
if isinstance(collector, nodes.Directory):
plugin = self._pending_conftests.pop(collector.path, None)
if plugin is not None:
self.parsefactories(holder=plugin, node=collector)
return result

def _getautousenames(self, node: nodes.Node) -> Iterator[str]:
"""Return the names of autouse fixtures applicable to node."""
seen_nodeids: set[str] = set()
for parentnode in node.listchain():
basenames = self._nodeid_autousenames.get(parentnode.nodeid)
nodeid = parentnode.nodeid
# Avoid yielding duplicates when multiple nodes share the same nodeid
# (e.g., Session and root Directory both have nodeid "").
if nodeid in seen_nodeids:
continue
seen_nodeids.add(nodeid)
basenames = self._nodeid_autousenames.get(nodeid)
if basenames:
yield from basenames

Expand Down Expand Up @@ -1767,11 +1802,12 @@ def _register_fixture(
*,
name: str,
func: _FixtureFunc[object],
nodeid: str | None,
nodeid: str | None = None,
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,
) -> None:
"""Register a fixture

Expand All @@ -1780,10 +1816,12 @@ def _register_fixture(
:param func:
The fixture's implementation function.
:param nodeid:
The visibility of the fixture. The fixture will be available to the
node with this nodeid and its children in the collection tree.
None means that the fixture is visible to the entire collection tree,
e.g. a fixture defined for general use in a plugin.
The visibility of the fixture (deprecated, use node instead).
The fixture will be available to the node with this nodeid and
its children in the collection tree. None means global visibility.
:param node:
The node where the fixture is defined (preferred over nodeid).
When provided, enables node-based matching which is more robust.
:param scope:
The fixture's scope.
:param params:
Expand All @@ -1793,16 +1831,26 @@ 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:
warnings.warn(
"Passing nodeid to _register_fixture is deprecated. "
"Pass node instead for fixture scoping.",
PytestRemovedIn10Warning,
stacklevel=2,
)
fixture_def = FixtureDef(
config=self.config,
baseid=nodeid,
baseid=nodeid if node is None else None,
argname=name,
func=func,
scope=scope,
params=params,
ids=ids,
_ispytest=True,
_autouse=autouse,
node=node,
)

faclist = self._arg2fixturedefs.setdefault(name, [])
Expand All @@ -1816,7 +1864,9 @@ def _register_fixture(
i = len([f for f in faclist if not f.has_location])
faclist.insert(i, fixture_def)
if autouse:
self._nodeid_autousenames.setdefault(nodeid or "", []).append(name)
# Use node.nodeid when available, fall back to nodeid string
effective_nodeid = node.nodeid if node is not None else (nodeid or "")
self._nodeid_autousenames.setdefault(effective_nodeid, []).append(name)

@overload
def parsefactories(
Expand All @@ -1833,32 +1883,65 @@ def parsefactories(
) -> None:
raise NotImplementedError()

@overload
def parsefactories(
self,
node_or_obj: nodes.Node | object,
node_or_obj: None = ...,
nodeid: None = ...,
*,
holder: object,
node: nodes.Node,
) -> None:
raise NotImplementedError()

def parsefactories(
self,
node_or_obj: nodes.Node | object | None = None,
nodeid: str | NotSetType | None = NOTSET,
*,
holder: object | None = None,
node: nodes.Node | None = None,
) -> None:
"""Collect fixtures from a collection node or object.

Found fixtures are parsed into `FixtureDef`s and saved.

If `node_or_object` is a collection node (with an underlying Python
object), the node's object is traversed and the node's nodeid is used to
determine the fixtures' visibility. `nodeid` must not be specified in
this case.
The preferred API uses keyword-only arguments:
- ``holder``: The object to scan for fixtures.
- ``node``: The node determining fixture visibility scope.

If `node_or_object` is an object (e.g. a plugin), the object is
traversed and the given `nodeid` is used to determine the fixtures'
visibility. `nodeid` must be specified in this case; None and "" mean
total visibility.
Legacy positional API (translated internally):
- ``parsefactories(node)``: Uses node.obj as holder, node for scope.
- ``parsefactories(obj, nodeid)``: Uses obj as holder, nodeid string for scope.
"""
if nodeid is not NOTSET:
# 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

if holder is not None:
# New API: holder and node explicitly provided
holderobj = holder
effective_node = node
elif node_or_obj is None:
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(
"Passing nodeid string to parsefactories is deprecated. "
"Use parsefactories(holder=obj, node=node) instead.",
PytestRemovedIn10Warning,
stacklevel=2,
)
holderobj = node_or_obj
effective_nodeid = nodeid
else:
# Legacy: parsefactories(node) - node has .obj attribute
assert isinstance(node_or_obj, nodes.Node)
holderobj = cast(object, node_or_obj.obj) # type: ignore[attr-defined]
assert isinstance(node_or_obj.nodeid, str)
nodeid = node_or_obj.nodeid
effective_node = node_or_obj
if holderobj in self._holderobjseen:
return

Expand Down Expand Up @@ -1891,12 +1974,13 @@ def parsefactories(

self._register_fixture(
name=fixture_name,
nodeid=nodeid,
func=func,
scope=marker.scope,
params=marker.params,
ids=marker.ids,
autouse=marker.autouse,
node=effective_node,
nodeid=effective_nodeid,
)

def getfixturedefs(
Expand All @@ -1922,9 +2006,17 @@ def getfixturedefs(
def _matchfactories(
self, fixturedefs: Iterable[FixtureDef[Any]], node: nodes.Node
) -> Iterator[FixtureDef[Any]]:
parentnodeids = {n.nodeid for n in node.iter_parents()}
# Collect parent nodes and their IDs for matching
parent_nodes = set(node.iter_parents())
parentnodeids = {n.nodeid for n in parent_nodes}

for fixturedef in fixturedefs:
if fixturedef.baseid in parentnodeids:
if fixturedef.node is not None:
# Node-based matching: check if fixture's node is a parent
if fixturedef.node in parent_nodes:
yield fixturedef
elif fixturedef.baseid in parentnodeids:
# Fallback to string-based matching for legacy/plugins
yield fixturedef


Expand Down
Loading