rustest 0.14.0__cp313-cp313-macosx_11_0_arm64.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.
- rustest/__init__.py +39 -0
- rustest/__main__.py +10 -0
- rustest/approx.py +176 -0
- rustest/builtin_fixtures.py +1137 -0
- rustest/cli.py +135 -0
- rustest/compat/__init__.py +3 -0
- rustest/compat/pytest.py +1141 -0
- rustest/core.py +56 -0
- rustest/decorators.py +968 -0
- rustest/fixture_registry.py +130 -0
- rustest/py.typed +0 -0
- rustest/reporting.py +63 -0
- rustest/rust.cpython-313-darwin.so +0 -0
- rustest/rust.py +23 -0
- rustest/rust.pyi +43 -0
- rustest-0.14.0.dist-info/METADATA +151 -0
- rustest-0.14.0.dist-info/RECORD +20 -0
- rustest-0.14.0.dist-info/WHEEL +4 -0
- rustest-0.14.0.dist-info/entry_points.txt +2 -0
- rustest-0.14.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1137 @@
|
|
|
1
|
+
"""Builtin fixtures that mirror a subset of pytest's default fixtures."""
|
|
2
|
+
|
|
3
|
+
# pyright: reportMissingImports=false
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import importlib
|
|
8
|
+
import itertools
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections.abc import Generator, MutableMapping
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from types import ModuleType
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Iterator, NamedTuple, cast
|
|
18
|
+
|
|
19
|
+
from .decorators import fixture
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CaptureResult(NamedTuple):
|
|
23
|
+
"""Result of capturing stdout and stderr."""
|
|
24
|
+
|
|
25
|
+
out: str
|
|
26
|
+
err: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
py: ModuleType | None
|
|
30
|
+
try: # pragma: no cover - optional dependency at runtime
|
|
31
|
+
import py as _py_module
|
|
32
|
+
except Exception: # pragma: no cover - import error reported at fixture usage time
|
|
33
|
+
py = None
|
|
34
|
+
else:
|
|
35
|
+
py = _py_module
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
try: # pragma: no cover - typing-only import
|
|
39
|
+
from py import path as _py_path
|
|
40
|
+
except ImportError:
|
|
41
|
+
PyPathLocal = Any
|
|
42
|
+
else:
|
|
43
|
+
PyPathLocal = _py_path.local
|
|
44
|
+
|
|
45
|
+
else: # pragma: no cover - imported only for typing
|
|
46
|
+
PyPathLocal = Any
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _NotSet:
|
|
50
|
+
"""Sentinel value for tracking missing attributes/items."""
|
|
51
|
+
|
|
52
|
+
def __repr__(self) -> str: # pragma: no cover - debug helper
|
|
53
|
+
return "<NOTSET>"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_NOT_SET = _NotSet()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class MonkeyPatch:
|
|
60
|
+
"""Lightweight re-implementation of :class:`pytest.MonkeyPatch`."""
|
|
61
|
+
|
|
62
|
+
def __init__(self) -> None:
|
|
63
|
+
super().__init__()
|
|
64
|
+
self._setattrs: list[tuple[object, str, object | _NotSet]] = []
|
|
65
|
+
self._setitems: list[tuple[MutableMapping[Any, Any], Any, object | _NotSet]] = []
|
|
66
|
+
self._environ: list[tuple[str, str | _NotSet]] = []
|
|
67
|
+
self._syspath_prepend: list[str] = []
|
|
68
|
+
self._cwd_original: str | None = None
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
@contextmanager
|
|
72
|
+
def context(cls) -> Generator[MonkeyPatch, None, None]:
|
|
73
|
+
patch = cls()
|
|
74
|
+
try:
|
|
75
|
+
yield patch
|
|
76
|
+
finally:
|
|
77
|
+
patch.undo()
|
|
78
|
+
|
|
79
|
+
def setattr(
|
|
80
|
+
self,
|
|
81
|
+
target: object | str,
|
|
82
|
+
name: object | str = _NOT_SET,
|
|
83
|
+
value: object = _NOT_SET,
|
|
84
|
+
*,
|
|
85
|
+
raising: bool = True,
|
|
86
|
+
) -> None:
|
|
87
|
+
if value is _NOT_SET:
|
|
88
|
+
if not isinstance(target, str):
|
|
89
|
+
raise TypeError("use setattr(target, name, value) or setattr('module.attr', value)")
|
|
90
|
+
module_path, attr_name = target.rsplit(".", 1)
|
|
91
|
+
module = importlib.import_module(module_path)
|
|
92
|
+
obj = module
|
|
93
|
+
attr_value = name
|
|
94
|
+
if attr_value is _NOT_SET:
|
|
95
|
+
raise TypeError("value must be provided when using dotted path syntax")
|
|
96
|
+
attr_name = attr_name
|
|
97
|
+
else:
|
|
98
|
+
if not isinstance(name, str):
|
|
99
|
+
raise TypeError("attribute name must be a string")
|
|
100
|
+
obj = target
|
|
101
|
+
attr_name = name
|
|
102
|
+
attr_value = value
|
|
103
|
+
|
|
104
|
+
original = getattr(obj, attr_name, _NOT_SET)
|
|
105
|
+
if original is _NOT_SET and raising:
|
|
106
|
+
raise AttributeError(f"{attr_name!r} not found for patching")
|
|
107
|
+
|
|
108
|
+
setattr(obj, attr_name, attr_value)
|
|
109
|
+
self._setattrs.append((obj, attr_name, original))
|
|
110
|
+
|
|
111
|
+
def delattr(
|
|
112
|
+
self, target: object | str, name: str | _NotSet = _NOT_SET, *, raising: bool = True
|
|
113
|
+
) -> None:
|
|
114
|
+
if isinstance(target, str) and name is _NOT_SET:
|
|
115
|
+
module_path, attr_name = target.rsplit(".", 1)
|
|
116
|
+
module = importlib.import_module(module_path)
|
|
117
|
+
obj = module
|
|
118
|
+
attr_name = attr_name
|
|
119
|
+
else:
|
|
120
|
+
if not isinstance(name, str):
|
|
121
|
+
raise TypeError("attribute name must be a string")
|
|
122
|
+
obj = target
|
|
123
|
+
attr_name = name
|
|
124
|
+
|
|
125
|
+
original = getattr(obj, attr_name, _NOT_SET)
|
|
126
|
+
if original is _NOT_SET:
|
|
127
|
+
if raising:
|
|
128
|
+
raise AttributeError(f"{attr_name!r} not found for deletion")
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
delattr(obj, attr_name)
|
|
132
|
+
self._setattrs.append((obj, attr_name, original))
|
|
133
|
+
|
|
134
|
+
def setitem(self, mapping: MutableMapping[Any, Any], key: Any, value: Any) -> None:
|
|
135
|
+
original = mapping.get(key, _NOT_SET)
|
|
136
|
+
mapping[key] = value
|
|
137
|
+
self._setitems.append((mapping, key, original))
|
|
138
|
+
|
|
139
|
+
def delitem(self, mapping: MutableMapping[Any, Any], key: Any, *, raising: bool = True) -> None:
|
|
140
|
+
if key not in mapping:
|
|
141
|
+
if raising:
|
|
142
|
+
raise KeyError(key)
|
|
143
|
+
self._setitems.append((mapping, key, _NOT_SET))
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
original = mapping[key]
|
|
147
|
+
del mapping[key]
|
|
148
|
+
self._setitems.append((mapping, key, original))
|
|
149
|
+
|
|
150
|
+
def setenv(self, name: str, value: Any, prepend: str | None = None) -> None:
|
|
151
|
+
str_value = str(value)
|
|
152
|
+
if prepend and name in os.environ:
|
|
153
|
+
str_value = f"{str_value}{prepend}{os.environ[name]}"
|
|
154
|
+
original = os.environ.get(name)
|
|
155
|
+
os.environ[name] = str_value
|
|
156
|
+
stored_original: str | _NotSet = original if original is not None else _NOT_SET
|
|
157
|
+
self._environ.append((name, stored_original))
|
|
158
|
+
|
|
159
|
+
def delenv(self, name: str, *, raising: bool = True) -> None:
|
|
160
|
+
if name not in os.environ:
|
|
161
|
+
if raising:
|
|
162
|
+
raise KeyError(name)
|
|
163
|
+
self._environ.append((name, _NOT_SET))
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
original = os.environ.pop(name)
|
|
167
|
+
self._environ.append((name, original))
|
|
168
|
+
|
|
169
|
+
def syspath_prepend(self, path: os.PathLike[str] | str) -> None:
|
|
170
|
+
str_path = os.fspath(path)
|
|
171
|
+
if str_path in sys.path:
|
|
172
|
+
return
|
|
173
|
+
sys.path.insert(0, str_path)
|
|
174
|
+
self._syspath_prepend.append(str_path)
|
|
175
|
+
|
|
176
|
+
def chdir(self, path: os.PathLike[str] | str) -> None:
|
|
177
|
+
if self._cwd_original is None:
|
|
178
|
+
self._cwd_original = os.getcwd()
|
|
179
|
+
os.chdir(os.fspath(path))
|
|
180
|
+
|
|
181
|
+
def undo(self) -> None:
|
|
182
|
+
for obj, attr_name, original in reversed(self._setattrs):
|
|
183
|
+
if original is _NOT_SET:
|
|
184
|
+
try:
|
|
185
|
+
delattr(obj, attr_name)
|
|
186
|
+
except AttributeError: # pragma: no cover - defensive
|
|
187
|
+
pass
|
|
188
|
+
else:
|
|
189
|
+
setattr(obj, attr_name, original)
|
|
190
|
+
self._setattrs.clear()
|
|
191
|
+
|
|
192
|
+
for mapping, key, original in reversed(self._setitems):
|
|
193
|
+
if original is _NOT_SET:
|
|
194
|
+
mapping.pop(key, None)
|
|
195
|
+
else:
|
|
196
|
+
mapping[key] = original
|
|
197
|
+
self._setitems.clear()
|
|
198
|
+
|
|
199
|
+
for name, original in reversed(self._environ):
|
|
200
|
+
if original is _NOT_SET:
|
|
201
|
+
os.environ.pop(name, None)
|
|
202
|
+
else:
|
|
203
|
+
os.environ[name] = cast(str, original)
|
|
204
|
+
self._environ.clear()
|
|
205
|
+
|
|
206
|
+
while self._syspath_prepend:
|
|
207
|
+
str_path = self._syspath_prepend.pop()
|
|
208
|
+
try:
|
|
209
|
+
sys.path.remove(str_path)
|
|
210
|
+
except ValueError: # pragma: no cover - path already removed externally
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
if self._cwd_original is not None:
|
|
214
|
+
os.chdir(self._cwd_original)
|
|
215
|
+
self._cwd_original = None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class TmpPathFactory:
|
|
219
|
+
"""Create temporary directories using :class:`pathlib.Path`."""
|
|
220
|
+
|
|
221
|
+
def __init__(self, prefix: str = "tmp_path") -> None:
|
|
222
|
+
super().__init__()
|
|
223
|
+
self._base = Path(tempfile.mkdtemp(prefix=f"rustest-{prefix}-"))
|
|
224
|
+
self._counter = itertools.count()
|
|
225
|
+
self._created: list[Path] = []
|
|
226
|
+
|
|
227
|
+
def mktemp(self, basename: str, *, numbered: bool = True) -> Path:
|
|
228
|
+
if not basename:
|
|
229
|
+
raise ValueError("basename must be a non-empty string")
|
|
230
|
+
if numbered:
|
|
231
|
+
suffix = next(self._counter)
|
|
232
|
+
name = f"{basename}{suffix}"
|
|
233
|
+
else:
|
|
234
|
+
name = basename
|
|
235
|
+
path = self._base / name
|
|
236
|
+
path.mkdir(parents=True, exist_ok=False)
|
|
237
|
+
self._created.append(path)
|
|
238
|
+
return path
|
|
239
|
+
|
|
240
|
+
def getbasetemp(self) -> Path:
|
|
241
|
+
return self._base
|
|
242
|
+
|
|
243
|
+
def cleanup(self) -> None:
|
|
244
|
+
for path in reversed(self._created):
|
|
245
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
246
|
+
shutil.rmtree(self._base, ignore_errors=True)
|
|
247
|
+
self._created.clear()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class TmpDirFactory:
|
|
251
|
+
"""Wrapper that exposes ``py.path.local`` directories."""
|
|
252
|
+
|
|
253
|
+
def __init__(self, path_factory: TmpPathFactory) -> None:
|
|
254
|
+
super().__init__()
|
|
255
|
+
self._factory = path_factory
|
|
256
|
+
|
|
257
|
+
def mktemp(self, basename: str, *, numbered: bool = True) -> Any:
|
|
258
|
+
if py is None: # pragma: no cover - exercised only when dependency missing
|
|
259
|
+
raise RuntimeError("py library is required for tmpdir fixtures")
|
|
260
|
+
path = self._factory.mktemp(basename, numbered=numbered)
|
|
261
|
+
return py.path.local(path)
|
|
262
|
+
|
|
263
|
+
def getbasetemp(self) -> Any:
|
|
264
|
+
if py is None: # pragma: no cover - exercised only when dependency missing
|
|
265
|
+
raise RuntimeError("py library is required for tmpdir fixtures")
|
|
266
|
+
return py.path.local(self._factory.getbasetemp())
|
|
267
|
+
|
|
268
|
+
def cleanup(self) -> None:
|
|
269
|
+
self._factory.cleanup()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@fixture(scope="session")
|
|
273
|
+
def tmp_path_factory() -> Iterator[TmpPathFactory]:
|
|
274
|
+
factory = TmpPathFactory("tmp_path")
|
|
275
|
+
try:
|
|
276
|
+
yield factory
|
|
277
|
+
finally:
|
|
278
|
+
factory.cleanup()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@fixture(scope="function")
|
|
282
|
+
def tmp_path(tmp_path_factory: TmpPathFactory) -> Iterator[Path]:
|
|
283
|
+
path = tmp_path_factory.mktemp("tmp_path")
|
|
284
|
+
yield path
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@fixture(scope="session")
|
|
288
|
+
def tmpdir_factory() -> Iterator[TmpDirFactory]:
|
|
289
|
+
factory = TmpDirFactory(TmpPathFactory("tmpdir"))
|
|
290
|
+
try:
|
|
291
|
+
yield factory
|
|
292
|
+
finally:
|
|
293
|
+
factory.cleanup()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@fixture(scope="function")
|
|
297
|
+
def tmpdir(tmpdir_factory: TmpDirFactory) -> Iterator[Any]:
|
|
298
|
+
yield tmpdir_factory.mktemp("tmpdir")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@fixture(scope="function")
|
|
302
|
+
def monkeypatch() -> Iterator[MonkeyPatch]:
|
|
303
|
+
patch = MonkeyPatch()
|
|
304
|
+
try:
|
|
305
|
+
yield patch
|
|
306
|
+
finally:
|
|
307
|
+
patch.undo()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@fixture(scope="function")
|
|
311
|
+
def request() -> Any:
|
|
312
|
+
"""Pytest-compatible request fixture for fixture parametrization.
|
|
313
|
+
|
|
314
|
+
This fixture provides access to the current fixture parameter value via
|
|
315
|
+
request.param when using parametrized fixtures.
|
|
316
|
+
|
|
317
|
+
**Supported:**
|
|
318
|
+
- request.param: Current parameter value for parametrized fixtures
|
|
319
|
+
- request.scope: Returns "function"
|
|
320
|
+
- Type annotations: request: pytest.FixtureRequest
|
|
321
|
+
|
|
322
|
+
**Not supported (returns None or raises NotImplementedError):**
|
|
323
|
+
- request.node, function, cls, module, config
|
|
324
|
+
- request.fixturename
|
|
325
|
+
- Methods: addfinalizer(), getfixturevalue()
|
|
326
|
+
|
|
327
|
+
Example:
|
|
328
|
+
@fixture(params=[1, 2, 3])
|
|
329
|
+
def number(request):
|
|
330
|
+
return request.param
|
|
331
|
+
|
|
332
|
+
@fixture(params=["mysql", "postgres"], ids=["MySQL", "PostgreSQL"])
|
|
333
|
+
def database(request):
|
|
334
|
+
return create_db(request.param)
|
|
335
|
+
"""
|
|
336
|
+
# NOTE: This fixture is not directly called in normal usage.
|
|
337
|
+
# Instead, the Rust execution engine creates FixtureRequest objects
|
|
338
|
+
# with the appropriate param value and injects them directly.
|
|
339
|
+
# This fixture definition exists for fallback and API compatibility.
|
|
340
|
+
from rustest.compat.pytest import FixtureRequest
|
|
341
|
+
|
|
342
|
+
return FixtureRequest()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class CaptureFixture:
|
|
346
|
+
"""Fixture to capture stdout and stderr.
|
|
347
|
+
|
|
348
|
+
This implements pytest's capsys fixture functionality.
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
def __init__(self) -> None:
|
|
352
|
+
import io
|
|
353
|
+
|
|
354
|
+
super().__init__()
|
|
355
|
+
self._capture_out: list[str] = []
|
|
356
|
+
self._capture_err: list[str] = []
|
|
357
|
+
self._original_stdout = sys.stdout
|
|
358
|
+
self._original_stderr = sys.stderr
|
|
359
|
+
self._capturing = False
|
|
360
|
+
self._stdout_buffer: io.StringIO = io.StringIO()
|
|
361
|
+
self._stderr_buffer: io.StringIO = io.StringIO()
|
|
362
|
+
|
|
363
|
+
def start_capture(self) -> None:
|
|
364
|
+
"""Start capturing stdout and stderr."""
|
|
365
|
+
import io
|
|
366
|
+
|
|
367
|
+
self._stdout_buffer = io.StringIO()
|
|
368
|
+
self._stderr_buffer = io.StringIO()
|
|
369
|
+
sys.stdout = self._stdout_buffer
|
|
370
|
+
sys.stderr = self._stderr_buffer
|
|
371
|
+
self._capturing = True
|
|
372
|
+
|
|
373
|
+
def stop_capture(self) -> None:
|
|
374
|
+
"""Stop capturing and restore original streams."""
|
|
375
|
+
if self._capturing:
|
|
376
|
+
sys.stdout = self._original_stdout
|
|
377
|
+
sys.stderr = self._original_stderr
|
|
378
|
+
self._capturing = False
|
|
379
|
+
|
|
380
|
+
def readouterr(self) -> CaptureResult:
|
|
381
|
+
"""Read and reset the captured output.
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
A CaptureResult with out and err attributes containing the captured output.
|
|
385
|
+
"""
|
|
386
|
+
if not self._capturing:
|
|
387
|
+
return CaptureResult("", "")
|
|
388
|
+
|
|
389
|
+
out = self._stdout_buffer.getvalue()
|
|
390
|
+
err = self._stderr_buffer.getvalue()
|
|
391
|
+
|
|
392
|
+
# Reset the buffers
|
|
393
|
+
import io
|
|
394
|
+
|
|
395
|
+
self._stdout_buffer = io.StringIO()
|
|
396
|
+
self._stderr_buffer = io.StringIO()
|
|
397
|
+
sys.stdout = self._stdout_buffer
|
|
398
|
+
sys.stderr = self._stderr_buffer
|
|
399
|
+
|
|
400
|
+
return CaptureResult(out, err)
|
|
401
|
+
|
|
402
|
+
def __enter__(self) -> "CaptureFixture":
|
|
403
|
+
self.start_capture()
|
|
404
|
+
return self
|
|
405
|
+
|
|
406
|
+
def __exit__(self, *args: Any) -> None:
|
|
407
|
+
self.stop_capture()
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@fixture
|
|
411
|
+
def capsys() -> Generator[CaptureFixture, None, None]:
|
|
412
|
+
"""
|
|
413
|
+
Enable text capturing of stdout and stderr.
|
|
414
|
+
|
|
415
|
+
The captured output is made available via capsys.readouterr() which
|
|
416
|
+
returns a (out, err) tuple. out and err are strings containing the
|
|
417
|
+
captured output.
|
|
418
|
+
|
|
419
|
+
Example:
|
|
420
|
+
def test_output(capsys):
|
|
421
|
+
print("hello")
|
|
422
|
+
captured = capsys.readouterr()
|
|
423
|
+
assert captured.out == "hello\\n"
|
|
424
|
+
"""
|
|
425
|
+
capture = CaptureFixture()
|
|
426
|
+
capture.start_capture()
|
|
427
|
+
try:
|
|
428
|
+
yield capture
|
|
429
|
+
finally:
|
|
430
|
+
capture.stop_capture()
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@fixture
|
|
434
|
+
def capfd() -> Generator[CaptureFixture, None, None]:
|
|
435
|
+
"""
|
|
436
|
+
Enable text capturing of stdout and stderr at file descriptor level.
|
|
437
|
+
|
|
438
|
+
Note: This is currently an alias for capsys in rustest.
|
|
439
|
+
The captured output is made available via capfd.readouterr().
|
|
440
|
+
"""
|
|
441
|
+
# For simplicity, capfd is implemented the same as capsys
|
|
442
|
+
# A true file descriptor capture would require more complex handling
|
|
443
|
+
capture = CaptureFixture()
|
|
444
|
+
capture.start_capture()
|
|
445
|
+
try:
|
|
446
|
+
yield capture
|
|
447
|
+
finally:
|
|
448
|
+
capture.stop_capture()
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
class LogRecord(NamedTuple):
|
|
452
|
+
"""A captured log record."""
|
|
453
|
+
|
|
454
|
+
name: str
|
|
455
|
+
levelno: int
|
|
456
|
+
levelname: str
|
|
457
|
+
message: str
|
|
458
|
+
pathname: str
|
|
459
|
+
lineno: int
|
|
460
|
+
exc_info: Any
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
class LogCaptureFixture:
|
|
464
|
+
"""Fixture to capture logging output.
|
|
465
|
+
|
|
466
|
+
This implements pytest's caplog fixture functionality for capturing
|
|
467
|
+
and inspecting log messages during test execution.
|
|
468
|
+
"""
|
|
469
|
+
|
|
470
|
+
def __init__(self) -> None:
|
|
471
|
+
import logging
|
|
472
|
+
|
|
473
|
+
super().__init__()
|
|
474
|
+
self._records: list[logging.LogRecord] = []
|
|
475
|
+
self._handler: logging.Handler | None = None
|
|
476
|
+
self._old_level: int | None = None
|
|
477
|
+
self._logger = logging.getLogger()
|
|
478
|
+
|
|
479
|
+
def start_capture(self) -> None:
|
|
480
|
+
"""Start capturing log messages."""
|
|
481
|
+
import logging
|
|
482
|
+
|
|
483
|
+
class ListHandler(logging.Handler):
|
|
484
|
+
"""Handler that collects log records in a list."""
|
|
485
|
+
|
|
486
|
+
def __init__(self, records: list[logging.LogRecord]) -> None:
|
|
487
|
+
super().__init__()
|
|
488
|
+
self.records = records
|
|
489
|
+
|
|
490
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
491
|
+
self.records.append(record)
|
|
492
|
+
|
|
493
|
+
self._handler = ListHandler(self._records)
|
|
494
|
+
self._handler.setLevel(logging.DEBUG)
|
|
495
|
+
self._logger.addHandler(self._handler)
|
|
496
|
+
self._old_level = self._logger.level
|
|
497
|
+
# Set to DEBUG to capture all messages
|
|
498
|
+
self._logger.setLevel(logging.DEBUG)
|
|
499
|
+
|
|
500
|
+
def stop_capture(self) -> None:
|
|
501
|
+
"""Stop capturing log messages."""
|
|
502
|
+
if self._handler is not None:
|
|
503
|
+
self._logger.removeHandler(self._handler)
|
|
504
|
+
if self._old_level is not None:
|
|
505
|
+
self._logger.setLevel(self._old_level)
|
|
506
|
+
self._handler = None
|
|
507
|
+
|
|
508
|
+
@property
|
|
509
|
+
def records(self) -> list[Any]:
|
|
510
|
+
"""Access to the captured log records.
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
A list of logging.LogRecord objects.
|
|
514
|
+
"""
|
|
515
|
+
return self._records
|
|
516
|
+
|
|
517
|
+
@property
|
|
518
|
+
def record_tuples(self) -> list[tuple[str, int, str]]:
|
|
519
|
+
"""Get captured log records as tuples of (name, level, message).
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
A list of tuples with (logger_name, level, message).
|
|
523
|
+
"""
|
|
524
|
+
return [(r.name, r.levelno, r.getMessage()) for r in self._records]
|
|
525
|
+
|
|
526
|
+
@property
|
|
527
|
+
def messages(self) -> list[str]:
|
|
528
|
+
"""Get captured log messages as strings.
|
|
529
|
+
|
|
530
|
+
Returns:
|
|
531
|
+
A list of log message strings.
|
|
532
|
+
"""
|
|
533
|
+
return [r.getMessage() for r in self._records]
|
|
534
|
+
|
|
535
|
+
@property
|
|
536
|
+
def text(self) -> str:
|
|
537
|
+
"""Get all captured log messages as a single text string.
|
|
538
|
+
|
|
539
|
+
Returns:
|
|
540
|
+
All log messages joined with newlines.
|
|
541
|
+
"""
|
|
542
|
+
return "\n".join(self.messages)
|
|
543
|
+
|
|
544
|
+
def clear(self) -> None:
|
|
545
|
+
"""Clear all captured log records."""
|
|
546
|
+
self._records.clear()
|
|
547
|
+
|
|
548
|
+
def set_level(self, level: int | str, logger: str | None = None) -> None:
|
|
549
|
+
"""Set the minimum log level to capture.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
level: The log level (e.g., logging.INFO, "INFO", 20)
|
|
553
|
+
logger: Optional logger name to set level for (default: root logger)
|
|
554
|
+
"""
|
|
555
|
+
import logging
|
|
556
|
+
|
|
557
|
+
if isinstance(level, str):
|
|
558
|
+
level = getattr(logging, level.upper())
|
|
559
|
+
|
|
560
|
+
if logger is None:
|
|
561
|
+
target_logger = self._logger
|
|
562
|
+
else:
|
|
563
|
+
target_logger = logging.getLogger(logger)
|
|
564
|
+
|
|
565
|
+
target_logger.setLevel(level)
|
|
566
|
+
|
|
567
|
+
@contextmanager
|
|
568
|
+
def at_level(
|
|
569
|
+
self, level: int | str, logger: str | None = None
|
|
570
|
+
) -> Generator["LogCaptureFixture", None, None]:
|
|
571
|
+
"""Context manager to temporarily set the log level.
|
|
572
|
+
|
|
573
|
+
Args:
|
|
574
|
+
level: The log level to set
|
|
575
|
+
logger: Optional logger name (default: root logger)
|
|
576
|
+
|
|
577
|
+
Usage:
|
|
578
|
+
with caplog.at_level(logging.INFO):
|
|
579
|
+
# Only INFO and above will be captured here
|
|
580
|
+
logging.debug("not captured")
|
|
581
|
+
logging.info("captured")
|
|
582
|
+
"""
|
|
583
|
+
import logging
|
|
584
|
+
|
|
585
|
+
if isinstance(level, str):
|
|
586
|
+
level = getattr(logging, level.upper())
|
|
587
|
+
|
|
588
|
+
if logger is None:
|
|
589
|
+
target_logger = self._logger
|
|
590
|
+
else:
|
|
591
|
+
target_logger = logging.getLogger(logger)
|
|
592
|
+
|
|
593
|
+
old_level = target_logger.level
|
|
594
|
+
target_logger.setLevel(level)
|
|
595
|
+
try:
|
|
596
|
+
yield self
|
|
597
|
+
finally:
|
|
598
|
+
target_logger.setLevel(old_level)
|
|
599
|
+
|
|
600
|
+
def __enter__(self) -> "LogCaptureFixture":
|
|
601
|
+
self.start_capture()
|
|
602
|
+
return self
|
|
603
|
+
|
|
604
|
+
def __exit__(self, *args: Any) -> None:
|
|
605
|
+
self.stop_capture()
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
@fixture
|
|
609
|
+
def caplog() -> Generator[LogCaptureFixture, None, None]:
|
|
610
|
+
"""
|
|
611
|
+
Enable capturing of logging output.
|
|
612
|
+
|
|
613
|
+
The captured logging is made available via the fixture's attributes:
|
|
614
|
+
- caplog.records: List of logging.LogRecord objects
|
|
615
|
+
- caplog.record_tuples: List of (name, level, message) tuples
|
|
616
|
+
- caplog.messages: List of message strings
|
|
617
|
+
- caplog.text: All messages as a single string
|
|
618
|
+
|
|
619
|
+
Example:
|
|
620
|
+
def test_logging(caplog):
|
|
621
|
+
import logging
|
|
622
|
+
logging.info("hello")
|
|
623
|
+
assert "hello" in caplog.text
|
|
624
|
+
assert caplog.records[0].levelname == "INFO"
|
|
625
|
+
|
|
626
|
+
def test_logging_level(caplog):
|
|
627
|
+
import logging
|
|
628
|
+
with caplog.at_level(logging.WARNING):
|
|
629
|
+
logging.info("not captured")
|
|
630
|
+
logging.warning("captured")
|
|
631
|
+
assert len(caplog.records) == 1
|
|
632
|
+
"""
|
|
633
|
+
capture = LogCaptureFixture()
|
|
634
|
+
capture.start_capture()
|
|
635
|
+
try:
|
|
636
|
+
yield capture
|
|
637
|
+
finally:
|
|
638
|
+
capture.stop_capture()
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
class Cache:
|
|
642
|
+
"""Cache fixture for storing values between test runs.
|
|
643
|
+
|
|
644
|
+
This implements pytest's cache fixture functionality for persisting
|
|
645
|
+
data across test sessions. Data is stored in .rustest_cache/ directory.
|
|
646
|
+
"""
|
|
647
|
+
|
|
648
|
+
def __init__(self, cache_dir: Path) -> None:
|
|
649
|
+
super().__init__()
|
|
650
|
+
self._cache_dir = cache_dir
|
|
651
|
+
self._cache_dir.mkdir(parents=True, exist_ok=True)
|
|
652
|
+
self._cache_file = self._cache_dir / "cache.json"
|
|
653
|
+
self._data: dict[str, Any] = {}
|
|
654
|
+
self._load()
|
|
655
|
+
|
|
656
|
+
def _load(self) -> None:
|
|
657
|
+
"""Load cache data from disk."""
|
|
658
|
+
if self._cache_file.exists():
|
|
659
|
+
try:
|
|
660
|
+
import json
|
|
661
|
+
|
|
662
|
+
with open(self._cache_file) as f:
|
|
663
|
+
self._data = json.load(f)
|
|
664
|
+
except Exception:
|
|
665
|
+
# If cache is corrupted, start fresh
|
|
666
|
+
self._data = {}
|
|
667
|
+
|
|
668
|
+
def _save(self) -> None:
|
|
669
|
+
"""Save cache data to disk."""
|
|
670
|
+
try:
|
|
671
|
+
import json
|
|
672
|
+
|
|
673
|
+
with open(self._cache_file, "w") as f:
|
|
674
|
+
json.dump(self._data, f, indent=2)
|
|
675
|
+
except Exception:
|
|
676
|
+
# Silently ignore save errors
|
|
677
|
+
pass
|
|
678
|
+
|
|
679
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
680
|
+
"""Get a value from the cache.
|
|
681
|
+
|
|
682
|
+
Args:
|
|
683
|
+
key: The cache key (should use "/" as separator, e.g., "myapp/version")
|
|
684
|
+
default: Default value if key not found
|
|
685
|
+
|
|
686
|
+
Returns:
|
|
687
|
+
The cached value or default if not found
|
|
688
|
+
"""
|
|
689
|
+
return self._data.get(key, default)
|
|
690
|
+
|
|
691
|
+
def set(self, key: str, value: Any) -> None:
|
|
692
|
+
"""Set a value in the cache.
|
|
693
|
+
|
|
694
|
+
Args:
|
|
695
|
+
key: The cache key (should use "/" as separator, e.g., "myapp/version")
|
|
696
|
+
value: The value to cache (must be JSON-serializable)
|
|
697
|
+
"""
|
|
698
|
+
self._data[key] = value
|
|
699
|
+
self._save()
|
|
700
|
+
|
|
701
|
+
def mkdir(self, name: str) -> Path:
|
|
702
|
+
"""Create and return a directory inside the cache directory.
|
|
703
|
+
|
|
704
|
+
Args:
|
|
705
|
+
name: Name of the directory to create
|
|
706
|
+
|
|
707
|
+
Returns:
|
|
708
|
+
Path to the created directory
|
|
709
|
+
"""
|
|
710
|
+
dir_path = self._cache_dir / name
|
|
711
|
+
dir_path.mkdir(parents=True, exist_ok=True)
|
|
712
|
+
return dir_path
|
|
713
|
+
|
|
714
|
+
def makedir(self, name: str) -> Any:
|
|
715
|
+
"""Create and return a py.path.local directory inside cache.
|
|
716
|
+
|
|
717
|
+
This is for pytest compatibility (uses py.path instead of pathlib).
|
|
718
|
+
|
|
719
|
+
Args:
|
|
720
|
+
name: Name of the directory to create
|
|
721
|
+
|
|
722
|
+
Returns:
|
|
723
|
+
py.path.local object for the directory
|
|
724
|
+
"""
|
|
725
|
+
if py is None: # pragma: no cover
|
|
726
|
+
raise RuntimeError("py library is required for makedir()")
|
|
727
|
+
dir_path = self.mkdir(name)
|
|
728
|
+
return py.path.local(dir_path)
|
|
729
|
+
|
|
730
|
+
def __contains__(self, key: str) -> bool:
|
|
731
|
+
"""Check if a key exists in the cache."""
|
|
732
|
+
return key in self._data
|
|
733
|
+
|
|
734
|
+
def __getitem__(self, key: str) -> Any:
|
|
735
|
+
"""Get a value from the cache (dict-style access)."""
|
|
736
|
+
return self._data[key]
|
|
737
|
+
|
|
738
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
739
|
+
"""Set a value in the cache (dict-style access)."""
|
|
740
|
+
self.set(key, value)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
@fixture(scope="session")
|
|
744
|
+
def cache() -> Cache:
|
|
745
|
+
"""
|
|
746
|
+
Provide access to a cache object that can persist between test sessions.
|
|
747
|
+
|
|
748
|
+
The cache stores data in .rustest_cache/ directory and survives across
|
|
749
|
+
test runs. This is useful for storing expensive computation results,
|
|
750
|
+
version information, or implementing features like --lf (last-failed).
|
|
751
|
+
|
|
752
|
+
The cache provides dict-like access and key/value methods:
|
|
753
|
+
- cache.get(key, default=None): Get a value
|
|
754
|
+
- cache.set(key, value): Set a value
|
|
755
|
+
- cache[key]: Dict-style get
|
|
756
|
+
- cache[key] = value: Dict-style set
|
|
757
|
+
- key in cache: Check if key exists
|
|
758
|
+
|
|
759
|
+
Cache keys should use "/" as separator (e.g., "myapp/version").
|
|
760
|
+
|
|
761
|
+
Example:
|
|
762
|
+
def test_expensive_computation(cache):
|
|
763
|
+
result = cache.get("myapp/result")
|
|
764
|
+
if result is None:
|
|
765
|
+
result = expensive_computation()
|
|
766
|
+
cache.set("myapp/result", result)
|
|
767
|
+
assert result > 0
|
|
768
|
+
|
|
769
|
+
def test_cache_version(cache):
|
|
770
|
+
version = cache.get("myapp/version", "1.0.0")
|
|
771
|
+
assert version >= "1.0.0"
|
|
772
|
+
"""
|
|
773
|
+
# Find a suitable cache directory
|
|
774
|
+
# Try current directory first, fall back to temp
|
|
775
|
+
try:
|
|
776
|
+
cache_dir = Path.cwd() / ".rustest_cache"
|
|
777
|
+
except Exception:
|
|
778
|
+
cache_dir = Path(tempfile.gettempdir()) / ".rustest_cache"
|
|
779
|
+
|
|
780
|
+
return Cache(cache_dir)
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
class MockerFixture:
|
|
784
|
+
"""Fixture for mocking that provides pytest-mock compatible API.
|
|
785
|
+
|
|
786
|
+
This fixture wraps Python's unittest.mock module and provides automatic
|
|
787
|
+
cleanup of all patches and mocks after the test completes. It's designed
|
|
788
|
+
to be API-compatible with pytest-mock's mocker fixture.
|
|
789
|
+
|
|
790
|
+
The fixture provides:
|
|
791
|
+
- mocker.patch(): Patch objects and modules
|
|
792
|
+
- mocker.patch.object(): Patch object attributes
|
|
793
|
+
- mocker.patch.multiple(): Patch multiple attributes
|
|
794
|
+
- mocker.patch.dict(): Patch dictionaries
|
|
795
|
+
- mocker.spy(): Spy on function calls
|
|
796
|
+
- mocker.stub(): Create stub functions
|
|
797
|
+
- mocker.Mock, mocker.MagicMock, etc.: Direct access to mock classes
|
|
798
|
+
"""
|
|
799
|
+
|
|
800
|
+
def __init__(self) -> None:
|
|
801
|
+
from unittest import mock
|
|
802
|
+
|
|
803
|
+
super().__init__()
|
|
804
|
+
self._patches: list[Any] = []
|
|
805
|
+
self._mocks: list[Any] = []
|
|
806
|
+
self._mock_module = mock
|
|
807
|
+
|
|
808
|
+
# Wrap Mock classes to track them for resetall()
|
|
809
|
+
self.Mock = self._make_mock_wrapper(mock.Mock)
|
|
810
|
+
self.MagicMock = self._make_mock_wrapper(mock.MagicMock)
|
|
811
|
+
self.PropertyMock = self._make_mock_wrapper(mock.PropertyMock)
|
|
812
|
+
self.AsyncMock = self._make_mock_wrapper(mock.AsyncMock)
|
|
813
|
+
self.NonCallableMock = self._make_mock_wrapper(mock.NonCallableMock)
|
|
814
|
+
self.NonCallableMagicMock = self._make_mock_wrapper(mock.NonCallableMagicMock)
|
|
815
|
+
|
|
816
|
+
# Expose other mock utilities directly (these don't need wrapping)
|
|
817
|
+
self.ANY = mock.ANY
|
|
818
|
+
self.DEFAULT = mock.DEFAULT
|
|
819
|
+
self.call = mock.call
|
|
820
|
+
self.sentinel = mock.sentinel
|
|
821
|
+
self.mock_open = mock.mock_open
|
|
822
|
+
self.seal = mock.seal
|
|
823
|
+
|
|
824
|
+
# Create nested patcher class for patch.object, patch.multiple, etc.
|
|
825
|
+
self.patch = self._make_patcher()
|
|
826
|
+
|
|
827
|
+
def _make_mock_wrapper(self, mock_class: Any) -> Any:
|
|
828
|
+
"""Wrap a mock class to track instances for resetall()."""
|
|
829
|
+
fixture = self
|
|
830
|
+
|
|
831
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
832
|
+
mock_obj = mock_class(*args, **kwargs)
|
|
833
|
+
fixture._mocks.append(mock_obj)
|
|
834
|
+
return mock_obj
|
|
835
|
+
|
|
836
|
+
return wrapper
|
|
837
|
+
|
|
838
|
+
def _make_patcher(self) -> Any:
|
|
839
|
+
"""Create a patcher object with methods for different patch types."""
|
|
840
|
+
from unittest import mock
|
|
841
|
+
|
|
842
|
+
fixture = self
|
|
843
|
+
|
|
844
|
+
class _Patcher:
|
|
845
|
+
"""Nested patcher class that provides patch.object, patch.multiple, etc."""
|
|
846
|
+
|
|
847
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
848
|
+
"""Equivalent to mock.patch()."""
|
|
849
|
+
p = mock.patch(*args, **kwargs) # type: ignore[misc]
|
|
850
|
+
mocked = p.start() # type: ignore[misc]
|
|
851
|
+
fixture._patches.append(p)
|
|
852
|
+
return mocked # type: ignore[no-any-return]
|
|
853
|
+
|
|
854
|
+
def object(self, *args: Any, **kwargs: Any) -> Any:
|
|
855
|
+
"""Equivalent to mock.patch.object()."""
|
|
856
|
+
p = mock.patch.object(*args, **kwargs) # type: ignore[misc]
|
|
857
|
+
mocked = p.start() # type: ignore[misc]
|
|
858
|
+
fixture._patches.append(p)
|
|
859
|
+
return mocked # type: ignore[no-any-return]
|
|
860
|
+
|
|
861
|
+
def multiple(self, *args: Any, **kwargs: Any) -> Any:
|
|
862
|
+
"""Equivalent to mock.patch.multiple()."""
|
|
863
|
+
p = mock.patch.multiple(*args, **kwargs)
|
|
864
|
+
mocked = p.start()
|
|
865
|
+
fixture._patches.append(p)
|
|
866
|
+
return mocked
|
|
867
|
+
|
|
868
|
+
def dict(self, *args: Any, **kwargs: Any) -> Any:
|
|
869
|
+
"""Equivalent to mock.patch.dict()."""
|
|
870
|
+
p = mock.patch.dict(*args, **kwargs)
|
|
871
|
+
mocked = p.start()
|
|
872
|
+
fixture._patches.append(p)
|
|
873
|
+
return mocked
|
|
874
|
+
|
|
875
|
+
return _Patcher()
|
|
876
|
+
|
|
877
|
+
def spy(self, obj: Any, name: str) -> Any:
|
|
878
|
+
"""Create a spy that wraps an existing function/method.
|
|
879
|
+
|
|
880
|
+
The spy will call through to the original function while recording
|
|
881
|
+
all calls. Useful for verifying that a function was called without
|
|
882
|
+
changing its behavior.
|
|
883
|
+
|
|
884
|
+
Args:
|
|
885
|
+
obj: The object containing the method to spy on
|
|
886
|
+
name: The name of the method to spy on
|
|
887
|
+
|
|
888
|
+
Returns:
|
|
889
|
+
A MagicMock that wraps the original method
|
|
890
|
+
|
|
891
|
+
Example:
|
|
892
|
+
class Calculator:
|
|
893
|
+
def add(self, a, b):
|
|
894
|
+
return a + b
|
|
895
|
+
|
|
896
|
+
def test_spy(mocker):
|
|
897
|
+
calc = Calculator()
|
|
898
|
+
spy = mocker.spy(calc, 'add')
|
|
899
|
+
result = calc.add(2, 3)
|
|
900
|
+
assert result == 5
|
|
901
|
+
spy.assert_called_once_with(2, 3)
|
|
902
|
+
"""
|
|
903
|
+
from unittest import mock
|
|
904
|
+
|
|
905
|
+
original = getattr(obj, name)
|
|
906
|
+
|
|
907
|
+
# Create a wrapper that calls through to the original
|
|
908
|
+
spy_mock = mock.MagicMock(side_effect=original)
|
|
909
|
+
|
|
910
|
+
# Patch the object with our spy
|
|
911
|
+
p = mock.patch.object(obj, name, spy_mock)
|
|
912
|
+
p.start()
|
|
913
|
+
self._patches.append(p)
|
|
914
|
+
|
|
915
|
+
# Store spy metadata (pytest-mock compatibility)
|
|
916
|
+
spy_mock.spy_return = None
|
|
917
|
+
spy_mock.spy_exception = None
|
|
918
|
+
|
|
919
|
+
return spy_mock
|
|
920
|
+
|
|
921
|
+
def stub(self, name: str | None = None) -> Any:
|
|
922
|
+
"""Create a stub function that accepts any arguments.
|
|
923
|
+
|
|
924
|
+
Stubs are useful for callbacks and other scenarios where you need
|
|
925
|
+
a function that does nothing but can be verified for calls.
|
|
926
|
+
|
|
927
|
+
Args:
|
|
928
|
+
name: Optional name for the stub (for better error messages)
|
|
929
|
+
|
|
930
|
+
Returns:
|
|
931
|
+
A MagicMock configured as a stub
|
|
932
|
+
|
|
933
|
+
Example:
|
|
934
|
+
def test_callback(mocker):
|
|
935
|
+
callback = mocker.stub(name='callback')
|
|
936
|
+
process_data(callback)
|
|
937
|
+
callback.assert_called_once()
|
|
938
|
+
"""
|
|
939
|
+
from unittest import mock
|
|
940
|
+
|
|
941
|
+
stub_mock = mock.MagicMock(name=name)
|
|
942
|
+
self._mocks.append(stub_mock)
|
|
943
|
+
return stub_mock
|
|
944
|
+
|
|
945
|
+
def async_stub(self, name: str | None = None) -> Any:
|
|
946
|
+
"""Create an async stub function.
|
|
947
|
+
|
|
948
|
+
Similar to stub() but for async functions.
|
|
949
|
+
|
|
950
|
+
Args:
|
|
951
|
+
name: Optional name for the stub
|
|
952
|
+
|
|
953
|
+
Returns:
|
|
954
|
+
An AsyncMock configured as a stub
|
|
955
|
+
|
|
956
|
+
Example:
|
|
957
|
+
async def test_async_callback(mocker):
|
|
958
|
+
callback = mocker.async_stub(name='async_callback')
|
|
959
|
+
await process_async(callback)
|
|
960
|
+
callback.assert_called_once()
|
|
961
|
+
"""
|
|
962
|
+
from unittest import mock
|
|
963
|
+
|
|
964
|
+
stub_mock = mock.AsyncMock(name=name)
|
|
965
|
+
self._mocks.append(stub_mock)
|
|
966
|
+
return stub_mock
|
|
967
|
+
|
|
968
|
+
def resetall(self, *, return_value: bool = False, side_effect: bool = False) -> None:
|
|
969
|
+
"""Reset all mocks created by this fixture.
|
|
970
|
+
|
|
971
|
+
Args:
|
|
972
|
+
return_value: If True, also reset return_value
|
|
973
|
+
side_effect: If True, also reset side_effect
|
|
974
|
+
|
|
975
|
+
Example:
|
|
976
|
+
def test_multiple_calls(mocker):
|
|
977
|
+
mock_fn = mocker.Mock(return_value=42)
|
|
978
|
+
assert mock_fn() == 42
|
|
979
|
+
mock_fn.assert_called_once()
|
|
980
|
+
|
|
981
|
+
mocker.resetall()
|
|
982
|
+
mock_fn.assert_not_called()
|
|
983
|
+
"""
|
|
984
|
+
for mock_obj in self._mocks:
|
|
985
|
+
mock_obj.reset_mock(return_value=return_value, side_effect=side_effect)
|
|
986
|
+
|
|
987
|
+
# Reset mocks from patches
|
|
988
|
+
for patch in self._patches:
|
|
989
|
+
try:
|
|
990
|
+
# Access the mock object from the patch
|
|
991
|
+
if hasattr(patch, "new") and hasattr(patch.new, "reset_mock"):
|
|
992
|
+
patch.new.reset_mock(return_value=return_value, side_effect=side_effect)
|
|
993
|
+
except Exception: # pragma: no cover
|
|
994
|
+
# Some patches might not have accessible mocks
|
|
995
|
+
pass
|
|
996
|
+
|
|
997
|
+
def stopall(self) -> None:
|
|
998
|
+
"""Stop all patches started by this fixture.
|
|
999
|
+
|
|
1000
|
+
This is called automatically during cleanup but can be called
|
|
1001
|
+
manually if needed.
|
|
1002
|
+
|
|
1003
|
+
Example:
|
|
1004
|
+
def test_manual_stop(mocker):
|
|
1005
|
+
mock_fn = mocker.patch('os.remove')
|
|
1006
|
+
mocker.stopall()
|
|
1007
|
+
# Patches are now stopped
|
|
1008
|
+
"""
|
|
1009
|
+
for patch in reversed(self._patches):
|
|
1010
|
+
try:
|
|
1011
|
+
patch.stop()
|
|
1012
|
+
except Exception: # pragma: no cover
|
|
1013
|
+
# Patch might already be stopped
|
|
1014
|
+
pass
|
|
1015
|
+
self._patches.clear()
|
|
1016
|
+
|
|
1017
|
+
def stop(self, mock_obj: Any) -> None:
|
|
1018
|
+
"""Stop a specific patch by its mock object.
|
|
1019
|
+
|
|
1020
|
+
Args:
|
|
1021
|
+
mock_obj: The mock object returned by patch() or spy()
|
|
1022
|
+
|
|
1023
|
+
Example:
|
|
1024
|
+
def test_selective_stop(mocker):
|
|
1025
|
+
mock1 = mocker.patch('os.remove')
|
|
1026
|
+
mock2 = mocker.patch('os.path.exists')
|
|
1027
|
+
|
|
1028
|
+
mocker.stop(mock1)
|
|
1029
|
+
# mock1 is stopped, mock2 is still active
|
|
1030
|
+
"""
|
|
1031
|
+
# Find and stop the patch associated with this mock
|
|
1032
|
+
for i, patch in enumerate(self._patches):
|
|
1033
|
+
try:
|
|
1034
|
+
if hasattr(patch, "new") and patch.new is mock_obj:
|
|
1035
|
+
patch.stop()
|
|
1036
|
+
self._patches.pop(i)
|
|
1037
|
+
return
|
|
1038
|
+
except Exception: # pragma: no cover
|
|
1039
|
+
continue
|
|
1040
|
+
|
|
1041
|
+
# If not found in patches, try to stop it directly
|
|
1042
|
+
if hasattr(mock_obj, "stop"):
|
|
1043
|
+
try:
|
|
1044
|
+
mock_obj.stop()
|
|
1045
|
+
except Exception: # pragma: no cover
|
|
1046
|
+
pass
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
@fixture
|
|
1050
|
+
def mocker() -> Generator[MockerFixture, None, None]:
|
|
1051
|
+
"""
|
|
1052
|
+
Fixture for mocking that provides pytest-mock compatible API.
|
|
1053
|
+
|
|
1054
|
+
The mocker fixture provides a thin wrapper around Python's unittest.mock
|
|
1055
|
+
with automatic cleanup. It's designed to be API-compatible with pytest-mock.
|
|
1056
|
+
|
|
1057
|
+
**Main patching methods:**
|
|
1058
|
+
- mocker.patch(target, **kwargs): Patch an object
|
|
1059
|
+
- mocker.patch.object(target, attr, **kwargs): Patch an attribute
|
|
1060
|
+
- mocker.patch.multiple(target, **kwargs): Patch multiple attributes
|
|
1061
|
+
- mocker.patch.dict(target, values, **kwargs): Patch a dictionary
|
|
1062
|
+
|
|
1063
|
+
**Utility methods:**
|
|
1064
|
+
- mocker.spy(obj, name): Spy on a method while calling through
|
|
1065
|
+
- mocker.stub(name=None): Create a stub that accepts any arguments
|
|
1066
|
+
- mocker.async_stub(name=None): Create an async stub
|
|
1067
|
+
|
|
1068
|
+
**Management methods:**
|
|
1069
|
+
- mocker.resetall(): Reset all mocks
|
|
1070
|
+
- mocker.stopall(): Stop all patches
|
|
1071
|
+
- mocker.stop(mock): Stop a specific patch
|
|
1072
|
+
|
|
1073
|
+
**Direct access to mock classes:**
|
|
1074
|
+
- mocker.Mock, mocker.MagicMock, mocker.AsyncMock
|
|
1075
|
+
- mocker.PropertyMock, mocker.NonCallableMock
|
|
1076
|
+
- mocker.ANY, mocker.call, mocker.sentinel
|
|
1077
|
+
- mocker.mock_open, mocker.seal
|
|
1078
|
+
|
|
1079
|
+
Example:
|
|
1080
|
+
def test_basic_mocking(mocker):
|
|
1081
|
+
# Patch a function
|
|
1082
|
+
mock_remove = mocker.patch('os.remove')
|
|
1083
|
+
os.remove('/tmp/file')
|
|
1084
|
+
mock_remove.assert_called_once_with('/tmp/file')
|
|
1085
|
+
|
|
1086
|
+
def test_spy(mocker):
|
|
1087
|
+
# Spy on a method
|
|
1088
|
+
obj = MyClass()
|
|
1089
|
+
spy = mocker.spy(obj, 'method')
|
|
1090
|
+
result = obj.method(42)
|
|
1091
|
+
spy.assert_called_once_with(42)
|
|
1092
|
+
|
|
1093
|
+
def test_stub(mocker):
|
|
1094
|
+
# Create a stub for callbacks
|
|
1095
|
+
callback = mocker.stub(name='callback')
|
|
1096
|
+
process_with_callback(callback)
|
|
1097
|
+
callback.assert_called()
|
|
1098
|
+
|
|
1099
|
+
def test_mock_return_value(mocker):
|
|
1100
|
+
# Mock with return value
|
|
1101
|
+
mock_fn = mocker.patch('my_module.expensive_function')
|
|
1102
|
+
mock_fn.return_value = 42
|
|
1103
|
+
assert my_module.expensive_function() == 42
|
|
1104
|
+
|
|
1105
|
+
def test_direct_mock_usage(mocker):
|
|
1106
|
+
# Use Mock classes directly
|
|
1107
|
+
mock_obj = mocker.MagicMock()
|
|
1108
|
+
mock_obj.method.return_value = 'result'
|
|
1109
|
+
assert mock_obj.method() == 'result'
|
|
1110
|
+
"""
|
|
1111
|
+
m = MockerFixture()
|
|
1112
|
+
try:
|
|
1113
|
+
yield m
|
|
1114
|
+
finally:
|
|
1115
|
+
m.stopall()
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
__all__ = [
|
|
1119
|
+
"Cache",
|
|
1120
|
+
"CaptureFixture",
|
|
1121
|
+
"LogCaptureFixture",
|
|
1122
|
+
"MockerFixture",
|
|
1123
|
+
"MonkeyPatch",
|
|
1124
|
+
"TmpDirFactory",
|
|
1125
|
+
"TmpPathFactory",
|
|
1126
|
+
"cache",
|
|
1127
|
+
"caplog",
|
|
1128
|
+
"capsys",
|
|
1129
|
+
"capfd",
|
|
1130
|
+
"mocker",
|
|
1131
|
+
"monkeypatch",
|
|
1132
|
+
"tmpdir",
|
|
1133
|
+
"tmpdir_factory",
|
|
1134
|
+
"tmp_path",
|
|
1135
|
+
"tmp_path_factory",
|
|
1136
|
+
"request",
|
|
1137
|
+
]
|