diff --git a/changelog/14864.bugfix.rst b/changelog/14864.bugfix.rst new file mode 100644 index 00000000000..27f6d7a401e --- /dev/null +++ b/changelog/14864.bugfix.rst @@ -0,0 +1 @@ +Fixed collection on Windows collecting the whole suite instead of the given path, on file systems which do not support file IDs (``st_ino`` is ``0`` for every file, as seen for example on ``sshfs-win``/WinFsp mounts). The Windows-only short-path fallback used when matching collection arguments now ignores a zero file ID and compares paths instead. diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 10326e1c9a6..7eded1f4e95 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -1090,9 +1090,30 @@ def safe_exists(p: Path) -> bool: return False -def samefile_nofollow(p1: Path, p2: Path) -> bool: - """Test whether two paths reference the same actual file or directory. +if sys.platform == "win32": + + def samefile_nofollow(p1: Path, p2: Path) -> bool: + """Test whether two paths reference the same actual file or directory. + + Unlike Path.samefile(), does not resolve symlinks. + + On Windows st_ino is the file ID, which file systems are free to not + support, in which case it is 0 for every file -- WinFsp mounts such as + sshfs-win are one example. os.path.samestat() would then consider any two + files on the volume to be the same (python/cpython#78116), so a zero file + ID is treated as "unknown", leaving the caller with plain path comparison + like on the other platforms (#14864). + """ + s1, s2 = p1.lstat(), p2.lstat() + if not s1.st_ino or not s2.st_ino: + return False + return os.path.samestat(s1, s2) - Unlike Path.samefile(), does not resolve symlinks. - """ - return os.path.samestat(p1.lstat(), p2.lstat()) +else: + + def samefile_nofollow(p1: Path, p2: Path) -> bool: + """Test whether two paths reference the same actual file or directory. + + Unlike Path.samefile(), does not resolve symlinks. + """ + return os.path.samestat(p1.lstat(), p2.lstat()) diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py index bd85b7e8fb4..402ca36a2c3 100644 --- a/testing/test_pathlib.py +++ b/testing/test_pathlib.py @@ -38,6 +38,7 @@ from _pytest.pathlib import resolve_package_path from _pytest.pathlib import resolve_pkg_root_and_module_name from _pytest.pathlib import safe_exists +from _pytest.pathlib import samefile_nofollow from _pytest.pathlib import scandir from _pytest.pathlib import spec_matches_module_path from _pytest.pathlib import symlink_or_skip @@ -570,6 +571,39 @@ def test_samefile_false_negatives(tmp_path: Path, monkeypatch: MonkeyPatch) -> N assert getattr(module, "foo")() == 42 +def test_samefile_nofollow(tmp_path: Path) -> None: + p1 = tmp_path / "test_one.py" + p2 = tmp_path / "test_two.py" + p1.touch() + p2.touch() + + assert samefile_nofollow(p1, p1) + assert not samefile_nofollow(p1, p2) + + +@pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows only") +def test_samefile_nofollow_zero_file_id( + tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + """On Windows file systems which do not support file IDs, st_ino is 0 for every + file, which must not make two distinct files compare equal (#14864).""" + p1 = tmp_path / "test_one.py" + p2 = tmp_path / "test_two.py" + p1.touch() + p2.touch() + + # st_dev is a real value (the volume serial number), only the file ID is missing. + zero_file_id = os.stat_result((0o100644, 0, 3816903231, 1, 0, 0, 0, 0, 0, 0)) + with monkeypatch.context() as mp: + # Use a context to narrow the patch as much as possible, given how central + # Path.lstat() is. + mp.setattr(Path, "lstat", lambda self: zero_file_id) + + assert not samefile_nofollow(p1, p2) + # Also for the same file -- the caller compares paths first anyway. + assert not samefile_nofollow(p1, p1) + + def test_scandir_with_non_existent_directory() -> None: # Test with a directory that does not exist non_existent_dir = "path_to_non_existent_dir"