diff --git a/changelog/15281.bugfix.rst b/changelog/15281.bugfix.rst new file mode 100644 index 00000000000..949d7445b02 --- /dev/null +++ b/changelog/15281.bugfix.rst @@ -0,0 +1 @@ +Fixed excessive memory use and slow collection when a test module imports ``unittest.mock.call`` and the recursion limit has been raised, a regression in pytest 9.2.0. diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 1389c6bb6b8..f2b49a6d3c0 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -234,6 +234,15 @@ def getimfunc(func): return func +def _is_mocked(obj: object) -> bool: + """Return if an object is possibly a mock object by checking the + existence of a highly improbable attribute.""" + return ( + safe_getattr(obj, "pytest_mock_example_attribute_that_shouldnt_exist", None) + is not None + ) + + def safe_getattr(object: Any, name: str, default: Any) -> Any: """Like getattr but return default upon any Exception or any OutcomeException. diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index 04d707113cc..6db71c50cd2 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -28,7 +28,7 @@ from _pytest._code.code import TerminalRepr from _pytest._io import TerminalWriter from _pytest.approx import approx -from _pytest.compat import safe_getattr +from _pytest.compat import _is_mocked from _pytest.config import Config from _pytest.config.argparsing import Parser from _pytest.fixtures import fixture @@ -464,15 +464,6 @@ def _check_all_skipped(test: doctest.DocTest) -> None: skip("all tests skipped by +SKIP option") -def _is_mocked(obj: object) -> bool: - """Return if an object is possibly a mock object by checking the - existence of a highly improbable attribute.""" - return ( - safe_getattr(obj, "pytest_mock_example_attribute_that_shouldnt_exist", None) - is not None - ) - - @contextmanager def _patch_unwrap_mock_aware() -> Generator[None]: """Context manager which replaces ``inspect.unwrap`` with a version diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index e3d6300b599..f979b7650f0 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -39,6 +39,7 @@ from _pytest._code.code import ExceptionInfoFormatter from _pytest._code.code import TerminalRepr from _pytest._io import TerminalWriter +from _pytest.compat import _is_mocked from _pytest.compat import assert_never from _pytest.compat import deprecated from _pytest.compat import get_real_func @@ -2165,11 +2166,6 @@ def _find_wrapped_fixture_def( Returns the definition if found, else ``None``. Loops, excessive depth, and objects that raise on attribute access are treated as not found. """ - # Skip mock objects to avoid false positives when traversing - # their wrapper chains (they have a _mock_name attribute). - if safe_getattr(obj, "_mock_name", None) is not None: - return None - try: # unwrap is typed for callables, but we also pass descriptors # (classmethod/staticmethod) that expose __wrapped__/__func__. @@ -2177,7 +2173,9 @@ def _find_wrapped_fixture_def( # isinstance via a broken __class__ (see pytest#4266). unwrapped = inspect.unwrap( cast(Callable[..., Any], obj), - stop=lambda o: type(o) is FixtureFunctionDefinition, + # Stop at mock-like objects (e.g. unittest.mock.call) whose + # __wrapped__ chain never ends (#15281). + stop=lambda o: type(o) is FixtureFunctionDefinition or _is_mocked(o), ) except Exception: # ValueError for wrapper loops / depth; also "evil objects" diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index cb2f1daab63..33885725d85 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -6324,3 +6324,34 @@ def test_fixt(self, fixt): ) result.stdout.fnmatch_lines(["*fixture 'fixt' not found*"]) result.assert_outcomes(errors=1) + + +def test_mock_call_with_raised_recursionlimit(pytester: Pytester) -> None: + """Collecting a module importing ``mock.call`` must not walk its endless + ``__wrapped__`` chain up to the recursion limit (#15281).""" + pytester.makeconftest( + """ + import sys + + sys.setrecursionlimit(100_000) + """ + ) + pytester.makepyfile( + """ + from unittest.mock import call + + def test_trivial(): + pass + """ + ) + result = pytester.runpytest_subprocess(timeout=60) + result.assert_outcomes(passed=1) + + +def test_find_wrapped_fixture_def_skips_mock_call(pytester: Pytester) -> None: + """``mock.call`` has ``_mock_name = None``; it must still be skipped.""" + from unittest import mock + + fm = pytester.getitem("def test_func(): pass").session._fixturemanager + assert fm._find_wrapped_fixture_def(mock.call) is None + assert fm._find_wrapped_fixture_def(mock.Mock()) is None diff --git a/testing/test_doctest.py b/testing/test_doctest.py index a322e9eb2a3..4c9344cbeb1 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -7,9 +7,9 @@ import sys import textwrap +from _pytest.compat import _is_mocked from _pytest.doctest import _get_checker from _pytest.doctest import _is_main_py -from _pytest.doctest import _is_mocked from _pytest.doctest import _is_setup_py from _pytest.doctest import _patch_unwrap_mock_aware from _pytest.doctest import DoctestItem