Skip to content

Commit 107f2c6

Browse files
committed
main: use a better windows short-path comparison method
The previous check does not account for multiple levels of symlinks: given a -> b -> c, a would match b.
1 parent babe85e commit 107f2c6

File tree

4 files changed

+49
-8
lines changed

4 files changed

+49
-8
lines changed

changelog/13598.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed possible collection confusion on Windows when short paths and symlinks are involved.

src/_pytest/main.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from _pytest.pathlib import bestrelpath
4040
from _pytest.pathlib import fnmatch_ex
4141
from _pytest.pathlib import safe_exists
42+
from _pytest.pathlib import samefile_nofollow
4243
from _pytest.pathlib import scandir
4344
from _pytest.reports import CollectReport
4445
from _pytest.reports import TestReport
@@ -935,14 +936,10 @@ def collect(self) -> Iterator[nodes.Item | nodes.Collector]:
935936
is_match = node.path == matchparts[0]
936937
if sys.platform == "win32" and not is_match:
937938
# In case the file paths do not match, fallback to samefile() to
938-
# account for short-paths on Windows (#11895).
939-
same_file = os.path.samefile(node.path, matchparts[0])
940-
# We don't want to match links to the current node,
941-
# otherwise we would match the same file more than once (#12039).
942-
is_match = same_file and (
943-
os.path.islink(node.path)
944-
== os.path.islink(matchparts[0])
945-
)
939+
# account for short-paths on Windows (#11895). But use a version
940+
# which doesn't resolve symlinks, otherwise we might match the
941+
# same file more than once (#12039).
942+
is_match = samefile_nofollow(node.path, matchparts[0])
946943

947944
# Name part e.g. `TestIt` in `/a/b/test_file.py::TestIt::test_it`.
948945
else:

src/_pytest/pathlib.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,3 +1053,11 @@ def safe_exists(p: Path) -> bool:
10531053
# ValueError: stat: path too long for Windows
10541054
# OSError: [WinError 123] The filename, directory name, or volume label syntax is incorrect
10551055
return False
1056+
1057+
1058+
def samefile_nofollow(p1: Path, p2: Path) -> bool:
1059+
"""Test whether two paths reference the same actual file or directory.
1060+
1061+
Unlike Path.samefile(), does not resolve symlinks.
1062+
"""
1063+
return os.path.samestat(p1.lstat(), p2.lstat())

testing/test_collection.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1780,6 +1780,41 @@ def test_collect_short_file_windows(pytester: Pytester) -> None:
17801780
assert result.parseoutcomes() == {"passed": 1}
17811781

17821782

1783+
def test_collect_short_file_windows_multi_level_symlink(
1784+
pytester: Pytester,
1785+
request: FixtureRequest,
1786+
) -> None:
1787+
"""Regression test for multi-level Windows short-path comparison with
1788+
symlinks.
1789+
1790+
Previously, when matching collection arguments against collected nodes on
1791+
Windows, the short path fallback resolved symlinks. With a chain a -> b ->
1792+
target, comparing 'a' against 'b' would incorrectly succeed because both
1793+
resolved to 'target', which could cause incorrect matching or duplicate
1794+
collection.
1795+
"""
1796+
# Prepare target directory with a test file.
1797+
short_path = Path(tempfile.mkdtemp())
1798+
request.addfinalizer(lambda: shutil.rmtree(short_path, ignore_errors=True))
1799+
target = short_path / "target"
1800+
target.mkdir()
1801+
(target / "test_chain.py").write_text("def test_chain(): pass", encoding="UTF-8")
1802+
1803+
# Create multi-level symlink chain: a -> b -> target.
1804+
b = short_path / "b"
1805+
a = short_path / "a"
1806+
symlink_or_skip(target, b, target_is_directory=True)
1807+
symlink_or_skip(b, a, target_is_directory=True)
1808+
1809+
# Collect via the first symlink; should find exactly one test.
1810+
result = pytester.runpytest(a)
1811+
result.assert_outcomes(passed=1)
1812+
1813+
# Collect via the intermediate symlink; also exactly one test.
1814+
result = pytester.runpytest(b)
1815+
result.assert_outcomes(passed=1)
1816+
1817+
17831818
def test_pyargs_collection_tree(pytester: Pytester, monkeypatch: MonkeyPatch) -> None:
17841819
"""When using `--pyargs`, the collection tree of a pyargs collection
17851820
argument should only include parents in the import path, not up to confcutdir.

0 commit comments

Comments
 (0)