Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/15281.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions src/_pytest/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
11 changes: 1 addition & 10 deletions src/_pytest/doctest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 4 additions & 6 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2165,19 +2166,16 @@ 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__.
# 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,
# 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"
Expand Down
31 changes: 31 additions & 0 deletions testing/python/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion testing/test_doctest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading