From 694e2f662165e85c00805b1cd70c9ed00b338e5f Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 9 Jan 2026 11:34:34 +0100 Subject: [PATCH 1/8] fix: properly scope conftest fixtures when testpaths points outside rootdir When testpaths in config points to a directory outside the rootdir using a relative path like '../tests/sdk', conftest fixture scoping was broken. Fixtures from nested conftest.py files would leak to sibling directories because conftests outside rootpath were assigned an empty string baseid, making them global. The fix computes proper nodeids for conftests outside rootpath by finding them relative to the initial paths (from config.args/testpaths), similar to how FSCollector computes nodeids for test files outside rootpath. Fixes #14004. Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 --- src/_pytest/fixtures.py | 31 ++++++++++- testing/test_conftest.py | 108 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index d8d19fcac6d..e9619a4bca9 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1632,7 +1632,11 @@ def pytest_plugin_registered(self, plugin: _PluggyPlugin, plugin_name: str) -> N try: nodeid = str(conftestpath.parent.relative_to(self.config.rootpath)) except ValueError: - nodeid = "" + # Conftest is outside rootpath. Try to find it relative to one + # of the initial paths (e.g. from testpaths config). This ensures + # proper fixture scoping when testpaths points outside rootdir. + # See issue #14004. + nodeid = self._get_nodeid_for_path_outside_rootpath(conftestpath.parent) if nodeid == ".": nodeid = "" if os.sep != nodes.SEP: @@ -1642,6 +1646,31 @@ def pytest_plugin_registered(self, plugin: _PluggyPlugin, plugin_name: str) -> N self.parsefactories(plugin, nodeid) + def _get_nodeid_for_path_outside_rootpath(self, path: Path) -> str: + """Get nodeid for a path outside rootpath using config.args. + + This is similar to how FSCollector uses _check_initialpaths_for_relpath, + but works with config.args since session._initialpaths is not available + when this is called (during plugin registration). + + :param path: The path to compute the nodeid for. + :returns: The computed nodeid, or "" if path is not under any arg path. + """ + # config.args contains the initial paths (from command line or testpaths) + # resolved relative to invocation_dir + invocation_dir = self.config.invocation_params.dir + for arg in self.config.args: + # Remove node-id syntax (::) if present + arg_path_str = arg.split("::")[0] if "::" in arg else arg + arg_path = absolutepath(invocation_dir / arg_path_str) + if path == arg_path: + return "" + try: + return str(path.relative_to(arg_path)) + except ValueError: + continue + return "" + def _getautousenames(self, node: nodes.Node) -> Iterator[str]: """Return the names of autouse fixtures applicable to node.""" for parentnode in node.listchain(): diff --git a/testing/test_conftest.py b/testing/test_conftest.py index 4de61bceb90..dbcec7ed4ee 100644 --- a/testing/test_conftest.py +++ b/testing/test_conftest.py @@ -779,3 +779,111 @@ def pytest_addoption(parser): result = pytester.runpytest("-h", x) result.stdout.no_fnmatch_line("*argument --xyz is required*") assert "general:" in result.stdout.str() + + +def test_conftest_fixture_scoping_with_testpaths_outside_rootdir( + pytester: Pytester, + monkeypatch: MonkeyPatch, +) -> None: + """Conftest fixtures should be properly scoped when testpaths points outside rootdir. + + Regression test for #14004. + + When testpaths in config points to a directory outside the rootdir using + a relative path like '../tests/sdk', conftest.py fixture scoping should + work correctly. Fixtures from nested conftest.py files should NOT leak to + sibling directories. + """ + # Create directory structure: + # pytester.path/ + # ├── sdk/ + # │ └── pyproject.toml (rootdir, with testpaths = ["../tests/sdk"]) + # └── tests/ + # └── sdk/ + # ├── conftest.py (defines outer_fixture) + # ├── test_outer.py + # └── inner/ + # ├── conftest.py (defines inner_fixture) + # └── test_inner.py + sdk = pytester.path / "sdk" + sdk.mkdir() + tests_sdk = pytester.path / "tests" / "sdk" + tests_sdk.mkdir(parents=True) + inner = tests_sdk / "inner" + inner.mkdir() + + # Create pyproject.toml in sdk/ pointing to ../tests/sdk + sdk.joinpath("pyproject.toml").write_text( + textwrap.dedent( + """\ + [project] + name = "sdk" + version = "0.1.0" + + [tool.pytest.ini_options] + testpaths = ["../tests/sdk"] + """ + ), + encoding="utf-8", + ) + + # Create outer conftest with autouse fixture + tests_sdk.joinpath("conftest.py").write_text( + textwrap.dedent( + """\ + import pytest + + @pytest.fixture(autouse=True) + def outer_fixture(request): + request.node.outer_fixture_called = True + """ + ), + encoding="utf-8", + ) + + # Create inner conftest with autouse fixture - this should ONLY apply to inner/ + inner.joinpath("conftest.py").write_text( + textwrap.dedent( + """\ + import pytest + + @pytest.fixture(autouse=True) + def inner_fixture(request): + request.node.inner_fixture_called = True + """ + ), + encoding="utf-8", + ) + + # Create test in outer directory - should NOT have inner_fixture + tests_sdk.joinpath("test_outer.py").write_text( + textwrap.dedent( + """\ + def test_outer(request): + assert hasattr(request.node, 'outer_fixture_called'), "outer_fixture should be called" + assert not hasattr(request.node, 'inner_fixture_called'), ( + "inner_fixture should NOT be called for outer test" + ) + """ + ), + encoding="utf-8", + ) + + # Create test in inner directory - should have both fixtures + inner.joinpath("test_inner.py").write_text( + textwrap.dedent( + """\ + def test_inner(request): + assert hasattr(request.node, 'outer_fixture_called'), "outer_fixture should be called" + assert hasattr(request.node, 'inner_fixture_called'), "inner_fixture should be called for inner test" + """ + ), + encoding="utf-8", + ) + + # Run pytest from sdk/ directory (where pyproject.toml is) + monkeypatch.chdir(sdk) + result = pytester.runpytest("--tb=short") + + # Both tests should pass - inner_fixture should not leak to test_outer + result.assert_outcomes(passed=2) From 4a380e044168c241796bfd8df2c09c48bc5a37c8 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 9 Jan 2026 12:09:39 +0100 Subject: [PATCH 2/8] fix: assign conftest fixtures to Directory nodes during collection Fixes #14004 - conftest fixtures now properly scoped when testpaths points outside rootdir. Instead of computing fixture nodeids during plugin registration (which required complex path resolution for conftests outside rootpath), conftest fixtures are now deferred until their Directory is collected. This ensures: - Conftest fixtures use the Directory's actual nodeid from the collection tree - Proper fixture scoping regardless of conftest location relative to rootpath - Simpler, more robust implementation Changes: - Add _pending_conftests dict to store conftest modules by directory path - Defer conftest fixture parsing via pytest_make_collect_report hook - Add parsefactories(holder=, node=) as preferred keyword-only API - Remove _get_nodeid_for_path_outside_rootpath (no longer needed) Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 --- src/_pytest/fixtures.py | 115 +++++++++++++++++++++------------------- 1 file changed, 60 insertions(+), 55 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index e9619a4bca9..0885f9070e2 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -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 @@ -77,6 +78,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). @@ -1580,6 +1582,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( @@ -1621,55 +1626,34 @@ 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: - # Conftest is outside rootpath. Try to find it relative to one - # of the initial paths (e.g. from testpaths config). This ensures - # proper fixture scoping when testpaths points outside rootdir. - # See issue #14004. - nodeid = self._get_nodeid_for_path_outside_rootpath(conftestpath.parent) - 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) - - def _get_nodeid_for_path_outside_rootpath(self, path: Path) -> str: - """Get nodeid for a path outside rootpath using config.args. - - This is similar to how FSCollector uses _check_initialpaths_for_relpath, - but works with config.args since session._initialpaths is not available - when this is called (during plugin registration). - - :param path: The path to compute the nodeid for. - :returns: The computed nodeid, or "" if path is not under any arg path. - """ - # config.args contains the initial paths (from command line or testpaths) - # resolved relative to invocation_dir - invocation_dir = self.config.invocation_params.dir - for arg in self.config.args: - # Remove node-id syntax (::) if present - arg_path_str = arg.split("::")[0] if "::" in arg else arg - arg_path = absolutepath(invocation_dir / arg_path_str) - if path == arg_path: - return "" - try: - return str(path.relative_to(arg_path)) - except ValueError: - continue - return "" + # 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.""" @@ -1862,32 +1846,53 @@ 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 + if holder is not None: + # New API: holder and node explicitly provided + holderobj = holder + effective_nodeid = node.nodeid if node is not None else None + elif node_or_obj is None: + raise TypeError("parsefactories() requires holder or node_or_obj") + elif nodeid is not NOTSET: + # Legacy: parsefactories(obj, nodeid) 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_nodeid = node_or_obj.nodeid if holderobj in self._holderobjseen: return @@ -1920,7 +1925,7 @@ def parsefactories( self._register_fixture( name=fixture_name, - nodeid=nodeid, + nodeid=effective_nodeid, func=func, scope=marker.scope, params=marker.params, From f1fc56a26539c88d26a85817b9e3e0158089cc52 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 9 Jan 2026 12:19:34 +0100 Subject: [PATCH 3/8] refactor: propagate nodes into FixtureDef for robust matching Add node parameter to FixtureDef and use node-based matching instead of relying solely on nodeid string prefix matching. Changes: - Add node parameter to FixtureDef to store the defining node - Derive baseid from node.nodeid when node is available - Add node parameter to _register_fixture - Update parsefactories to track and pass effective_node - Update _matchfactories to use node identity for matching when available - Fall back to string-based matching for legacy/plugins This enables more robust fixture matching by using node identity comparison instead of string prefix matching, while maintaining backward compatibility. Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 --- src/_pytest/fixtures.py | 56 +++++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 0885f9070e2..c4ac800c5e1 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -972,8 +972,12 @@ 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) + # 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. @@ -987,11 +991,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. @@ -1002,7 +1007,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. @@ -1780,11 +1785,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 @@ -1793,10 +1799,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 (legacy, prefer node). + 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: @@ -1808,7 +1816,7 @@ def _register_fixture( """ fixture_def = FixtureDef( config=self.config, - baseid=nodeid, + baseid=nodeid if node is None else None, argname=name, func=func, scope=scope, @@ -1816,6 +1824,7 @@ def _register_fixture( ids=ids, _ispytest=True, _autouse=autouse, + node=node, ) faclist = self._arg2fixturedefs.setdefault(name, []) @@ -1829,7 +1838,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( @@ -1878,21 +1889,25 @@ def parsefactories( - ``parsefactories(obj, nodeid)``: Uses obj as holder, nodeid string for scope. """ # 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_nodeid = node.nodeid if node is not None else None + 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) + # Legacy: parsefactories(obj, nodeid) - string-based scoping only 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] - effective_nodeid = node_or_obj.nodeid + effective_node = node_or_obj if holderobj in self._holderobjseen: return @@ -1925,12 +1940,13 @@ def parsefactories( self._register_fixture( name=fixture_name, - nodeid=effective_nodeid, func=func, scope=marker.scope, params=marker.params, ids=marker.ids, autouse=marker.autouse, + node=effective_node, + nodeid=effective_nodeid, ) def getfixturedefs( @@ -1956,9 +1972,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 From 4b82e95aef990d6601d2b8277b07ae029b8257cb Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 9 Jan 2026 12:28:22 +0100 Subject: [PATCH 4/8] refactor: use node= instead of nodeid= in all internal fixture registration Update all internal call sites to use node= parameter instead of nodeid= string for fixture registration. This enables node-based matching. Changes: - python.py: Module and Class xunit fixtures now use node=self - python.py: Class.collect parsefactories uses holder=..., node=self - unittest.py: UnitTestCase fixtures now use node=self - unittest.py: UnitTestCase.collect parsefactories uses holder=..., node=self This completes Phase 1 of migrating from string-based to node-based fixture scoping. Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 --- src/_pytest/python.py | 12 +++++++----- src/_pytest/unittest.py | 8 +++++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 7374fa3cee0..20ba68d28ba 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -595,7 +595,7 @@ def xunit_setup_module_fixture(request) -> Generator[None]: # Use a unique name to speed up lookup. name=f"_xunit_setup_module_fixture_{self.obj.__name__}", func=xunit_setup_module_fixture, - nodeid=self.nodeid, + node=self, scope="module", autouse=True, ) @@ -631,7 +631,7 @@ def xunit_setup_function_fixture(request) -> Generator[None]: # Use a unique name to speed up lookup. name=f"_xunit_setup_function_fixture_{self.obj.__name__}", func=xunit_setup_function_fixture, - nodeid=self.nodeid, + node=self, scope="function", autouse=True, ) @@ -779,7 +779,9 @@ def collect(self) -> Iterable[nodes.Item | nodes.Collector]: self._register_setup_class_fixture() self._register_setup_method_fixture() - self.session._fixturemanager.parsefactories(self.newinstance(), self.nodeid) + self.session._fixturemanager.parsefactories( + holder=self.newinstance(), node=self + ) return super().collect() @@ -809,7 +811,7 @@ def xunit_setup_class_fixture(request) -> Generator[None]: # Use a unique name to speed up lookup. name=f"_xunit_setup_class_fixture_{self.obj.__qualname__}", func=xunit_setup_class_fixture, - nodeid=self.nodeid, + node=self, scope="class", autouse=True, ) @@ -843,7 +845,7 @@ def xunit_setup_method_fixture(request) -> Generator[None]: # Use a unique name to speed up lookup. name=f"_xunit_setup_method_fixture_{self.obj.__qualname__}", func=xunit_setup_method_fixture, - nodeid=self.nodeid, + node=self, scope="function", autouse=True, ) diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index 23b92724f5d..06459c46bde 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -101,7 +101,9 @@ def collect(self) -> Iterable[Item | Collector]: self._register_unittest_setup_class_fixture(cls) self._register_setup_class_fixture() - self.session._fixturemanager.parsefactories(self.newinstance(), self.nodeid) + self.session._fixturemanager.parsefactories( + holder=self.newinstance(), node=self + ) loader = TestLoader() foundsomething = False @@ -170,7 +172,7 @@ def unittest_setup_class_fixture( # Use a unique name to speed up lookup. name=f"_unittest_setUpClass_fixture_{cls.__qualname__}", func=unittest_setup_class_fixture, - nodeid=self.nodeid, + node=self, scope="class", autouse=True, ) @@ -200,7 +202,7 @@ def unittest_setup_method_fixture( # Use a unique name to speed up lookup. name=f"_unittest_setup_method_fixture_{cls.__qualname__}", func=unittest_setup_method_fixture, - nodeid=self.nodeid, + node=self, scope="function", autouse=True, ) From b03896b75fd849865148c7e701b2f8d1a038203c Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 9 Jan 2026 12:36:15 +0100 Subject: [PATCH 5/8] deprecate: add PytestRemovedIn10Warning for baseid/nodeid string parameters Add deprecation warnings for using string-based fixture scoping: - FixtureDef baseid parameter: use node parameter instead - _register_fixture nodeid parameter: use node parameter instead - parsefactories nodeid string: use holder/node API instead The warnings only trigger when a non-empty nodeid string is passed without a node. Global plugins (nodeid=None) and synthetic fixtures (baseid='') do not trigger warnings. These will be removed in pytest 10, completing the migration to node-based fixture scoping. Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 --- src/_pytest/fixtures.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index c4ac800c5e1..180b7fb1a38 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -67,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 @@ -975,6 +976,15 @@ def __init__( 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 @@ -1799,7 +1809,7 @@ def _register_fixture( :param func: The fixture's implementation function. :param nodeid: - The visibility of the fixture (legacy, prefer node). + 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: @@ -1814,6 +1824,15 @@ 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 if node is None else None, @@ -1901,6 +1920,14 @@ def parsefactories( 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: From f26e719c7c5c335b7a7352fd1a4bf76aa927a127 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 9 Jan 2026 12:37:22 +0100 Subject: [PATCH 6/8] doc: add changelog fragments for #14004 Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 --- changelog/14004.bugfix.rst | 7 +++++++ changelog/14004.deprecation.rst | 7 +++++++ 2 files changed, 14 insertions(+) create mode 100644 changelog/14004.bugfix.rst create mode 100644 changelog/14004.deprecation.rst diff --git a/changelog/14004.bugfix.rst b/changelog/14004.bugfix.rst new file mode 100644 index 00000000000..38169dad025 --- /dev/null +++ b/changelog/14004.bugfix.rst @@ -0,0 +1,7 @@ +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's +nodeid for proper scoping. diff --git a/changelog/14004.deprecation.rst b/changelog/14004.deprecation.rst new file mode 100644 index 00000000000..8eca2da046f --- /dev/null +++ b/changelog/14004.deprecation.rst @@ -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. From c6bb6601d7fff1c98817a07d387a2effc1e01eb9 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 11 Jan 2026 10:59:42 +0100 Subject: [PATCH 7/8] improve: compute meaningful nodeids for paths outside rootdir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add compute_nodeid_prefix_for_path() for better nodeid computation: - Paths in site-packages use site:// prefix - Nearby paths (≤2 levels up) use relative paths with .. - Far-away paths use absolute paths - Add _path_in_site_packages() with optional site_packages parameter for testing - Fix _getautousenames() to skip duplicate nodeids (Session and root Dir both have nodeid='') - Add unit tests for nodeid prefix computation Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude --- changelog/14004.bugfix.rst | 6 +- src/_pytest/fixtures.py | 9 ++- src/_pytest/nodes.py | 137 ++++++++++++++++++++++++++++++-- testing/test_nodes.py | 159 +++++++++++++++++++++++++++++++++++++ 4 files changed, 301 insertions(+), 10 deletions(-) diff --git a/changelog/14004.bugfix.rst b/changelog/14004.bugfix.rst index 38169dad025..102868cd925 100644 --- a/changelog/14004.bugfix.rst +++ b/changelog/14004.bugfix.rst @@ -3,5 +3,7 @@ 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's -nodeid for proper scoping. +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. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 180b7fb1a38..432e6d3aa72 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1672,8 +1672,15 @@ def pytest_make_collect_report( 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 diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 6690f6ab1f8..798e854a335 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -38,6 +38,7 @@ from _pytest.mark.structures import NodeKeywords from _pytest.outcomes import fail from _pytest.pathlib import absolutepath +from _pytest.pathlib import bestrelpath from _pytest.stash import Stash from _pytest.warning_types import PytestWarning @@ -557,6 +558,129 @@ def _check_initialpaths_for_relpath( return None +def _get_site_packages_dirs() -> frozenset[Path]: + """Get all site-packages directories as resolved absolute paths.""" + import site + + dirs: set[Path] = set() + # Standard site-packages + for sp in site.getsitepackages(): + try: + dirs.add(Path(sp).resolve()) + except OSError: + pass + # User site-packages + user_site = site.getusersitepackages() + if user_site: + try: + dirs.add(Path(user_site).resolve()) + except OSError: + pass + return frozenset(dirs) + + +# Cache site-packages dirs since they don't change during a run. +_SITE_PACKAGES_DIRS: frozenset[Path] | None = None + + +def _get_cached_site_packages_dirs() -> frozenset[Path]: + """Get cached site-packages directories.""" + global _SITE_PACKAGES_DIRS + if _SITE_PACKAGES_DIRS is None: + _SITE_PACKAGES_DIRS = _get_site_packages_dirs() + return _SITE_PACKAGES_DIRS + + +def _path_in_site_packages( + path: Path, + site_packages: frozenset[Path] | None = None, +) -> tuple[Path, Path] | None: + """Check if path is inside a site-packages directory. + + :param path: The path to check. + :param site_packages: Optional set of site-packages directories to check against. + If None, uses the cached system site-packages directories. + Returns (site_packages_dir, relative_path) if found, None otherwise. + """ + if site_packages is None: + site_packages = _get_cached_site_packages_dirs() + try: + resolved = path.resolve() + except OSError: + return None + + for sp in site_packages: + try: + rel = resolved.relative_to(sp) + return (sp, rel) + except ValueError: + continue + return None + + +def compute_nodeid_prefix_for_path( + path: Path, + rootpath: Path, + invocation_dir: Path, + initial_paths: frozenset[Path], + site_packages: frozenset[Path] | None = None, +) -> str: + """Compute a nodeid prefix for a filesystem path. + + The nodeid prefix is computed based on the path's relationship to: + 1. rootpath - if relative, use simple relative path + 2. initial_paths - if relative to an initial path, use that + 3. site-packages - use "site:///" prefix + 4. invocation_dir - if close by, use relative path with ".." components + 5. Otherwise, use absolute path + + :param path: The path to compute a nodeid prefix for. + :param rootpath: The pytest root path. + :param invocation_dir: The directory from which pytest was invoked. + :param initial_paths: The initial paths (testpaths or command line args). + :param site_packages: Optional set of site-packages directories. If None, + uses the cached system site-packages directories. + + The returned string uses forward slashes as separators regardless of OS. + """ + # 1. Try relative to rootpath (simplest case) + try: + rel = path.relative_to(rootpath) + result = str(rel) + if result == ".": + return "" + return result.replace(os.sep, SEP) + except ValueError: + pass + + # 2. Try relative to initial_paths + nodeid = _check_initialpaths_for_relpath(initial_paths, path) + if nodeid is not None: + return nodeid.replace(os.sep, SEP) if nodeid else "" + + # 3. Check if path is in site-packages + site_info = _path_in_site_packages(path, site_packages) + if site_info is not None: + _sp_dir, rel_path = site_info + result = f"site://{rel_path}" + return result.replace(os.sep, SEP) + + # 4. Try relative to invocation_dir if "close by" (i.e., not too many ".." components) + rel_from_invocation = bestrelpath(invocation_dir, path) + # Count the number of ".." components - if it's reasonable, use the relative path + # Also check total path length to avoid overly long relative paths + parts = Path(rel_from_invocation).parts + up_count = sum(1 for p in parts if p == "..") + # Only use relative path if: + # - At most 2 ".." components (close to invocation dir) + # - bestrelpath actually produced a relative path (not the absolute path unchanged) + if up_count <= 2 and rel_from_invocation != str(path): + return rel_from_invocation.replace(os.sep, SEP) + + # 5. Fall back to absolute path + return str(path).replace(os.sep, SEP) + + class FSCollector(Collector, abc.ABC): """Base class for filesystem collectors.""" @@ -597,13 +721,12 @@ def __init__( session = parent.session if nodeid is None: - try: - nodeid = str(self.path.relative_to(session.config.rootpath)) - except ValueError: - nodeid = _check_initialpaths_for_relpath(session._initialpaths, path) - - if nodeid and os.sep != SEP: - nodeid = nodeid.replace(os.sep, SEP) + nodeid = compute_nodeid_prefix_for_path( + path=path, + rootpath=session.config.rootpath, + invocation_dir=session.config.invocation_params.dir, + initial_paths=session._initialpaths, + ) super().__init__( name=name, diff --git a/testing/test_nodes.py b/testing/test_nodes.py index de7875ca427..d8a701dce40 100644 --- a/testing/test_nodes.py +++ b/testing/test_nodes.py @@ -138,3 +138,162 @@ def test_show_wrong_path(private_dir): ) result = pytester.runpytest() result.stdout.fnmatch_lines([str(p) + ":*: AssertionError", "*1 failed in *"]) + + +class TestNodeidPrefixComputation: + """Tests for nodeid prefix computation for paths outside rootdir.""" + + def test_path_in_site_packages_found(self, tmp_path: Path) -> None: + """Test _path_in_site_packages finds paths inside site-packages.""" + fake_site_packages = tmp_path / "site-packages" + fake_site_packages.mkdir() + pkg_path = fake_site_packages / "mypackage" / "tests" / "test_foo.py" + pkg_path.parent.mkdir(parents=True) + pkg_path.touch() + + site_packages = frozenset([fake_site_packages]) + result = nodes._path_in_site_packages(pkg_path, site_packages) + + assert result is not None + sp_dir, rel_path = result + assert sp_dir == fake_site_packages + assert rel_path == Path("mypackage/tests/test_foo.py") + + def test_path_in_site_packages_not_found(self, tmp_path: Path) -> None: + """Test _path_in_site_packages returns None for paths outside site-packages.""" + fake_site_packages = tmp_path / "site-packages" + fake_site_packages.mkdir() + other_path = tmp_path / "other" / "test_foo.py" + other_path.parent.mkdir(parents=True) + other_path.touch() + + site_packages = frozenset([fake_site_packages]) + result = nodes._path_in_site_packages(other_path, site_packages) + + assert result is None + + def test_compute_nodeid_inside_rootpath(self, tmp_path: Path) -> None: + """Test nodeid computation for paths inside rootpath.""" + rootpath = tmp_path / "project" + rootpath.mkdir() + test_file = rootpath / "tests" / "test_foo.py" + test_file.parent.mkdir(parents=True) + test_file.touch() + + result = nodes.compute_nodeid_prefix_for_path( + path=test_file, + rootpath=rootpath, + invocation_dir=rootpath, + initial_paths=frozenset(), + site_packages=frozenset(), + ) + + assert result == "tests/test_foo.py" + + def test_compute_nodeid_in_initial_paths(self, tmp_path: Path) -> None: + """Test nodeid computation for paths relative to initial_paths.""" + rootpath = tmp_path / "project" + rootpath.mkdir() + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + test_file = tests_dir / "test_foo.py" + test_file.touch() + + result = nodes.compute_nodeid_prefix_for_path( + path=test_file, + rootpath=rootpath, + invocation_dir=rootpath, + initial_paths=frozenset([tests_dir]), + site_packages=frozenset(), + ) + + assert result == "test_foo.py" + + def test_compute_nodeid_in_site_packages(self, tmp_path: Path) -> None: + """Test nodeid computation for paths in site-packages uses site:// prefix.""" + rootpath = tmp_path / "project" + rootpath.mkdir() + fake_site_packages = tmp_path / "site-packages" + fake_site_packages.mkdir() + pkg_test = fake_site_packages / "mypackage" / "tests" / "test_foo.py" + pkg_test.parent.mkdir(parents=True) + pkg_test.touch() + + result = nodes.compute_nodeid_prefix_for_path( + path=pkg_test, + rootpath=rootpath, + invocation_dir=rootpath, + initial_paths=frozenset(), + site_packages=frozenset([fake_site_packages]), + ) + + assert result == "site://mypackage/tests/test_foo.py" + + def test_compute_nodeid_nearby_relative(self, tmp_path: Path) -> None: + """Test nodeid computation for nearby paths uses relative path.""" + rootpath = tmp_path / "project" + rootpath.mkdir() + sibling = tmp_path / "sibling" / "tests" / "test_foo.py" + sibling.parent.mkdir(parents=True) + sibling.touch() + + result = nodes.compute_nodeid_prefix_for_path( + path=sibling, + rootpath=rootpath, + invocation_dir=rootpath, + initial_paths=frozenset(), + site_packages=frozenset(), + ) + + assert result == "../sibling/tests/test_foo.py" + + def test_compute_nodeid_far_away_absolute(self, tmp_path: Path) -> None: + """Test nodeid computation for far-away paths uses absolute path.""" + rootpath = tmp_path / "deep" / "nested" / "project" + rootpath.mkdir(parents=True) + far_away = tmp_path / "other" / "location" / "tests" / "test_foo.py" + far_away.parent.mkdir(parents=True) + far_away.touch() + + result = nodes.compute_nodeid_prefix_for_path( + path=far_away, + rootpath=rootpath, + invocation_dir=rootpath, + initial_paths=frozenset(), + site_packages=frozenset(), + ) + + # Should use absolute path since it's more than 2 levels up + assert result == str(far_away) + + def test_compute_nodeid_rootpath_itself(self, tmp_path: Path) -> None: + """Test nodeid computation for rootpath itself returns empty string.""" + rootpath = tmp_path / "project" + rootpath.mkdir() + + result = nodes.compute_nodeid_prefix_for_path( + path=rootpath, + rootpath=rootpath, + invocation_dir=rootpath, + initial_paths=frozenset(), + site_packages=frozenset(), + ) + + assert result == "" + + def test_compute_nodeid_initial_path_itself(self, tmp_path: Path) -> None: + """Test nodeid computation for initial_path itself returns empty string.""" + rootpath = tmp_path / "project" + rootpath.mkdir() + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + + result = nodes.compute_nodeid_prefix_for_path( + path=tests_dir, + rootpath=rootpath, + invocation_dir=rootpath, + initial_paths=frozenset([tests_dir]), + site_packages=frozenset(), + ) + + assert result == "" From 7ccc79f518ab481cb42d2e91c08c23ea8d7b4f55 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 11 Jan 2026 13:23:48 +0100 Subject: [PATCH 8/8] add: nodes.norm_sep helper for nodeid path separator normalization - Add norm_sep() to convert path separators to forward slashes - Simplifies handling of Windows paths and cross-platform data - Use norm_sep in compute_nodeid_prefix_for_path and FSCollector - Fix test_compute_nodeid_far_away_absolute for Windows compatibility Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude --- changelog/14098.improvement.rst | 3 +++ src/_pytest/nodes.py | 27 ++++++++++++++++++++------- testing/test_nodes.py | 3 ++- 3 files changed, 25 insertions(+), 8 deletions(-) create mode 100644 changelog/14098.improvement.rst diff --git a/changelog/14098.improvement.rst b/changelog/14098.improvement.rst new file mode 100644 index 00000000000..e9e51dfcb9c --- /dev/null +++ b/changelog/14098.improvement.rst @@ -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. diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 798e854a335..4b3ec60fe71 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -52,6 +52,20 @@ SEP = "/" + +def norm_sep(path: str | os.PathLike[str]) -> str: + """Normalize path separators to forward slashes for nodeid compatibility. + + Replaces backslashes with forward slashes. This handles both Windows native + paths and cross-platform data (e.g., Windows paths in serialized test reports + when running on Linux). + + :param path: A path string or PathLike object. + :returns: String with all backslashes replaced by forward slashes. + """ + return os.fspath(path).replace("\\", SEP) + + tracebackcutdir = Path(_pytest.__file__).parent @@ -649,21 +663,20 @@ def compute_nodeid_prefix_for_path( result = str(rel) if result == ".": return "" - return result.replace(os.sep, SEP) + return norm_sep(result) except ValueError: pass # 2. Try relative to initial_paths nodeid = _check_initialpaths_for_relpath(initial_paths, path) if nodeid is not None: - return nodeid.replace(os.sep, SEP) if nodeid else "" + return norm_sep(nodeid) if nodeid else "" # 3. Check if path is in site-packages site_info = _path_in_site_packages(path, site_packages) if site_info is not None: _sp_dir, rel_path = site_info - result = f"site://{rel_path}" - return result.replace(os.sep, SEP) + return f"site://{norm_sep(rel_path)}" # 4. Try relative to invocation_dir if "close by" (i.e., not too many ".." components) rel_from_invocation = bestrelpath(invocation_dir, path) @@ -675,10 +688,10 @@ def compute_nodeid_prefix_for_path( # - At most 2 ".." components (close to invocation dir) # - bestrelpath actually produced a relative path (not the absolute path unchanged) if up_count <= 2 and rel_from_invocation != str(path): - return rel_from_invocation.replace(os.sep, SEP) + return norm_sep(rel_from_invocation) # 5. Fall back to absolute path - return str(path).replace(os.sep, SEP) + return norm_sep(path) class FSCollector(Collector, abc.ABC): @@ -713,7 +726,7 @@ def __init__( pass else: name = str(rel) - name = name.replace(os.sep, SEP) + name = norm_sep(name) self.path = path if session is None: diff --git a/testing/test_nodes.py b/testing/test_nodes.py index d8a701dce40..e2e3798d3a6 100644 --- a/testing/test_nodes.py +++ b/testing/test_nodes.py @@ -264,7 +264,8 @@ def test_compute_nodeid_far_away_absolute(self, tmp_path: Path) -> None: ) # Should use absolute path since it's more than 2 levels up - assert result == str(far_away) + # Nodeids always use forward slashes regardless of OS + assert result == nodes.norm_sep(far_away) def test_compute_nodeid_rootpath_itself(self, tmp_path: Path) -> None: """Test nodeid computation for rootpath itself returns empty string."""