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.
@@ -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
+ ]