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
This commit is contained in:
Cserna Zsolt 2020-10-28 08:27:43 +01:00
parent b95991aeea
commit 8a38e7a6e8
5 changed files with 67 additions and 1 deletions

View File

@ -314,3 +314,4 @@ Xuecong Liao
Yoav Caspi Yoav Caspi
Zac Hatfield-Dodds Zac Hatfield-Dodds
Zoltán Máté Zoltán Máté
Zsolt Cserna

View File

@ -0,0 +1 @@
Fixed handling of recursive symlinks when collecting tests.

View File

@ -9,6 +9,10 @@ import sys
import uuid import uuid
import warnings import warnings
from enum import Enum from enum import Enum
from errno import EBADF
from errno import ELOOP
from errno import ENOENT
from errno import ENOTDIR
from functools import partial from functools import partial
from os.path import expanduser from os.path import expanduser
from os.path import expandvars from os.path import expandvars
@ -37,6 +41,24 @@ LOCK_TIMEOUT = 60 * 60 * 24 * 3
_AnyPurePath = TypeVar("_AnyPurePath", bound=PurePath) _AnyPurePath = TypeVar("_AnyPurePath", bound=PurePath)
# The following function, variables and comments were
# copied from cpython 3.9 Lib/pathlib.py file.
# EBADF - guard against macOS `stat` throwing EBADF
_IGNORED_ERRORS = (ENOENT, ENOTDIR, EBADF, ELOOP)
_IGNORED_WINERRORS = (
21, # ERROR_NOT_READY - drive exists but is not accessible
1921, # ERROR_CANT_RESOLVE_FILENAME - fix for broken symlink pointing to itself
)
def _ignore_error(exception):
return (
getattr(exception, "errno", None) in _IGNORED_ERRORS
or getattr(exception, "winerror", None) in _IGNORED_WINERRORS
)
def get_lock_path(path: _AnyPurePath) -> _AnyPurePath: def get_lock_path(path: _AnyPurePath) -> _AnyPurePath:
return path.joinpath(".lock") return path.joinpath(".lock")
@ -555,8 +577,23 @@ def visit(
Entries at each directory level are sorted. Entries at each directory level are sorted.
""" """
entries = sorted(os.scandir(path), key=lambda entry: entry.name)
# Skip entries with symlink loops and other brokenness, so the caller doesn't
# have to deal with it.
entries = []
for entry in os.scandir(path):
try:
entry.is_file()
except OSError as err:
if _ignore_error(err):
continue
raise
entries.append(entry)
entries.sort(key=lambda entry: entry.name)
yield from entries yield from entries
for entry in entries: for entry in entries:
if entry.is_dir(follow_symlinks=False) and recurse(entry): if entry.is_dir(follow_symlinks=False) and recurse(entry):
yield from visit(entry.path, recurse) yield from visit(entry.path, recurse)

View File

@ -1404,3 +1404,17 @@ def test_does_not_crash_on_error_from_decorated_function(testdir: Testdir) -> No
result = testdir.runpytest() result = testdir.runpytest()
# Not INTERNAL_ERROR # Not INTERNAL_ERROR
assert result.ret == ExitCode.INTERRUPTED assert result.ret == ExitCode.INTERRUPTED
def test_does_not_crash_on_recursive_symlink(testdir: Testdir) -> None:
"""Regression test for an issue around recursive symlinks (#7951)."""
symlink_or_skip("recursive", testdir.tmpdir.join("recursive"))
testdir.makepyfile(
"""
def test_foo(): assert True
"""
)
result = testdir.runpytest()
assert result.ret == ExitCode.OK
assert result.parseoutcomes() == {"passed": 1}

View File

@ -17,6 +17,8 @@ from _pytest.pathlib import import_path
from _pytest.pathlib import ImportPathMismatchError from _pytest.pathlib import ImportPathMismatchError
from _pytest.pathlib import maybe_delete_a_numbered_dir from _pytest.pathlib import maybe_delete_a_numbered_dir
from _pytest.pathlib import resolve_package_path from _pytest.pathlib import resolve_package_path
from _pytest.pathlib import symlink_or_skip
from _pytest.pathlib import visit
class TestFNMatcherPort: class TestFNMatcherPort:
@ -401,3 +403,14 @@ def test_commonpath() -> None:
assert commonpath(subpath, path) == path assert commonpath(subpath, path) == path
assert commonpath(Path(str(path) + "suffix"), path) == path.parent assert commonpath(Path(str(path) + "suffix"), path) == path.parent
assert commonpath(path, path.parent.parent) == path.parent.parent assert commonpath(path, path.parent.parent) == path.parent.parent
def test_visit_ignores_errors(tmpdir) -> None:
symlink_or_skip("recursive", tmpdir.join("recursive"))
tmpdir.join("foo").write_binary(b"")
tmpdir.join("bar").write_binary(b"")
assert [entry.name for entry in visit(tmpdir, recurse=lambda entry: False)] == [
"bar",
"foo",
]