Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions changelog/14004.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Fixed conftest.py fixture scoping when ``testpaths`` points outside ``rootdir``.

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.
102 changes: 93 additions & 9 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -1629,23 +1629,107 @@ def pytest_plugin_registered(self, plugin: _PluggyPlugin, plugin_name: str) -> N
# 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)
nodeid = self._compute_conftest_nodeid(conftestpath.parent)
else:
nodeid = None

self.parsefactories(plugin, nodeid)

def _compute_conftest_nodeid(self, conftest_dir: Path) -> str:
"""Compute nodeid for a conftest directory.

The nodeid must match how FSCollector computes nodeids so that
fixture scoping works correctly. This is especially important when
testpaths points outside rootdir (issue #14004).

This mirrors the logic in FSCollector.__init__:
1. Try relative to rootpath
2. Fall back to _check_initialpaths_for_relpath logic
"""
rootpath = self.config.rootpath
invocation_dir = self.config.invocation_params.dir

# First, try relative to rootpath (same as FSCollector)
try:
nodeid = str(conftest_dir.relative_to(rootpath))
if nodeid == ".":
return ""
return nodes.norm_sep(nodeid)
except ValueError:
pass

# Path is outside rootpath. Use the same logic as FSCollector's
# _check_initialpaths_for_relpath fallback.
#
# During collection, _initialpaths is available and contains the
# resolved collection targets (including --pyargs targets). This
# allows conftests in those targets to get the correct nodeid.
initialpaths = self.session._initialpaths
if initialpaths:
# Same logic as _check_initialpaths_for_relpath in nodes.py
if conftest_dir in initialpaths:
return ""
for initialpath in initialpaths:
try:
rel = conftest_dir.relative_to(initialpath)
nodeid = str(rel)
if nodeid == ".":
return ""
return nodes.norm_sep(nodeid)
except ValueError:
continue

# During initial conftest loading (before collection), _initialpaths
# is empty. Fall back to checking testpaths configuration.
testpaths = self.config.getini("testpaths")
if testpaths:
for testpath_str in testpaths:
# Resolve testpath relative to invocation_dir
testpath = (invocation_dir / testpath_str).resolve()
# Only consider testpaths that are outside rootpath
try:
testpath.relative_to(rootpath)
# testpath is under rootpath, skip
continue
except ValueError:
pass
# testpath is outside rootpath, check if conftest is under it
try:
rel = conftest_dir.relative_to(testpath)
nodeid = str(rel)
if nodeid == ".":
return ""
return nodes.norm_sep(nodeid)
except ValueError:
continue

# Path is outside rootpath, not under any initialpath or testpath.
# Check if rootpath is under conftest_dir (conftest is a parent).
# In this case, the conftest should be global (nodeid="").
try:
rootpath.relative_to(conftest_dir)
# rootpath is under conftest_dir, so conftest is a parent
return ""
except ValueError:
pass

# For all other cases (e.g., site-packages, unrelated paths),
# return empty nodeid so fixtures are globally visible.
# This matches the behavior when FSCollector's
# _check_initialpaths_for_relpath returns None.
return ""

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

Expand Down
124 changes: 123 additions & 1 deletion src/_pytest/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -571,6 +572,127 @@ 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()

def add_from(getter: Callable[[], list[str]]) -> None:
"""Call getter and add resolved paths to dirs.

Handles AttributeError (function missing in some virtualenvs)
and OSError (path resolution failures).
"""
try:
paths = getter()
except AttributeError:
return
for p in paths:
try:
dirs.add(Path(p).resolve())
except OSError:
pass

add_from(lambda: site.getsitepackages())
add_from(lambda: [site.getusersitepackages()])
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,
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. site-packages - use "site://<package>/<path>" prefix
3. invocation_dir - if close by, use relative path with ".." components
4. 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 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. 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)

# 3. 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)

# 4. Fall back to absolute path
return str(path).replace(os.sep, SEP)


class FSCollector(Collector, abc.ABC):
"""Base class for filesystem collectors."""

Expand Down Expand Up @@ -612,7 +734,7 @@ def __init__(

if nodeid is None:
try:
nodeid = str(self.path.relative_to(session.config.rootpath))
nodeid = str(path.relative_to(session.config.rootpath))
except ValueError:
nodeid = _check_initialpaths_for_relpath(session._initialpaths, path)

Expand Down
Loading