From 119daf34e7e33f05ac5f42faffa54f2b02329f8b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 15:29:49 +0000 Subject: [PATCH 1/4] Bound wrapped-fixture unwrapping independently of the recursion limit Fixes #15281. `_find_wrapped_fixture_def` used `inspect.unwrap`, whose depth limit is `sys.getrecursionlimit()`. `unittest.mock.call` returns a new `_Call` for every attribute, including `__wrapped__`, and slipped through the mock guard because its `_mock_name` is `None`. With a raised recursion limit, collection walked that chain until it exhausted memory. - Treat any `_mock_name` attribute as a mock by using `NOTSET` as the sentinel instead of `None`. - Walk `__wrapped__` with a local loop capped at 100 steps, with id-based loop detection. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014GZsRF9tm1CrxGUNz1r4UC --- changelog/15281.bugfix.rst | 1 + src/_pytest/fixtures.py | 53 +++++++++++++++++++++++--------------- testing/python/fixtures.py | 50 +++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 21 deletions(-) create mode 100644 changelog/15281.bugfix.rst 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/fixtures.py b/src/_pytest/fixtures.py index e3d6300b599..eba3d73fef7 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1761,6 +1761,11 @@ def deduplicate_names(*seqs: Iterable[str]) -> tuple[str, ...]: return tuple(dict.fromkeys(name for seq in seqs for name in seq)) +# Upper bound for following ``__wrapped__`` chains when looking for fixtures +# hidden behind decorators; real decorator stacks are far shallower. +_MAX_UNWRAP_DEPTH: Final = 100 + + class FixtureManager: """pytest fixture definitions and information is stored and managed from this class. @@ -2158,34 +2163,40 @@ def _find_wrapped_fixture_def( ) -> FixtureFunctionDefinition | None: """Find a FixtureFunctionDefinition in ``obj``'s ``__wrapped__`` chain. - Uses :func:`inspect.unwrap` with a ``stop`` callback so unwrapping does - not continue past :class:`FixtureFunctionDefinition` (which itself sets - ``__wrapped__`` via :func:`functools.update_wrapper`). + Stops at the first :class:`FixtureFunctionDefinition` (which itself sets + ``__wrapped__`` via :func:`functools.update_wrapper`). ``obj`` may also + be a descriptor (classmethod/staticmethod) exposing ``__wrapped__``. 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__. - # Use type(...) is, not isinstance: some proxies raise on - # isinstance via a broken __class__ (see pytest#4266). - unwrapped = inspect.unwrap( - cast(Callable[..., Any], obj), - stop=lambda o: type(o) is FixtureFunctionDefinition, - ) - except Exception: - # ValueError for wrapper loops / depth; also "evil objects" - # that raise on attribute access (see pytest#214). + # their wrapper chains. Any ``_mock_name`` attribute counts, even + # ``None``: ``unittest.mock.call`` has ``_mock_name = None`` and returns + # a new ``_Call`` for every attribute, including ``__wrapped__``. + if safe_getattr(obj, "_mock_name", NOTSET) is not NOTSET: return None - if type(unwrapped) is FixtureFunctionDefinition: - return unwrapped + # Walk __wrapped__ ourselves rather than via inspect.unwrap, whose + # depth limit is sys.getrecursionlimit(): objects that return a fresh + # __wrapped__ on every access would otherwise be followed until a + # raised recursion limit exhausts memory (pytest#15281). + # Use type(...) is, not isinstance: some proxies raise on isinstance + # via a broken __class__ (see pytest#4266). + # Keep visited objects alive so their ids are not reused. + seen: dict[int, object] = {} + current = obj + for _ in range(_MAX_UNWRAP_DEPTH): + if type(current) is FixtureFunctionDefinition: + return current + if id(current) in seen: + return None + seen[id(current)] = current + # safe_getattr also covers "evil objects" that raise on + # attribute access (see pytest#214). + current = safe_getattr(current, "__wrapped__", NOTSET) + if current is NOTSET: + return None return None def _check_for_wrapped_fixture(self, name: str, obj: object) -> None: diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index cb2f1daab63..84529f4dd44 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -6324,3 +6324,53 @@ 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_depth_is_bounded(pytester: Pytester) -> None: + """Objects returning a fresh ``__wrapped__`` on every access are only + followed for a fixed depth, independent of the recursion limit.""" + from _pytest.fixtures import _MAX_UNWRAP_DEPTH + + accesses = 0 + + class Endless: + @property + def __wrapped__(self) -> Endless: + nonlocal accesses + accesses += 1 + return Endless() + + fm = pytester.getitem("def test_func(): pass").session._fixturemanager + assert fm._find_wrapped_fixture_def(Endless()) is None + assert accesses == _MAX_UNWRAP_DEPTH + + +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 From 65cb16892aa1a4277ae7610a8a296e1c6fb60174 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 15:34:43 +0000 Subject: [PATCH 2/4] Cover wrapped-fixture loop detection in a test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014GZsRF9tm1CrxGUNz1r4UC --- testing/python/fixtures.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 84529f4dd44..f99353d791b 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -6367,6 +6367,22 @@ def __wrapped__(self) -> Endless: assert accesses == _MAX_UNWRAP_DEPTH +def test_find_wrapped_fixture_def_detects_loops(pytester: Pytester) -> None: + """A ``__wrapped__`` cycle stops at the first revisited object.""" + accesses = 0 + + class Loop: + @property + def __wrapped__(self) -> Loop: + nonlocal accesses + accesses += 1 + return self + + fm = pytester.getitem("def test_func(): pass").session._fixturemanager + assert fm._find_wrapped_fixture_def(Loop()) is None + assert accesses == 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 From f68dbb48d61f546a3f9ec7604d7327a2ec82d4e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 09:49:32 +0000 Subject: [PATCH 3/4] Replace the custom unwrap loop with a mock-aware stop Review feedback: the hand-rolled __wrapped__ walk added complexity on top of #13745. Go back to inspect.unwrap and stop it at mock-like objects instead, reusing the _is_mocked check that doctest already uses for the same unittest.mock.call problem (moved to compat so fixtures can share it). This also replaces the _mock_name guard, which missed mock.call. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014GZsRF9tm1CrxGUNz1r4UC --- src/_pytest/compat.py | 9 +++++++ src/_pytest/doctest.py | 11 +------- src/_pytest/fixtures.py | 53 ++++++++++++++------------------------ testing/python/fixtures.py | 35 ------------------------- 4 files changed, 30 insertions(+), 78 deletions(-) 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 eba3d73fef7..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 @@ -1761,11 +1762,6 @@ def deduplicate_names(*seqs: Iterable[str]) -> tuple[str, ...]: return tuple(dict.fromkeys(name for seq in seqs for name in seq)) -# Upper bound for following ``__wrapped__`` chains when looking for fixtures -# hidden behind decorators; real decorator stacks are far shallower. -_MAX_UNWRAP_DEPTH: Final = 100 - - class FixtureManager: """pytest fixture definitions and information is stored and managed from this class. @@ -2163,40 +2159,31 @@ def _find_wrapped_fixture_def( ) -> FixtureFunctionDefinition | None: """Find a FixtureFunctionDefinition in ``obj``'s ``__wrapped__`` chain. - Stops at the first :class:`FixtureFunctionDefinition` (which itself sets - ``__wrapped__`` via :func:`functools.update_wrapper`). ``obj`` may also - be a descriptor (classmethod/staticmethod) exposing ``__wrapped__``. + Uses :func:`inspect.unwrap` with a ``stop`` callback so unwrapping does + not continue past :class:`FixtureFunctionDefinition` (which itself sets + ``__wrapped__`` via :func:`functools.update_wrapper`). 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. Any ``_mock_name`` attribute counts, even - # ``None``: ``unittest.mock.call`` has ``_mock_name = None`` and returns - # a new ``_Call`` for every attribute, including ``__wrapped__``. - if safe_getattr(obj, "_mock_name", NOTSET) is not NOTSET: + try: + # unwrap is typed for callables, but we also pass descriptors + # (classmethod/staticmethod) that expose __wrapped__/__func__. + # Use type(...) is, not isinstance: some proxies raise on + # isinstance via a broken __class__ (see pytest#4266). + unwrapped = inspect.unwrap( + cast(Callable[..., Any], obj), + # 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" + # that raise on attribute access (see pytest#214). return None - # Walk __wrapped__ ourselves rather than via inspect.unwrap, whose - # depth limit is sys.getrecursionlimit(): objects that return a fresh - # __wrapped__ on every access would otherwise be followed until a - # raised recursion limit exhausts memory (pytest#15281). - # Use type(...) is, not isinstance: some proxies raise on isinstance - # via a broken __class__ (see pytest#4266). - # Keep visited objects alive so their ids are not reused. - seen: dict[int, object] = {} - current = obj - for _ in range(_MAX_UNWRAP_DEPTH): - if type(current) is FixtureFunctionDefinition: - return current - if id(current) in seen: - return None - seen[id(current)] = current - # safe_getattr also covers "evil objects" that raise on - # attribute access (see pytest#214). - current = safe_getattr(current, "__wrapped__", NOTSET) - if current is NOTSET: - return None + if type(unwrapped) is FixtureFunctionDefinition: + return unwrapped return None def _check_for_wrapped_fixture(self, name: str, obj: object) -> None: diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index f99353d791b..33885725d85 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -6348,41 +6348,6 @@ def test_trivial(): result.assert_outcomes(passed=1) -def test_find_wrapped_fixture_def_depth_is_bounded(pytester: Pytester) -> None: - """Objects returning a fresh ``__wrapped__`` on every access are only - followed for a fixed depth, independent of the recursion limit.""" - from _pytest.fixtures import _MAX_UNWRAP_DEPTH - - accesses = 0 - - class Endless: - @property - def __wrapped__(self) -> Endless: - nonlocal accesses - accesses += 1 - return Endless() - - fm = pytester.getitem("def test_func(): pass").session._fixturemanager - assert fm._find_wrapped_fixture_def(Endless()) is None - assert accesses == _MAX_UNWRAP_DEPTH - - -def test_find_wrapped_fixture_def_detects_loops(pytester: Pytester) -> None: - """A ``__wrapped__`` cycle stops at the first revisited object.""" - accesses = 0 - - class Loop: - @property - def __wrapped__(self) -> Loop: - nonlocal accesses - accesses += 1 - return self - - fm = pytester.getitem("def test_func(): pass").session._fixturemanager - assert fm._find_wrapped_fixture_def(Loop()) is None - assert accesses == 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 From 8acd92de17aeb28e16e0738f43c79abf61dbd783 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 09:58:28 +0000 Subject: [PATCH 4/4] Import _is_mocked from its new home in test_doctest Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014GZsRF9tm1CrxGUNz1r4UC --- testing/test_doctest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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