Skip to content

Commit 8a38e7a

Browse files
committed
Fix handling recursive symlinks
When pytest was run on a directory containing a recursive symlink it failed with ELOOP as the library was not able to determine the type of the direntry: src/_pytest/main.py:685: in collect if not direntry.is_file(): E OSError: [Errno 40] Too many levels of symbolic links: '/home/florian/proj/pytest/tests/recursive' This is fixed by handling ELOOP and other errors in the visit function in pathlib.py, so the entries whose is_file() call raises an OSError with the pre-defined list of error numbers will be exluded from the result. The _ignore_errors function was copied from Lib/pathlib.py of cpython 3.9. Fixes #7951
1 parent b95991a commit 8a38e7a

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
@@ -314,3 +314,4 @@ Xuecong Liao
314314
Yoav Caspi
315315
Zac Hatfield-Dodds
316316
Zoltán Máté
317+
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(follow_symlinks=False) 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
@@ -1404,3 +1404,17 @@ def a(): return 4
14041404
result = testdir.runpytest()
14051405
# Not INTERNAL_ERROR
14061406
assert result.ret == ExitCode.INTERRUPTED
1407+
1408+
1409+
def test_does_not_crash_on_recursive_symlink(testdir: Testdir) -> None:
1410+
"""Regression test for an issue around recursive symlinks (#7951)."""
1411+
symlink_or_skip("recursive", testdir.tmpdir.join("recursive"))
1412+
testdir.makepyfile(
1413+
"""
1414+
def test_foo(): assert True
1415+
"""
1416+
)
1417+
result = testdir.runpytest()
1418+
1419+
assert result.ret == ExitCode.OK
1420+
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)