pytest-mockrot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mockrot/__init__.py +7 -0
- mockrot/cli.py +23 -0
- mockrot/errors.py +19 -0
- mockrot/extractor.py +180 -0
- mockrot/interceptor.py +418 -0
- mockrot/models.py +66 -0
- mockrot/plugin.py +186 -0
- mockrot/py.typed +1 -0
- mockrot/reporter.py +164 -0
- mockrot/validator.py +420 -0
- pytest_mockrot-0.1.0.dist-info/METADATA +261 -0
- pytest_mockrot-0.1.0.dist-info/RECORD +15 -0
- pytest_mockrot-0.1.0.dist-info/WHEEL +4 -0
- pytest_mockrot-0.1.0.dist-info/entry_points.txt +5 -0
- pytest_mockrot-0.1.0.dist-info/licenses/LICENSE +21 -0
mockrot/__init__.py
ADDED
mockrot/cli.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Standalone CLI entry point for Mockrot (delegates to pytest)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(argv: list[str] | None = None) -> int:
|
|
11
|
+
"""Run pytest with Mockrot enabled by default."""
|
|
12
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
13
|
+
|
|
14
|
+
# Ensure --mockrot flag is injected if not explicitly provided
|
|
15
|
+
has_mockrot_flag = any(arg.startswith("--mockrot") for arg in args)
|
|
16
|
+
if not has_mockrot_flag:
|
|
17
|
+
args.insert(0, "--mockrot=warn")
|
|
18
|
+
|
|
19
|
+
return int(pytest.main(args))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
sys.exit(main())
|
mockrot/errors.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Exceptions hierarchy for Mockrot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MockrotError(Exception):
|
|
7
|
+
"""Base exception for all Mockrot runtime and configuration errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ContractExtractionError(MockrotError):
|
|
11
|
+
"""Raised when extraction of signature or type hints fails unexpectedly."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ContractValidationError(MockrotError):
|
|
15
|
+
"""Raised when a mock interaction violates the target contract."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfigurationError(MockrotError):
|
|
19
|
+
"""Raised when Mockrot configuration in pyproject.toml or CLI is invalid."""
|
mockrot/extractor.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Contract extraction layer inspecting signatures and type metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import sys
|
|
7
|
+
import weakref
|
|
8
|
+
from typing import Any, get_type_hints
|
|
9
|
+
|
|
10
|
+
from pydantic import TypeAdapter
|
|
11
|
+
from pydantic.errors import PydanticUserError
|
|
12
|
+
|
|
13
|
+
from mockrot.models import Contract
|
|
14
|
+
|
|
15
|
+
# Two-tier cache: WeakKeyDictionary for living objects (zero memory leak),
|
|
16
|
+
# mapping underlying callable -> dict[is_bound, Contract].
|
|
17
|
+
# Fallback to static key (module, qualname, is_bound) for unhashable or non-weakrefable callables.
|
|
18
|
+
_CONTRACT_CACHE: weakref.WeakKeyDictionary[Any, dict[bool, Contract]] = weakref.WeakKeyDictionary()
|
|
19
|
+
_STATIC_CONTRACT_CACHE: dict[tuple[str, str, bool], Contract] = {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def clear_extractor_cache() -> None:
|
|
23
|
+
"""Clear all cached contracts (primarily used for test isolation)."""
|
|
24
|
+
_CONTRACT_CACHE.clear()
|
|
25
|
+
_STATIC_CONTRACT_CACHE.clear()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_target_qualname(target: Any) -> str:
|
|
29
|
+
"""Build a reliable identifier from target module and qualname."""
|
|
30
|
+
module = getattr(target, "__module__", "")
|
|
31
|
+
qualname = getattr(target, "__qualname__", getattr(target, "__name__", str(target)))
|
|
32
|
+
return f"{module}.{qualname}" if module else qualname
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _resolve_type_hints_return(real_fn: Any, globalns: dict[str, Any] | None) -> Any | None:
|
|
36
|
+
"""Attempt resolving return annotation using typing.get_type_hints."""
|
|
37
|
+
try:
|
|
38
|
+
hints = get_type_hints(real_fn, globalns=globalns)
|
|
39
|
+
ret = hints.get("return")
|
|
40
|
+
if ret is not None and ret is not type(None):
|
|
41
|
+
return ret
|
|
42
|
+
except NameError:
|
|
43
|
+
# Resolve closure or local function annotations by inspecting caller frames
|
|
44
|
+
try:
|
|
45
|
+
merged_locals: dict[str, Any] = {}
|
|
46
|
+
frame = inspect.currentframe()
|
|
47
|
+
while frame:
|
|
48
|
+
merged_locals.update(frame.f_locals)
|
|
49
|
+
frame = frame.f_back
|
|
50
|
+
hints = get_type_hints(real_fn, globalns=globalns, localns=merged_locals)
|
|
51
|
+
ret = hints.get("return")
|
|
52
|
+
if ret is not None and ret is not type(None):
|
|
53
|
+
return ret
|
|
54
|
+
except (NameError, TypeError, ValueError):
|
|
55
|
+
return None
|
|
56
|
+
except (TypeError, ValueError, AttributeError):
|
|
57
|
+
return None
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _resolve_raw_annotations_return(
|
|
62
|
+
real_fn: Any, underlying: Any, globalns: dict[str, Any] | None
|
|
63
|
+
) -> Any | None:
|
|
64
|
+
"""Fallback resolving return type from raw annotations or string evaluation."""
|
|
65
|
+
annotations: dict[str, Any] = getattr(real_fn, "__annotations__", {}) or getattr(
|
|
66
|
+
underlying, "__annotations__", {}
|
|
67
|
+
)
|
|
68
|
+
raw_ret = annotations.get("return")
|
|
69
|
+
if raw_ret is None or raw_ret is type(None) or raw_ret == "None":
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
if isinstance(raw_ret, str):
|
|
73
|
+
try:
|
|
74
|
+
resolved = eval(raw_ret, globalns)
|
|
75
|
+
if resolved is not None and resolved is not type(None):
|
|
76
|
+
return resolved
|
|
77
|
+
except (NameError, TypeError, ValueError, SyntaxError):
|
|
78
|
+
return None
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
return raw_ret
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _resolve_return_type(
|
|
85
|
+
real_fn: Any, underlying: Any
|
|
86
|
+
) -> tuple[Any | None, TypeAdapter[Any] | None]:
|
|
87
|
+
"""Resolve return type annotation and compile its Pydantic TypeAdapter."""
|
|
88
|
+
globalns: dict[str, Any] | None = getattr(underlying, "__globals__", None) or getattr(
|
|
89
|
+
sys.modules.get(getattr(underlying, "__module__", "")), "__dict__", None
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
ret = _resolve_type_hints_return(real_fn, globalns)
|
|
93
|
+
if ret is None and underlying is not real_fn:
|
|
94
|
+
ret = _resolve_type_hints_return(underlying, globalns)
|
|
95
|
+
if ret is None:
|
|
96
|
+
ret = _resolve_raw_annotations_return(real_fn, underlying, globalns)
|
|
97
|
+
|
|
98
|
+
adapter: TypeAdapter[Any] | None = None
|
|
99
|
+
if ret is not None:
|
|
100
|
+
try:
|
|
101
|
+
adapter = TypeAdapter(ret)
|
|
102
|
+
except (PydanticUserError, TypeError, ValueError):
|
|
103
|
+
adapter = None
|
|
104
|
+
|
|
105
|
+
return ret, adapter
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def extract_contract(real_fn: Any) -> Contract:
|
|
109
|
+
"""Extract runtime contract (signature, async status, annotations) from a callable.
|
|
110
|
+
|
|
111
|
+
Features two-tier caching to prevent memory leaks and protect against Python
|
|
112
|
+
id() recycling or non-hashable callables. Distinguishes bound and unbound callables.
|
|
113
|
+
"""
|
|
114
|
+
if real_fn is None:
|
|
115
|
+
return Contract(
|
|
116
|
+
target_name="unknown",
|
|
117
|
+
is_async=False,
|
|
118
|
+
has_return_annotation=False,
|
|
119
|
+
signature=None,
|
|
120
|
+
return_type=None,
|
|
121
|
+
return_adapter=None,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
underlying = getattr(real_fn, "__func__", real_fn)
|
|
125
|
+
is_bound = hasattr(real_fn, "__self__") and real_fn.__self__ is not None
|
|
126
|
+
|
|
127
|
+
# 1. Check caches
|
|
128
|
+
try:
|
|
129
|
+
if is_bound in _CONTRACT_CACHE[underlying]:
|
|
130
|
+
return _CONTRACT_CACHE[underlying][is_bound]
|
|
131
|
+
except (KeyError, TypeError):
|
|
132
|
+
key = (
|
|
133
|
+
getattr(underlying, "__module__", ""),
|
|
134
|
+
getattr(underlying, "__qualname__", getattr(underlying, "__name__", "")),
|
|
135
|
+
is_bound,
|
|
136
|
+
)
|
|
137
|
+
if key in _STATIC_CONTRACT_CACHE:
|
|
138
|
+
return _STATIC_CONTRACT_CACHE[key]
|
|
139
|
+
|
|
140
|
+
# 2. Extract signature with graceful fallback for C builtins or unsupported callables
|
|
141
|
+
try:
|
|
142
|
+
sig = inspect.signature(real_fn)
|
|
143
|
+
except (ValueError, TypeError):
|
|
144
|
+
sig = None
|
|
145
|
+
|
|
146
|
+
# 3. Determine if coroutine function
|
|
147
|
+
is_async = inspect.iscoroutinefunction(real_fn) or inspect.iscoroutinefunction(underlying)
|
|
148
|
+
|
|
149
|
+
# 4. Extract return type annotation and compile TypeAdapter
|
|
150
|
+
return_type, return_adapter = _resolve_return_type(real_fn, underlying)
|
|
151
|
+
has_return = return_type is not None or (
|
|
152
|
+
sig is not None
|
|
153
|
+
and sig.return_annotation is not inspect.Signature.empty
|
|
154
|
+
and sig.return_annotation is not None
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
target_name = _get_target_qualname(real_fn)
|
|
158
|
+
contract = Contract(
|
|
159
|
+
target_name=target_name,
|
|
160
|
+
is_async=is_async,
|
|
161
|
+
has_return_annotation=has_return,
|
|
162
|
+
signature=sig,
|
|
163
|
+
return_type=return_type,
|
|
164
|
+
return_adapter=return_adapter,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# 5. Populate cache
|
|
168
|
+
try:
|
|
169
|
+
if underlying not in _CONTRACT_CACHE:
|
|
170
|
+
_CONTRACT_CACHE[underlying] = {}
|
|
171
|
+
_CONTRACT_CACHE[underlying][is_bound] = contract
|
|
172
|
+
except TypeError:
|
|
173
|
+
key = (
|
|
174
|
+
getattr(underlying, "__module__", ""),
|
|
175
|
+
getattr(underlying, "__qualname__", getattr(underlying, "__name__", "")),
|
|
176
|
+
is_bound,
|
|
177
|
+
)
|
|
178
|
+
_STATIC_CONTRACT_CACHE[key] = contract
|
|
179
|
+
|
|
180
|
+
return contract
|
mockrot/interceptor.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""Transparent mock interception layer for unittest.mock and pytest.MonkeyPatch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
from unittest import mock
|
|
12
|
+
|
|
13
|
+
from mockrot.models import MockInteraction, MockSite
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import _pytest.monkeypatch as _mp
|
|
17
|
+
except ImportError: # pragma: no cover
|
|
18
|
+
_mp = None # type: ignore[assignment]
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
import _pytest.monkeypatch
|
|
22
|
+
|
|
23
|
+
# Type alias for listener callback receiving intercepted interactions
|
|
24
|
+
InteractionListener = Callable[[MockInteraction], None]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class _InterceptorState:
|
|
29
|
+
"""Internal state tracking original patch methods and active listener."""
|
|
30
|
+
|
|
31
|
+
orig_patch_enter: Callable[..., Any] | None = None
|
|
32
|
+
orig_monkeypatch_setattr: Callable[..., Any] | None = None
|
|
33
|
+
listener: InteractionListener | None = None
|
|
34
|
+
is_installed: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_STATE = _InterceptorState()
|
|
38
|
+
|
|
39
|
+
# Thread-local storage to prevent reentrant interception loops
|
|
40
|
+
_TLS = threading.local()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _is_active() -> bool:
|
|
44
|
+
"""Return True if currently processing an interception."""
|
|
45
|
+
return bool(getattr(_TLS, "active", False))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _resolve_mock_site() -> MockSite:
|
|
49
|
+
"""Resolve caller file, line, and test name, skipping internal frameworks."""
|
|
50
|
+
try:
|
|
51
|
+
# sys._getframe is used for fast memory-only frame traversal without blocking disk I/O
|
|
52
|
+
frame = sys._getframe(1) # pyright: ignore[reportPrivateUsage] - fast frame inspection
|
|
53
|
+
while frame is not None:
|
|
54
|
+
fn = frame.f_code.co_filename
|
|
55
|
+
if any(
|
|
56
|
+
skip in fn
|
|
57
|
+
for skip in (
|
|
58
|
+
"unittest/mock.py",
|
|
59
|
+
"contextlib.py",
|
|
60
|
+
"_pytest",
|
|
61
|
+
"/mockrot/",
|
|
62
|
+
"pluggy",
|
|
63
|
+
"pytest_asyncio",
|
|
64
|
+
)
|
|
65
|
+
):
|
|
66
|
+
frame = frame.f_back
|
|
67
|
+
continue
|
|
68
|
+
return MockSite(
|
|
69
|
+
file_path=fn,
|
|
70
|
+
line_number=frame.f_lineno,
|
|
71
|
+
test_name=frame.f_code.co_name,
|
|
72
|
+
)
|
|
73
|
+
except (AttributeError, ValueError):
|
|
74
|
+
pass
|
|
75
|
+
return MockSite(file_path="unknown", line_number=0, test_name="")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _get_target_name(target: Any, attribute: str | None = None) -> str:
|
|
79
|
+
"""Build a human-readable identifier for the target function or attribute."""
|
|
80
|
+
if attribute:
|
|
81
|
+
target_mod = getattr(target, "__module__", "")
|
|
82
|
+
target_cls = getattr(target, "__name__", "")
|
|
83
|
+
prefix = (
|
|
84
|
+
f"{target_mod}.{target_cls}" if target_mod and target_cls else target_cls or target_mod
|
|
85
|
+
)
|
|
86
|
+
return f"{prefix}.{attribute}" if prefix else attribute
|
|
87
|
+
|
|
88
|
+
module = getattr(target, "__module__", "")
|
|
89
|
+
qualname = getattr(target, "__qualname__", getattr(target, "__name__", str(target)))
|
|
90
|
+
return f"{module}.{qualname}" if module else qualname
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _wrap_mock_object(mock_obj: Any, orig_target: Any, target_name: str) -> None:
|
|
94
|
+
"""Wrap the _mock_call dispatch of a Mock or AsyncMock instance."""
|
|
95
|
+
if not hasattr(mock_obj, "_mock_call"):
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
orig_mock_call = mock_obj._mock_call
|
|
99
|
+
if getattr(orig_mock_call, "__mockrot_wrapped__", False):
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
is_async = isinstance(mock_obj, mock.AsyncMock)
|
|
103
|
+
|
|
104
|
+
if is_async:
|
|
105
|
+
|
|
106
|
+
async def async_interceptor_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
107
|
+
if _is_active() or _STATE.listener is None:
|
|
108
|
+
return await orig_mock_call(*args, **kwargs)
|
|
109
|
+
|
|
110
|
+
_TLS.active = True
|
|
111
|
+
try:
|
|
112
|
+
site = _resolve_mock_site()
|
|
113
|
+
try:
|
|
114
|
+
result = await orig_mock_call(*args, **kwargs)
|
|
115
|
+
except BaseException as exc:
|
|
116
|
+
_STATE.listener(
|
|
117
|
+
MockInteraction(
|
|
118
|
+
site=site,
|
|
119
|
+
target_func=orig_target,
|
|
120
|
+
target_name=target_name,
|
|
121
|
+
args=args,
|
|
122
|
+
kwargs=kwargs,
|
|
123
|
+
returned_value=None,
|
|
124
|
+
raised_exception=exc,
|
|
125
|
+
is_async_call=True,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
raise
|
|
129
|
+
else:
|
|
130
|
+
_STATE.listener(
|
|
131
|
+
MockInteraction(
|
|
132
|
+
site=site,
|
|
133
|
+
target_func=orig_target,
|
|
134
|
+
target_name=target_name,
|
|
135
|
+
args=args,
|
|
136
|
+
kwargs=kwargs,
|
|
137
|
+
returned_value=result,
|
|
138
|
+
raised_exception=None,
|
|
139
|
+
is_async_call=True,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
return result
|
|
143
|
+
finally:
|
|
144
|
+
_TLS.active = False
|
|
145
|
+
|
|
146
|
+
async_interceptor_wrapper.__mockrot_wrapped__ = True # type: ignore[attr-defined]
|
|
147
|
+
mock_obj._mock_call = async_interceptor_wrapper
|
|
148
|
+
|
|
149
|
+
else:
|
|
150
|
+
|
|
151
|
+
def sync_interceptor_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
152
|
+
if _is_active() or _STATE.listener is None:
|
|
153
|
+
return orig_mock_call(*args, **kwargs)
|
|
154
|
+
|
|
155
|
+
_TLS.active = True
|
|
156
|
+
try:
|
|
157
|
+
site = _resolve_mock_site()
|
|
158
|
+
try:
|
|
159
|
+
result = orig_mock_call(*args, **kwargs)
|
|
160
|
+
except BaseException as exc:
|
|
161
|
+
_STATE.listener(
|
|
162
|
+
MockInteraction(
|
|
163
|
+
site=site,
|
|
164
|
+
target_func=orig_target,
|
|
165
|
+
target_name=target_name,
|
|
166
|
+
args=args,
|
|
167
|
+
kwargs=kwargs,
|
|
168
|
+
returned_value=None,
|
|
169
|
+
raised_exception=exc,
|
|
170
|
+
is_async_call=False,
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
raise
|
|
174
|
+
else:
|
|
175
|
+
_STATE.listener(
|
|
176
|
+
MockInteraction(
|
|
177
|
+
site=site,
|
|
178
|
+
target_func=orig_target,
|
|
179
|
+
target_name=target_name,
|
|
180
|
+
args=args,
|
|
181
|
+
kwargs=kwargs,
|
|
182
|
+
returned_value=result,
|
|
183
|
+
raised_exception=None,
|
|
184
|
+
is_async_call=False,
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
return result
|
|
188
|
+
finally:
|
|
189
|
+
_TLS.active = False
|
|
190
|
+
|
|
191
|
+
sync_interceptor_wrapper.__mockrot_wrapped__ = True # type: ignore[attr-defined]
|
|
192
|
+
mock_obj._mock_call = sync_interceptor_wrapper
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _wrap_plain_callable(func: Any, orig_target: Any, target_name: str) -> Any:
|
|
196
|
+
"""Wrap a non-Mock callable (e.g. lambda or dummy function) with interception."""
|
|
197
|
+
if getattr(func, "__mockrot_wrapped__", False):
|
|
198
|
+
return func
|
|
199
|
+
|
|
200
|
+
if inspect.iscoroutinefunction(func):
|
|
201
|
+
|
|
202
|
+
async def async_callable_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
203
|
+
if _is_active() or _STATE.listener is None:
|
|
204
|
+
return await func(*args, **kwargs)
|
|
205
|
+
|
|
206
|
+
_TLS.active = True
|
|
207
|
+
try:
|
|
208
|
+
site = _resolve_mock_site()
|
|
209
|
+
try:
|
|
210
|
+
result = await func(*args, **kwargs)
|
|
211
|
+
except BaseException as exc:
|
|
212
|
+
_STATE.listener(
|
|
213
|
+
MockInteraction(
|
|
214
|
+
site=site,
|
|
215
|
+
target_func=orig_target,
|
|
216
|
+
target_name=target_name,
|
|
217
|
+
args=args,
|
|
218
|
+
kwargs=kwargs,
|
|
219
|
+
returned_value=None,
|
|
220
|
+
raised_exception=exc,
|
|
221
|
+
is_async_call=True,
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
raise
|
|
225
|
+
else:
|
|
226
|
+
_STATE.listener(
|
|
227
|
+
MockInteraction(
|
|
228
|
+
site=site,
|
|
229
|
+
target_func=orig_target,
|
|
230
|
+
target_name=target_name,
|
|
231
|
+
args=args,
|
|
232
|
+
kwargs=kwargs,
|
|
233
|
+
returned_value=result,
|
|
234
|
+
raised_exception=None,
|
|
235
|
+
is_async_call=True,
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
return result
|
|
239
|
+
finally:
|
|
240
|
+
_TLS.active = False
|
|
241
|
+
|
|
242
|
+
async_callable_wrapper.__mockrot_wrapped__ = True # type: ignore[attr-defined]
|
|
243
|
+
return async_callable_wrapper
|
|
244
|
+
|
|
245
|
+
def callable_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
246
|
+
if _is_active() or _STATE.listener is None:
|
|
247
|
+
return func(*args, **kwargs)
|
|
248
|
+
|
|
249
|
+
_TLS.active = True
|
|
250
|
+
try:
|
|
251
|
+
site = _resolve_mock_site()
|
|
252
|
+
try:
|
|
253
|
+
result = func(*args, **kwargs)
|
|
254
|
+
except BaseException as exc:
|
|
255
|
+
_STATE.listener(
|
|
256
|
+
MockInteraction(
|
|
257
|
+
site=site,
|
|
258
|
+
target_func=orig_target,
|
|
259
|
+
target_name=target_name,
|
|
260
|
+
args=args,
|
|
261
|
+
kwargs=kwargs,
|
|
262
|
+
returned_value=None,
|
|
263
|
+
raised_exception=exc,
|
|
264
|
+
is_async_call=False,
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
raise
|
|
268
|
+
else:
|
|
269
|
+
_STATE.listener(
|
|
270
|
+
MockInteraction(
|
|
271
|
+
site=site,
|
|
272
|
+
target_func=orig_target,
|
|
273
|
+
target_name=target_name,
|
|
274
|
+
args=args,
|
|
275
|
+
kwargs=kwargs,
|
|
276
|
+
returned_value=result,
|
|
277
|
+
raised_exception=None,
|
|
278
|
+
is_async_call=False,
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
return result
|
|
282
|
+
finally:
|
|
283
|
+
_TLS.active = False
|
|
284
|
+
|
|
285
|
+
callable_wrapper.__mockrot_wrapped__ = True # type: ignore[attr-defined]
|
|
286
|
+
return callable_wrapper
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _patched_enter(patch_self: Any) -> Any:
|
|
290
|
+
"""Intercept unittest.mock._patch.__enter__ to wrap created mocks transparently."""
|
|
291
|
+
if _STATE.orig_patch_enter is None:
|
|
292
|
+
return patch_self # pragma: no cover
|
|
293
|
+
|
|
294
|
+
mock_obj = _STATE.orig_patch_enter(patch_self)
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
orig_target = getattr(patch_self, "temp_original", None)
|
|
298
|
+
attr_name = getattr(patch_self, "attribute", "")
|
|
299
|
+
target_obj = getattr(patch_self, "target", None)
|
|
300
|
+
target_name = _get_target_name(target_obj, attr_name)
|
|
301
|
+
|
|
302
|
+
if callable(orig_target) and hasattr(mock_obj, "_mock_call"):
|
|
303
|
+
_wrap_mock_object(mock_obj, orig_target, target_name)
|
|
304
|
+
except Exception: # noqa: BLE001
|
|
305
|
+
# Invariant 1: fail-safe, never crash user tests on internal interception failure
|
|
306
|
+
pass
|
|
307
|
+
|
|
308
|
+
return mock_obj
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _is_runner_internal_caller() -> bool:
|
|
312
|
+
"""Return True if caller of monkeypatch is an internal test runner or plugin."""
|
|
313
|
+
try:
|
|
314
|
+
caller = sys._getframe(2) # pyright: ignore[reportPrivateUsage] - caller inspection
|
|
315
|
+
caller_fn = caller.f_code.co_filename
|
|
316
|
+
return any(skip in caller_fn for skip in ("pytest_asyncio", "_pytest", "pluggy"))
|
|
317
|
+
except (AttributeError, ValueError):
|
|
318
|
+
return False
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _wrap_dotted_monkeypatch(target: str, value: Any) -> Any:
|
|
322
|
+
"""Wrap replacement mock or callable when monkeypatch uses dotted target string."""
|
|
323
|
+
if "." not in target:
|
|
324
|
+
return value
|
|
325
|
+
mod_name, attr = target.rsplit(".", 1)
|
|
326
|
+
try:
|
|
327
|
+
mod = sys.modules.get(mod_name) or __import__(mod_name, fromlist=[attr])
|
|
328
|
+
orig_target = getattr(mod, attr, None)
|
|
329
|
+
if hasattr(value, "_mock_call"):
|
|
330
|
+
_wrap_mock_object(value, orig_target, target)
|
|
331
|
+
elif callable(value) and callable(orig_target):
|
|
332
|
+
return _wrap_plain_callable(value, orig_target, target)
|
|
333
|
+
except Exception: # noqa: BLE001
|
|
334
|
+
pass
|
|
335
|
+
return value
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _patched_monkeypatch_setattr(
|
|
339
|
+
self: _pytest.monkeypatch.MonkeyPatch,
|
|
340
|
+
target: str | object,
|
|
341
|
+
name: object | str = mock.DEFAULT,
|
|
342
|
+
value: object = mock.DEFAULT,
|
|
343
|
+
raising: bool = True,
|
|
344
|
+
) -> None:
|
|
345
|
+
"""Intercept pytest.MonkeyPatch.setattr to wrap replacement callables or mocks."""
|
|
346
|
+
if _STATE.orig_monkeypatch_setattr is None:
|
|
347
|
+
return # pragma: no cover
|
|
348
|
+
|
|
349
|
+
if _is_active():
|
|
350
|
+
_STATE.orig_monkeypatch_setattr(self, target, name, value, raising=raising)
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
_TLS.active = True
|
|
354
|
+
try:
|
|
355
|
+
if _is_runner_internal_caller():
|
|
356
|
+
if value is mock.DEFAULT:
|
|
357
|
+
_STATE.orig_monkeypatch_setattr(self, target, name, raising=raising)
|
|
358
|
+
else:
|
|
359
|
+
_STATE.orig_monkeypatch_setattr(self, target, name, value, raising=raising)
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
if value is mock.DEFAULT:
|
|
363
|
+
real_name = _wrap_dotted_monkeypatch(target, name) if isinstance(target, str) else name
|
|
364
|
+
_STATE.orig_monkeypatch_setattr(self, target, real_name, raising=raising)
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
if isinstance(name, str):
|
|
368
|
+
orig_target = getattr(target, name, None)
|
|
369
|
+
target_name = _get_target_name(target, name)
|
|
370
|
+
if hasattr(value, "_mock_call"):
|
|
371
|
+
_wrap_mock_object(value, orig_target, target_name)
|
|
372
|
+
elif callable(value) and callable(orig_target):
|
|
373
|
+
value = _wrap_plain_callable(value, orig_target, target_name)
|
|
374
|
+
|
|
375
|
+
_STATE.orig_monkeypatch_setattr(self, target, name, value, raising=raising)
|
|
376
|
+
finally:
|
|
377
|
+
_TLS.active = False
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def is_interceptor_installed() -> bool:
|
|
381
|
+
"""Return True if mockrot interceptor is actively installed."""
|
|
382
|
+
return _STATE.is_installed
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def install_interceptor(listener: InteractionListener) -> None:
|
|
386
|
+
"""Install interception hooks on unittest.mock._patch and MonkeyPatch.setattr."""
|
|
387
|
+
_STATE.listener = listener
|
|
388
|
+
if _STATE.is_installed:
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
patch_cls: Any = mock._patch # pyright: ignore[reportPrivateUsage] # Wrap unittest.mock._patch.__enter__
|
|
392
|
+
_STATE.orig_patch_enter = patch_cls.__enter__
|
|
393
|
+
patch_cls.__enter__ = _patched_enter
|
|
394
|
+
|
|
395
|
+
if _mp is not None and hasattr(_mp, "MonkeyPatch"):
|
|
396
|
+
_STATE.orig_monkeypatch_setattr = _mp.MonkeyPatch.setattr
|
|
397
|
+
_mp.MonkeyPatch.setattr = _patched_monkeypatch_setattr # type: ignore[assignment]
|
|
398
|
+
|
|
399
|
+
_STATE.is_installed = True
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def uninstall_interceptor() -> None:
|
|
403
|
+
"""Restore original patch methods idempotently."""
|
|
404
|
+
if not _STATE.is_installed:
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
if _STATE.orig_patch_enter is not None:
|
|
408
|
+
patch_cls: Any = mock._patch # pyright: ignore[reportPrivateUsage] # Restore original _patch.__enter__
|
|
409
|
+
patch_cls.__enter__ = _STATE.orig_patch_enter
|
|
410
|
+
_STATE.orig_patch_enter = None
|
|
411
|
+
|
|
412
|
+
if _STATE.orig_monkeypatch_setattr is not None:
|
|
413
|
+
if _mp is not None and hasattr(_mp, "MonkeyPatch"):
|
|
414
|
+
_mp.MonkeyPatch.setattr = _STATE.orig_monkeypatch_setattr # type: ignore[assignment]
|
|
415
|
+
_STATE.orig_monkeypatch_setattr = None
|
|
416
|
+
|
|
417
|
+
_STATE.listener = None
|
|
418
|
+
_STATE.is_installed = False
|