From e10769a3d6e7e9f155022cb97187bf55a18b78c8 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 9 Jan 2026 12:09:39 +0100 Subject: [PATCH 1/6] 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 | 86 ++++++++++++++++++++++++++++------------- 1 file changed, 60 insertions(+), 26 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 84f90f946be..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,26 +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: - nodeid = "" - if nodeid == ".": - nodeid = "" - elif nodeid: - nodeid = nodes.norm_sep(nodeid) + 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.""" @@ -1833,32 +1846,53 @@ def parsefactories( ) -> None: raise NotImplementedError() + @overload + def parsefactories( + self, + node_or_obj: None = ..., + nodeid: None = ..., + *, + holder: object, + node: nodes.Node, + ) -> None: + raise NotImplementedError() + def parsefactories( self, - node_or_obj: nodes.Node | object, + 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 @@ -1891,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 206c6010ee630a15ff0a29f4903bd5972b149fd0 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 9 Jan 2026 12:19:34 +0100 Subject: [PATCH 2/6] 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 a90992bc3924c65888fee0e8b359c71d2bcf369a Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 9 Jan 2026 12:28:22 +0100 Subject: [PATCH 3/6] 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 077b71d1d9567211c4996024e2c8e628227854f9 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 9 Jan 2026 12:36:15 +0100 Subject: [PATCH 4/6] 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 f859c93bbf2bc8a5cdc2b3a959512748310e52cd Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 9 Jan 2026 12:37:22 +0100 Subject: [PATCH 5/6] 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 585cdc4c572f6759e7d573bb536f7c62b824e9d1 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 11 Jan 2026 10:59:42 +0100 Subject: [PATCH 6/6] 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 bc1dfc90d96..acfc01a1073 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 @@ -571,6 +572,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.""" @@ -611,13 +735,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: - nodeid = norm_sep(nodeid) + 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 f66a11ce5c8..3803850d24e 100644 --- a/testing/test_nodes.py +++ b/testing/test_nodes.py @@ -169,3 +169,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 == ""