rustest 0.8.0__cp312-cp312-win_amd64.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 ADDED
@@ -0,0 +1,28 @@
1
+ """Public Python API for rustest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from . import decorators
6
+ from .approx import approx
7
+ from .cli import main
8
+ from .reporting import RunReport, TestResult
9
+ from .core import run
10
+
11
+ fixture = decorators.fixture
12
+ mark = decorators.mark
13
+ parametrize = decorators.parametrize
14
+ raises = decorators.raises
15
+ skip = decorators.skip
16
+
17
+ __all__ = [
18
+ "RunReport",
19
+ "TestResult",
20
+ "approx",
21
+ "fixture",
22
+ "main",
23
+ "mark",
24
+ "parametrize",
25
+ "raises",
26
+ "run",
27
+ "skip",
28
+ ]
rustest/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Module executed when running ``python -m rustest``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
rustest/approx.py ADDED
@@ -0,0 +1,176 @@
1
+ """Approximate comparison for floating-point numbers.
2
+
3
+ This module provides the `approx` class for comparing floating-point numbers
4
+ with a tolerance, similar to ``pytest.approx``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping, Sequence
10
+ from typing import Any, Union, cast
11
+
12
+ ApproxScalar = Union[float, int, complex]
13
+ ApproxValue = Union[ApproxScalar, Sequence["ApproxValue"], Mapping[str, "ApproxValue"]]
14
+
15
+
16
+ class approx:
17
+ """Assert that two numbers (or collections of numbers) are equal to each other
18
+ within some tolerance.
19
+
20
+ This is similar to pytest.approx and is useful for comparing floating-point
21
+ numbers that may have small rounding errors.
22
+
23
+ Usage:
24
+ assert 0.1 + 0.2 == approx(0.3)
25
+ assert 0.1 + 0.2 == approx(0.3, rel=1e-6)
26
+ assert 0.1 + 0.2 == approx(0.3, abs=1e-9)
27
+ assert [0.1 + 0.2, 0.3] == approx([0.3, 0.3])
28
+ assert {"a": 0.1 + 0.2} == approx({"a": 0.3})
29
+
30
+ Args:
31
+ expected: The expected value to compare against
32
+ rel: The relative tolerance (default: 1e-6)
33
+ abs: The absolute tolerance (default: 1e-12)
34
+
35
+ By default, numbers are considered close if the difference between them is
36
+ less than or equal to:
37
+ abs(expected * rel) + abs_tolerance
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ expected: ApproxValue,
43
+ rel: float = 1e-6,
44
+ abs: float = 1e-12,
45
+ ) -> None:
46
+ """Initialize approx with expected value and tolerances.
47
+
48
+ Args:
49
+ expected: The expected value to compare against
50
+ rel: The relative tolerance (default: 1e-6)
51
+ abs: The absolute tolerance (default: 1e-12)
52
+ """
53
+ super().__init__()
54
+ self.expected = expected
55
+ self.rel = rel
56
+ self.abs = abs
57
+
58
+ def __repr__(self) -> str:
59
+ """Return a string representation of the approx object."""
60
+ return f"approx({self.expected!r}, rel={self.rel}, abs={self.abs})"
61
+
62
+ def __eq__(self, actual: Any) -> bool:
63
+ """Compare actual value with expected value within tolerance.
64
+
65
+ Args:
66
+ actual: The actual value to compare
67
+
68
+ Returns:
69
+ True if the values are approximately equal, False otherwise
70
+ """
71
+ return self._approx_compare(actual, self.expected)
72
+
73
+ def _approx_compare(self, actual: Any, expected: Any) -> bool:
74
+ """Recursively compare actual and expected values.
75
+
76
+ Args:
77
+ actual: The actual value
78
+ expected: The expected value
79
+
80
+ Returns:
81
+ True if values are approximately equal, False otherwise
82
+ """
83
+ # Handle None
84
+ if actual is None or expected is None:
85
+ return actual == expected
86
+
87
+ # Handle dictionaries
88
+ if isinstance(expected, Mapping):
89
+ expected_mapping = cast(Mapping[str, ApproxValue], expected)
90
+ if not isinstance(actual, Mapping):
91
+ return False
92
+ actual_mapping = cast(Mapping[str, ApproxValue], actual)
93
+ if set(actual_mapping.keys()) != set(expected_mapping.keys()):
94
+ return False
95
+ return all(
96
+ self._approx_compare(actual_mapping[key], expected_mapping[key])
97
+ for key in expected_mapping
98
+ )
99
+
100
+ # Handle sequences (lists, tuples, etc.) but not strings
101
+ if isinstance(expected, Sequence) and not isinstance(expected, (str, bytes, bytearray)):
102
+ expected_sequence = cast(Sequence[ApproxValue], expected)
103
+ if not (
104
+ isinstance(actual, Sequence)
105
+ and not isinstance(actual, (str, bytes, bytearray))
106
+ and type(actual) is type(expected)
107
+ ):
108
+ return False
109
+ actual_sequence = cast(Sequence[ApproxValue], actual)
110
+ if len(actual_sequence) != len(expected_sequence):
111
+ return False
112
+ return all(
113
+ self._approx_compare(actual_item, expected_item)
114
+ for actual_item, expected_item in zip(actual_sequence, expected_sequence)
115
+ )
116
+
117
+ # Handle numbers (float, int, complex)
118
+ if isinstance(expected, (float, int, complex)) and isinstance(
119
+ actual, (float, int, complex)
120
+ ):
121
+ return self._is_close(actual, expected)
122
+
123
+ # For other types, use exact equality
124
+ return actual == expected
125
+
126
+ def _is_close(
127
+ self, actual: Union[float, int, complex], expected: Union[float, int, complex]
128
+ ) -> bool:
129
+ """Check if two numbers are close within tolerance.
130
+
131
+ Uses the formula: |actual - expected| <= max(rel * max(|actual|, |expected|), abs)
132
+
133
+ Args:
134
+ actual: The actual number
135
+ expected: The expected number
136
+
137
+ Returns:
138
+ True if numbers are close, False otherwise
139
+ """
140
+ # Handle infinities and NaN
141
+ if isinstance(actual, complex) or isinstance(expected, complex):
142
+ # For complex numbers, compare real and imaginary parts separately
143
+ if isinstance(actual, complex) and isinstance(expected, complex):
144
+ return self._is_close(actual.real, expected.real) and self._is_close(
145
+ actual.imag, expected.imag
146
+ )
147
+ # One is complex, the other is not
148
+ if isinstance(actual, complex):
149
+ return self._is_close(actual.real, expected) and abs(actual.imag) <= self.abs
150
+ else: # expected is complex
151
+ return self._is_close(actual, expected.real) and abs(expected.imag) <= self.abs
152
+
153
+ # Convert to float for comparison
154
+ actual_float = float(actual)
155
+ expected_float = float(expected)
156
+
157
+ # Handle special float values
158
+ if actual_float == expected_float:
159
+ # This handles infinities and zeros
160
+ return True
161
+
162
+ # Check for NaN - NaN should never be equal to anything
163
+ import math
164
+
165
+ if math.isnan(actual_float) or math.isnan(expected_float):
166
+ return False
167
+
168
+ # Check for infinities
169
+ if math.isinf(actual_float) or math.isinf(expected_float):
170
+ return actual_float == expected_float
171
+
172
+ # Calculate tolerance
173
+ abs_diff = abs(actual_float - expected_float)
174
+ tolerance = max(self.rel * max(abs(actual_float), abs(expected_float)), self.abs)
175
+
176
+ return abs_diff <= tolerance
@@ -0,0 +1,309 @@
1
+ """Builtin fixtures that mirror a subset of pytest's default fixtures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import itertools
7
+ import os
8
+ import shutil
9
+ import sys
10
+ import tempfile
11
+ from collections.abc import Generator, MutableMapping
12
+ from contextlib import contextmanager
13
+ from pathlib import Path
14
+ from types import ModuleType
15
+ from typing import TYPE_CHECKING, Any, Iterator, cast
16
+
17
+ from .decorators import fixture
18
+
19
+ py: ModuleType | None
20
+ try: # pragma: no cover - optional dependency at runtime
21
+ import py as _py_module
22
+ except Exception: # pragma: no cover - import error reported at fixture usage time
23
+ py = None
24
+ else:
25
+ py = _py_module
26
+
27
+ if TYPE_CHECKING:
28
+ try: # pragma: no cover - typing-only import
29
+ from py import path as _py_path
30
+ except ImportError:
31
+ PyPathLocal = Any
32
+ else:
33
+ PyPathLocal = _py_path.local
34
+
35
+ else: # pragma: no cover - imported only for typing
36
+ PyPathLocal = Any
37
+
38
+
39
+ class _NotSet:
40
+ """Sentinel value for tracking missing attributes/items."""
41
+
42
+ def __repr__(self) -> str: # pragma: no cover - debug helper
43
+ return "<NOTSET>"
44
+
45
+
46
+ _NOT_SET = _NotSet()
47
+
48
+
49
+ class MonkeyPatch:
50
+ """Lightweight re-implementation of :class:`pytest.MonkeyPatch`."""
51
+
52
+ def __init__(self) -> None:
53
+ super().__init__()
54
+ self._setattrs: list[tuple[object, str, object | _NotSet]] = []
55
+ self._setitems: list[tuple[MutableMapping[Any, Any], Any, object | _NotSet]] = []
56
+ self._environ: list[tuple[str, str | _NotSet]] = []
57
+ self._syspath_prepend: list[str] = []
58
+ self._cwd_original: str | None = None
59
+
60
+ @classmethod
61
+ @contextmanager
62
+ def context(cls) -> Generator[MonkeyPatch, None, None]:
63
+ patch = cls()
64
+ try:
65
+ yield patch
66
+ finally:
67
+ patch.undo()
68
+
69
+ def setattr(
70
+ self,
71
+ target: object | str,
72
+ name: object | str = _NOT_SET,
73
+ value: object = _NOT_SET,
74
+ *,
75
+ raising: bool = True,
76
+ ) -> None:
77
+ if value is _NOT_SET:
78
+ if not isinstance(target, str):
79
+ raise TypeError("use setattr(target, name, value) or setattr('module.attr', value)")
80
+ module_path, attr_name = target.rsplit(".", 1)
81
+ module = importlib.import_module(module_path)
82
+ obj = module
83
+ attr_value = name
84
+ if attr_value is _NOT_SET:
85
+ raise TypeError("value must be provided when using dotted path syntax")
86
+ attr_name = attr_name
87
+ else:
88
+ if not isinstance(name, str):
89
+ raise TypeError("attribute name must be a string")
90
+ obj = target
91
+ attr_name = name
92
+ attr_value = value
93
+
94
+ original = getattr(obj, attr_name, _NOT_SET)
95
+ if original is _NOT_SET and raising:
96
+ raise AttributeError(f"{attr_name!r} not found for patching")
97
+
98
+ setattr(obj, attr_name, attr_value)
99
+ self._setattrs.append((obj, attr_name, original))
100
+
101
+ def delattr(
102
+ self, target: object | str, name: str | _NotSet = _NOT_SET, *, raising: bool = True
103
+ ) -> None:
104
+ if isinstance(target, str) and name is _NOT_SET:
105
+ module_path, attr_name = target.rsplit(".", 1)
106
+ module = importlib.import_module(module_path)
107
+ obj = module
108
+ attr_name = attr_name
109
+ else:
110
+ if not isinstance(name, str):
111
+ raise TypeError("attribute name must be a string")
112
+ obj = target
113
+ attr_name = name
114
+
115
+ original = getattr(obj, attr_name, _NOT_SET)
116
+ if original is _NOT_SET:
117
+ if raising:
118
+ raise AttributeError(f"{attr_name!r} not found for deletion")
119
+ return
120
+
121
+ delattr(obj, attr_name)
122
+ self._setattrs.append((obj, attr_name, original))
123
+
124
+ def setitem(self, mapping: MutableMapping[Any, Any], key: Any, value: Any) -> None:
125
+ original = mapping.get(key, _NOT_SET)
126
+ mapping[key] = value
127
+ self._setitems.append((mapping, key, original))
128
+
129
+ def delitem(self, mapping: MutableMapping[Any, Any], key: Any, *, raising: bool = True) -> None:
130
+ if key not in mapping:
131
+ if raising:
132
+ raise KeyError(key)
133
+ self._setitems.append((mapping, key, _NOT_SET))
134
+ return
135
+
136
+ original = mapping[key]
137
+ del mapping[key]
138
+ self._setitems.append((mapping, key, original))
139
+
140
+ def setenv(self, name: str, value: Any, prepend: str | None = None) -> None:
141
+ str_value = str(value)
142
+ if prepend and name in os.environ:
143
+ str_value = f"{str_value}{prepend}{os.environ[name]}"
144
+ original = os.environ.get(name)
145
+ os.environ[name] = str_value
146
+ stored_original: str | _NotSet = original if original is not None else _NOT_SET
147
+ self._environ.append((name, stored_original))
148
+
149
+ def delenv(self, name: str, *, raising: bool = True) -> None:
150
+ if name not in os.environ:
151
+ if raising:
152
+ raise KeyError(name)
153
+ self._environ.append((name, _NOT_SET))
154
+ return
155
+
156
+ original = os.environ.pop(name)
157
+ self._environ.append((name, original))
158
+
159
+ def syspath_prepend(self, path: os.PathLike[str] | str) -> None:
160
+ str_path = os.fspath(path)
161
+ if str_path in sys.path:
162
+ return
163
+ sys.path.insert(0, str_path)
164
+ self._syspath_prepend.append(str_path)
165
+
166
+ def chdir(self, path: os.PathLike[str] | str) -> None:
167
+ if self._cwd_original is None:
168
+ self._cwd_original = os.getcwd()
169
+ os.chdir(os.fspath(path))
170
+
171
+ def undo(self) -> None:
172
+ for obj, attr_name, original in reversed(self._setattrs):
173
+ if original is _NOT_SET:
174
+ try:
175
+ delattr(obj, attr_name)
176
+ except AttributeError: # pragma: no cover - defensive
177
+ pass
178
+ else:
179
+ setattr(obj, attr_name, original)
180
+ self._setattrs.clear()
181
+
182
+ for mapping, key, original in reversed(self._setitems):
183
+ if original is _NOT_SET:
184
+ mapping.pop(key, None)
185
+ else:
186
+ mapping[key] = original
187
+ self._setitems.clear()
188
+
189
+ for name, original in reversed(self._environ):
190
+ if original is _NOT_SET:
191
+ os.environ.pop(name, None)
192
+ else:
193
+ os.environ[name] = cast(str, original)
194
+ self._environ.clear()
195
+
196
+ while self._syspath_prepend:
197
+ str_path = self._syspath_prepend.pop()
198
+ try:
199
+ sys.path.remove(str_path)
200
+ except ValueError: # pragma: no cover - path already removed externally
201
+ pass
202
+
203
+ if self._cwd_original is not None:
204
+ os.chdir(self._cwd_original)
205
+ self._cwd_original = None
206
+
207
+
208
+ class TmpPathFactory:
209
+ """Create temporary directories using :class:`pathlib.Path`."""
210
+
211
+ def __init__(self, prefix: str = "tmp_path") -> None:
212
+ super().__init__()
213
+ self._base = Path(tempfile.mkdtemp(prefix=f"rustest-{prefix}-"))
214
+ self._counter = itertools.count()
215
+ self._created: list[Path] = []
216
+
217
+ def mktemp(self, basename: str, *, numbered: bool = True) -> Path:
218
+ if not basename:
219
+ raise ValueError("basename must be a non-empty string")
220
+ if numbered:
221
+ suffix = next(self._counter)
222
+ name = f"{basename}{suffix}"
223
+ else:
224
+ name = basename
225
+ path = self._base / name
226
+ path.mkdir(parents=True, exist_ok=False)
227
+ self._created.append(path)
228
+ return path
229
+
230
+ def getbasetemp(self) -> Path:
231
+ return self._base
232
+
233
+ def cleanup(self) -> None:
234
+ for path in reversed(self._created):
235
+ shutil.rmtree(path, ignore_errors=True)
236
+ shutil.rmtree(self._base, ignore_errors=True)
237
+ self._created.clear()
238
+
239
+
240
+ class TmpDirFactory:
241
+ """Wrapper that exposes ``py.path.local`` directories."""
242
+
243
+ def __init__(self, path_factory: TmpPathFactory) -> None:
244
+ super().__init__()
245
+ self._factory = path_factory
246
+
247
+ def mktemp(self, basename: str, *, numbered: bool = True) -> Any:
248
+ if py is None: # pragma: no cover - exercised only when dependency missing
249
+ raise RuntimeError("py library is required for tmpdir fixtures")
250
+ path = self._factory.mktemp(basename, numbered=numbered)
251
+ return py.path.local(path)
252
+
253
+ def getbasetemp(self) -> Any:
254
+ if py is None: # pragma: no cover - exercised only when dependency missing
255
+ raise RuntimeError("py library is required for tmpdir fixtures")
256
+ return py.path.local(self._factory.getbasetemp())
257
+
258
+ def cleanup(self) -> None:
259
+ self._factory.cleanup()
260
+
261
+
262
+ @fixture(scope="session")
263
+ def tmp_path_factory() -> Iterator[TmpPathFactory]:
264
+ factory = TmpPathFactory("tmp_path")
265
+ try:
266
+ yield factory
267
+ finally:
268
+ factory.cleanup()
269
+
270
+
271
+ @fixture(scope="function")
272
+ def tmp_path(tmp_path_factory: TmpPathFactory) -> Iterator[Path]:
273
+ path = tmp_path_factory.mktemp("tmp_path")
274
+ yield path
275
+
276
+
277
+ @fixture(scope="session")
278
+ def tmpdir_factory() -> Iterator[TmpDirFactory]:
279
+ factory = TmpDirFactory(TmpPathFactory("tmpdir"))
280
+ try:
281
+ yield factory
282
+ finally:
283
+ factory.cleanup()
284
+
285
+
286
+ @fixture(scope="function")
287
+ def tmpdir(tmpdir_factory: TmpDirFactory) -> Iterator[Any]:
288
+ yield tmpdir_factory.mktemp("tmpdir")
289
+
290
+
291
+ @fixture(scope="function")
292
+ def monkeypatch() -> Iterator[MonkeyPatch]:
293
+ patch = MonkeyPatch()
294
+ try:
295
+ yield patch
296
+ finally:
297
+ patch.undo()
298
+
299
+
300
+ __all__ = [
301
+ "MonkeyPatch",
302
+ "TmpDirFactory",
303
+ "TmpPathFactory",
304
+ "monkeypatch",
305
+ "tmpdir",
306
+ "tmpdir_factory",
307
+ "tmp_path",
308
+ "tmp_path_factory",
309
+ ]