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", + ]