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 ADDED
@@ -0,0 +1,7 @@
1
+ """Mockrot: Runtime contract verification to detect rotting mocks in pytest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["__version__"]
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