diff --git a/AUTHORS b/AUTHORS index 27c0b3ac408..408ad6c063c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -302,6 +302,7 @@ Mark Abramowitz Mark Dickinson Mark Vong Marko Pacak +marko1olo Markus Unterwaditzer Martijn Faassen Martin Altmayer diff --git a/changelog/9703.bugfix.rst b/changelog/9703.bugfix.rst new file mode 100644 index 00000000000..d04e335f1e6 --- /dev/null +++ b/changelog/9703.bugfix.rst @@ -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. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index bc75c1e16fc..78d71277a91 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -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]) diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index f0629c2daf7..f4b6f02f579 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -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 @@ -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: @@ -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.""" @@ -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) diff --git a/testing/test_collection.py b/testing/test_collection.py index 093162ddec4..5446127d613 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -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 @@ -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: diff --git a/testing/test_nodes.py b/testing/test_nodes.py index e976b9e6f11..56bc9f7d739 100644 --- a/testing/test_nodes.py +++ b/testing/test_nodes.py @@ -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