summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AUTHORS1
-rw-r--r--changelog/7951.bugfix.rst1
-rw-r--r--src/_pytest/pathlib.py39
-rw-r--r--testing/test_collection.py14
-rw-r--r--testing/test_pathlib.py13
5 files changed, 67 insertions, 1 deletions
diff --git a/AUTHORS b/AUTHORS
index 35d220e00..f8d3d421c 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -314,3 +314,4 @@ Xuecong Liao
Yoav Caspi
Zac Hatfield-Dodds
Zoltán Máté
+Zsolt Cserna
diff --git a/changelog/7951.bugfix.rst b/changelog/7951.bugfix.rst
new file mode 100644
index 000000000..56c71db78
--- /dev/null
+++ b/changelog/7951.bugfix.rst
@@ -0,0 +1 @@
+Fixed handling of recursive symlinks when collecting tests.
diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py
index f0bdb1481..a1c364076 100644
--- a/src/_pytest/pathlib.py
+++ b/src/_pytest/pathlib.py
@@ -9,6 +9,10 @@ import sys
import uuid
import warnings
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 os.path import expanduser
from os.path import expandvars
@@ -37,6 +41,24 @@ LOCK_TIMEOUT = 60 * 60 * 24 * 3
_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:
return path.joinpath(".lock")
@@ -555,8 +577,23 @@ def visit(
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
+
for entry in entries:
if entry.is_dir(follow_symlinks=False) and recurse(entry):
yield from visit(entry.path, recurse)
diff --git a/testing/test_collection.py b/testing/test_collection.py
index 841aa358b..b05048742 100644
--- a/testing/test_collection.py
+++ b/testing/test_collection.py
@@ -1404,3 +1404,17 @@ def test_does_not_crash_on_error_from_decorated_function(testdir: Testdir) -> No
result = testdir.runpytest()
# Not INTERNAL_ERROR
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}
diff --git a/testing/test_pathlib.py b/testing/test_pathlib.py
index e37b33847..0507e3d68 100644
--- a/testing/test_pathlib.py
+++ b/testing/test_pathlib.py
@@ -17,6 +17,8 @@ from _pytest.pathlib import import_path
from _pytest.pathlib import ImportPathMismatchError
from _pytest.pathlib import maybe_delete_a_numbered_dir
from _pytest.pathlib import resolve_package_path
+from _pytest.pathlib import symlink_or_skip
+from _pytest.pathlib import visit
class TestFNMatcherPort:
@@ -401,3 +403,14 @@ def test_commonpath() -> None:
assert commonpath(subpath, path) == path
assert commonpath(Path(str(path) + "suffix"), path) == path.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",
+ ]