diff --git a/AUTHORS b/AUTHORS index 136ffd6c5ee..aa9ce45e90a 100644 --- a/AUTHORS +++ b/AUTHORS @@ -426,6 +426,7 @@ Raquel Alegre Ravi Chandra Reagan Lee Reilly Brogan +rinopatrick Rob Arrow Robert Holt Roberto Aldera diff --git a/changelog/15281.bugfix.rst b/changelog/15281.bugfix.rst new file mode 100644 index 00000000000..6d103524caf --- /dev/null +++ b/changelog/15281.bugfix.rst @@ -0,0 +1 @@ +Fixed collection hanging and using gigabytes of memory when a collected module contains ``unittest.mock.call`` (or similar mock objects) at module level while the recursion limit is raised: mock objects are now skipped before traversing their ``__wrapped__`` chain. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index e3d6300b599..3e873411401 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -2166,8 +2166,11 @@ def _find_wrapped_fixture_def( 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: + # their wrapper chains. Any object with a `_mock_name` attribute is + # a mock — including `unittest.mock.call`, whose `_mock_name` is None + # and whose `__wrapped__` returns a new object on every access, so + # unwrapping it only stops at the recursion limit (see pytest#15281). + if safe_getattr(obj, "_mock_name", NOTSET) is not NOTSET: return None try: diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index cb2f1daab63..ac53adcab0e 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -6294,6 +6294,36 @@ def test_fixt(self, fixt): result.assert_outcomes(passed=1) +def test_find_wrapped_fixture_def_skips_mock_call_without_unwrap( + monkeypatch: MonkeyPatch, +) -> None: + """`unittest.mock.call` must be skipped before `inspect.unwrap` (see #15281). + + ``call`` is a ``_Call`` whose ``_mock_name`` is ``None`` and whose + ``__wrapped__`` returns a new ``_Call`` on every access, so unwrapping it + only stops at the recursion limit (slow + huge memory with a raised limit). + """ + import inspect + from typing import cast + from unittest.mock import call + + from _pytest.fixtures import FixtureManager + + calls = [] + real_unwrap = inspect.unwrap + + def counting_unwrap(*args, **kwargs): + calls.append(args) + return real_unwrap(*args, **kwargs) + + monkeypatch.setattr(inspect, "unwrap", counting_unwrap) + # `_find_wrapped_fixture_def` does not use `self`; call it on the class + # with a dummy instance to avoid constructing a full `FixtureManager`. + manager = cast(FixtureManager, object()) + assert FixtureManager._find_wrapped_fixture_def(manager, call) is None + assert calls == [] + + @pytest.mark.filterwarnings("default:cannot discover fixture *:pytest.PytestWarning") def test_classmethod_above_fixture_warning_inherited(pytester: Pytester) -> None: """MRO ``__dict__`` lookup finds @classmethod wrappers on a base class."""