diff --git a/changelog/14004.bugfix.rst b/changelog/14004.bugfix.rst new file mode 100644 index 00000000000..102868cd925 --- /dev/null +++ b/changelog/14004.bugfix.rst @@ -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. 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. 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/fixtures.py b/src/_pytest/fixtures.py index d8d19fcac6d..432e6d3aa72 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 @@ -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 @@ -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). @@ -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. @@ -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. @@ -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. @@ -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( @@ -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 @@ -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 @@ -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: @@ -1793,9 +1831,18 @@ 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, @@ -1803,6 +1850,7 @@ def _register_fixture( ids=ids, _ispytest=True, _autouse=autouse, + node=node, ) faclist = self._arg2fixturedefs.setdefault(name, []) @@ -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( @@ -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 @@ -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( @@ -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 diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 6690f6ab1f8..4b3ec60fe71 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 @@ -51,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 @@ -557,6 +572,128 @@ 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 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 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 + 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) + # 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 norm_sep(rel_from_invocation) + + # 5. Fall back to absolute path + return norm_sep(path) + + class FSCollector(Collector, abc.ABC): """Base class for filesystem collectors.""" @@ -589,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: @@ -597,13 +734,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/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, ) 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) diff --git a/testing/test_nodes.py b/testing/test_nodes.py index de7875ca427..e2e3798d3a6 100644 --- a/testing/test_nodes.py +++ b/testing/test_nodes.py @@ -138,3 +138,163 @@ 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 + # 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.""" + 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 == ""