Skip to content

Commit 7fb0ea3

Browse files
authored
Merge pull request #7956 from csernazs/fix-7951
Fix handling recursive symlinks
2 parents 1c18fb8 + 8a38e7a commit 7fb0ea3

File tree

5 files changed

+67
-1
lines changed

5 files changed

+67
-1
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,3 +316,4 @@ Xuecong Liao
316316
Yoav Caspi
317317
Zac Hatfield-Dodds
318318
Zoltán Máté
319+
Zsolt Cserna

changelog/7951.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed handling of recursive symlinks when collecting tests.

src/_pytest/pathlib.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
import uuid
1010
import warnings
1111
from enum import Enum
12+
from errno import EBADF
13+
from errno import ELOOP
14+
from errno import ENOENT
15+
from errno import ENOTDIR
1216
from functools import partial
1317
from os.path import expanduser
1418
from os.path import expandvars
@@ -37,6 +41,24 @@
3741

3842
_AnyPurePath = TypeVar("_AnyPurePath", bound=PurePath)
3943

44+
# The following function, variables and comments were
45+
# copied from cpython 3.9 Lib/pathlib.py file.
46+
47+
# EBADF - guard against macOS `stat` throwing EBADF
48+
_IGNORED_ERRORS = (ENOENT, ENOTDIR, EBADF, ELOOP)
49+
50+
_IGNORED_WINERRORS = (
51+
21, # ERROR_NOT_READY - drive exists but is not accessible
52+
1921, # ERROR_CANT_RESOLVE_FILENAME - fix for broken symlink pointing to itself
53+
)
54+
55+
56+
def _ignore_error(exception):
57+
return (
58+
getattr(exception, "errno", None) in _IGNORED_ERRORS
59+
or getattr(exception, "winerror", None) in _IGNORED_WINERRORS
60+
)
61+
4062

4163
def get_lock_path(path: _AnyPurePath) -> _AnyPurePath:
4264
return path.joinpath(".lock")
@@ -555,8 +577,23 @@ def visit(
555577
556578
Entries at each directory level are sorted.
557579
"""
558-
entries = sorted(os.scandir(path), key=lambda entry: entry.name)
580+
581+
# Skip entries with symlink loops and other brokenness, so the caller doesn't
582+
# have to deal with it.
583+
entries = []
584+
for entry in os.scandir(path):
585+
try:
586+
entry.is_file()
587+
except OSError as err:
588+
if _ignore_error(err):
589+
continue
590+
raise
591+
entries.append(entry)
592+
593+
entries.sort(key=lambda entry: entry.name)
594+
559595
yield from entries
596+
560597
for entry in entries:
561598
if entry.is_dir() and recurse(entry):
562599
yield from visit(entry.path, recurse)

testing/test_collection.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,3 +1414,17 @@ def a(): return 4
14141414
result = testdir.runpytest()
14151415
# Not INTERNAL_ERROR
14161416
assert result.ret == ExitCode.INTERRUPTED
1417+
1418+
1419+
def test_does_not_crash_on_recursive_symlink(testdir: Testdir) -> None:
1420+
"""Regression test for an issue around recursive symlinks (#7951)."""
1421+
symlink_or_skip("recursive", testdir.tmpdir.join("recursive"))
1422+
testdir.makepyfile(
1423+
"""
1424+
def test_foo(): assert True
1425+
"""
1426+
)
1427+
result = testdir.runpytest()
1428+
1429+
assert result.ret == ExitCode.OK
1430+
assert result.parseoutcomes() == {"passed": 1}

testing/test_pathlib.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
from _pytest.pathlib import ImportPathMismatchError
1818
from _pytest.pathlib import maybe_delete_a_numbered_dir
1919
from _pytest.pathlib import resolve_package_path
20+
from _pytest.pathlib import symlink_or_skip
21+
from _pytest.pathlib import visit
2022

2123

2224
class TestFNMatcherPort:
@@ -401,3 +403,14 @@ def test_commonpath() -> None:
401403
assert commonpath(subpath, path) == path
402404
assert commonpath(Path(str(path) + "suffix"), path) == path.parent
403405
assert commonpath(path, path.parent.parent) == path.parent.parent
406+
407+
408+
def test_visit_ignores_errors(tmpdir) -> None:
409+
symlink_or_skip("recursive", tmpdir.join("recursive"))
410+
tmpdir.join("foo").write_binary(b"")
411+
tmpdir.join("bar").write_binary(b"")
412+
413+
assert [entry.name for entry in visit(tmpdir, recurse=lambda entry: False)] == [
414+
"bar",
415+
"foo",
416+
]

0 commit comments

Comments
 (0)