Skip to content
Closed
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
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ Mark Abramowitz
Mark Dickinson
Mark Vong
Marko Pacak
marko1olo
Markus Unterwaditzer
Martijn Faassen
Martin Altmayer
Expand Down
3 changes: 3 additions & 0 deletions changelog/9703.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Fixed collection node IDs for explicitly requested test files outside the configured
``rootdir``, avoiding duplicate last-failed cache entries and preserving file-scoped
fixture visibility when using ``-c`` with a config file in another directory.
2 changes: 1 addition & 1 deletion src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1275,7 +1275,7 @@ def cwd_relative_nodeid(self, nodeid: str) -> str:
if self.invocation_params.dir != self.rootpath:
base_path_part, *nodeid_part = nodeid.split("::")
# Only process path part
fullpath = self.rootpath / base_path_part
fullpath = absolutepath(self.rootpath / base_path_part)
relative_path = bestrelpath(self.invocation_params.dir, fullpath)

nodeid = "::".join([relative_path, *nodeid_part])
Expand Down
29 changes: 27 additions & 2 deletions src/_pytest/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,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 @@ -537,9 +538,13 @@ def _traceback_filter(self, excinfo: ExceptionInfo[BaseException]) -> Traceback:

@lru_cache(maxsize=1000)
def _check_initialpaths_for_relpath(
initial_paths: frozenset[Path], path: Path
initial_paths: frozenset[Path], path: Path, rootpath: Path | None = None
) -> str | None:
if path in initial_paths:
if rootpath is not None and _has_multiple_outside_initial_files(
initial_paths, rootpath
):
return bestrelpath(rootpath, path)
return ""

for parent in path.parents:
Expand All @@ -549,6 +554,24 @@ def _check_initialpaths_for_relpath(
return None


def _has_multiple_outside_initial_files(
initial_paths: frozenset[Path], rootpath: Path
) -> bool:
outside_file_count = 0
for initial_path in initial_paths:
if not initial_path.is_file():
continue

try:
initial_path.relative_to(rootpath)
except ValueError:
outside_file_count += 1
if outside_file_count > 1:
return True

return False


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

Expand Down Expand Up @@ -592,7 +615,9 @@ def __init__(
try:
nodeid = str(self.path.relative_to(session.config.rootpath))
except ValueError:
nodeid = _check_initialpaths_for_relpath(session._initialpaths, path)
nodeid = _check_initialpaths_for_relpath(
session._initialpaths, path, session.config.rootpath
)

if nodeid:
nodeid = norm_sep(nodeid)
Expand Down
90 changes: 90 additions & 0 deletions testing/test_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from __future__ import annotations

from collections.abc import Sequence
import json
import os
from pathlib import Path
from pathlib import PurePath
Expand Down Expand Up @@ -261,6 +262,95 @@ def test_testpaths_ini(self, pytester: Pytester, monkeypatch: MonkeyPatch) -> No
items, _reprec = pytester.inline_genitems()
assert [x.name for x in items] == [f"test_{dirname}"]

def test_config_outside_test_paths_keeps_unique_lastfailed_nodeids(
self, pytester: Pytester
) -> None:
"""Regression test for #9703."""
test_dir = pytester.mkdir("test")
test_dir.joinpath("test_file1.py").write_text(
"def test_same():\n assert False\n",
encoding="utf-8",
)
test_dir.joinpath("test_file2.py").write_text(
"def test_same():\n assert False\n",
encoding="utf-8",
)
config_dir = pytester.mkdir("config")
config_dir.joinpath("pytest.ini").write_text("[pytest]\n", encoding="utf-8")

result = pytester.runpytest(
"-c",
"config/pytest.ini",
"test/test_file1.py",
"test/test_file2.py",
)

result.assert_outcomes(failed=2)
lastfailed = json.loads(
config_dir.joinpath(".pytest_cache", "v", "cache", "lastfailed").read_text(
encoding="utf-8"
)
)
assert set(lastfailed) == {
"../test/test_file1.py::test_same",
"../test/test_file2.py::test_same",
}

def test_config_outside_test_paths_keeps_file_scoped_autouse(
self, pytester: Pytester
) -> None:
"""Regression test for #9703."""
test_dir = pytester.mkdir("test")
test_dir.joinpath("test_file1.py").write_text(
textwrap.dedent(
"""
import pytest

@pytest.fixture(autouse=True)
def some_fixture():
print("Fixture called")

def test_in_file1(request):
print("test_in_file1")
assert request.node.nodeid == "../test/test_file1.py::test_in_file1"
"""
),
encoding="utf-8",
)
test_dir.joinpath("test_file2.py").write_text(
textwrap.dedent(
"""
def test_in_file2(request):
print("test_in_file2")
assert "some_fixture" not in request.fixturenames
assert request.node.nodeid == "../test/test_file2.py::test_in_file2"
"""
),
encoding="utf-8",
)
config_dir = pytester.mkdir("config")
config_dir.joinpath("pytest.ini").write_text("[pytest]\n", encoding="utf-8")

result = pytester.runpytest(
"-c",
"config/pytest.ini",
"-s",
"-v",
"test/test_file1.py",
"test/test_file2.py",
)

result.assert_outcomes(passed=2)
result.stdout.fnmatch_lines(
[
f"test{os.sep}test_file1.py::test_in_file1 Fixture called",
"test_in_file1",
"PASSED",
f"test{os.sep}test_file2.py::test_in_file2 test_in_file2",
"PASSED",
]
)

def test_missing_permissions_on_unselected_directory_doesnt_crash(
self, pytester: Pytester
) -> None:
Expand Down
54 changes: 54 additions & 0 deletions testing/test_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,60 @@ def test__check_initialpaths_for_relpath() -> None:
assert nodes._check_initialpaths_for_relpath(initial_paths, outside) is None


def test__check_initialpaths_for_relpath_keeps_single_outside_file(
tmp_path: Path,
) -> None:
root = tmp_path / "root"
root.mkdir()
outside = tmp_path / "test_outside.py"
outside.write_text("", encoding="utf-8")

assert (
nodes._check_initialpaths_for_relpath(frozenset({outside}), outside, root) == ""
)


def test__check_initialpaths_for_relpath_keeps_inside_file(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
inside = root / "test_inside.py"
inside.write_text("", encoding="utf-8")

assert (
nodes._check_initialpaths_for_relpath(frozenset({inside}), inside, root) == ""
)


def test__check_initialpaths_for_relpath_keeps_initial_directory(
tmp_path: Path,
) -> None:
root = tmp_path / "root"
root.mkdir()

assert (
nodes._check_initialpaths_for_relpath(frozenset({tmp_path}), tmp_path, root)
== ""
)


def test__check_initialpaths_for_relpath_disambiguates_outside_files(
tmp_path: Path,
) -> None:
root = tmp_path / "root"
root.mkdir()
outside_1 = tmp_path / "test_outside_1.py"
outside_1.write_text("", encoding="utf-8")
outside_2 = tmp_path / "test_outside_2.py"
outside_2.write_text("", encoding="utf-8")

nodeid = nodes._check_initialpaths_for_relpath(
frozenset({outside_1, outside_2}), outside_1, root
)

assert nodeid is not None
assert nodes.norm_sep(nodeid) == "../test_outside_1.py"


def test_failure_with_changed_cwd(pytester: Pytester) -> None:
"""
Test failure lines should use absolute paths if cwd has changed since
Expand Down
Loading