rustest 0.1.0__cp312-cp312-macosx_11_0_arm64.whl → 0.3.0__cp312-cp312-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 +11 -7
- rustest/__main__.py +1 -1
- rustest/approx.py +160 -0
- rustest/{_cli.py → cli.py} +1 -1
- rustest/core.py +3 -3
- rustest/{_decorators.py → decorators.py} +156 -0
- rustest/py.typed +0 -0
- rustest/{_reporting.py → reporting.py} +3 -3
- rustest/rust.cpython-312-darwin.so +0 -0
- rustest/{_rust.py → rust.py} +1 -1
- rustest/{_rust.pyi → rust.pyi} +7 -7
- {rustest-0.1.0.dist-info → rustest-0.3.0.dist-info}/METADATA +195 -24
- rustest-0.3.0.dist-info/RECORD +16 -0
- rustest/_rust.cpython-312-darwin.so +0 -0
- rustest-0.1.0.dist-info/RECORD +0 -14
- {rustest-0.1.0.dist-info → rustest-0.3.0.dist-info}/WHEEL +0 -0
- {rustest-0.1.0.dist-info → rustest-0.3.0.dist-info}/entry_points.txt +0 -0
- {rustest-0.1.0.dist-info → rustest-0.3.0.dist-info}/licenses/LICENSE +0 -0
rustest/__init__.py
CHANGED
|
@@ -2,23 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from . import
|
|
6
|
-
from .
|
|
7
|
-
from .
|
|
5
|
+
from . import decorators
|
|
6
|
+
from .approx import approx
|
|
7
|
+
from .cli import main
|
|
8
|
+
from .reporting import RunReport, TestResult
|
|
8
9
|
from .core import run
|
|
9
10
|
|
|
10
|
-
fixture =
|
|
11
|
-
mark =
|
|
12
|
-
parametrize =
|
|
13
|
-
|
|
11
|
+
fixture = decorators.fixture
|
|
12
|
+
mark = decorators.mark
|
|
13
|
+
parametrize = decorators.parametrize
|
|
14
|
+
raises = decorators.raises
|
|
15
|
+
skip = decorators.skip
|
|
14
16
|
|
|
15
17
|
__all__ = [
|
|
16
18
|
"RunReport",
|
|
17
19
|
"TestResult",
|
|
20
|
+
"approx",
|
|
18
21
|
"fixture",
|
|
19
22
|
"main",
|
|
20
23
|
"mark",
|
|
21
24
|
"parametrize",
|
|
25
|
+
"raises",
|
|
22
26
|
"run",
|
|
23
27
|
"skip",
|
|
24
28
|
]
|
rustest/__main__.py
CHANGED
rustest/approx.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
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 typing import Any, Mapping, Sequence, Union
|
|
8
|
+
|
|
9
|
+
# Type alias for values that can be approximated
|
|
10
|
+
ApproxValue = Union[float, int, complex, Sequence[Any], Mapping[str, Any]]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class approx:
|
|
14
|
+
"""Assert that two numbers (or collections of numbers) are equal to each other
|
|
15
|
+
within some tolerance.
|
|
16
|
+
|
|
17
|
+
This is similar to pytest.approx and is useful for comparing floating-point
|
|
18
|
+
numbers that may have small rounding errors.
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
assert 0.1 + 0.2 == approx(0.3)
|
|
22
|
+
assert 0.1 + 0.2 == approx(0.3, rel=1e-6)
|
|
23
|
+
assert 0.1 + 0.2 == approx(0.3, abs=1e-9)
|
|
24
|
+
assert [0.1 + 0.2, 0.3] == approx([0.3, 0.3])
|
|
25
|
+
assert {"a": 0.1 + 0.2} == approx({"a": 0.3})
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
expected: The expected value to compare against
|
|
29
|
+
rel: The relative tolerance (default: 1e-6)
|
|
30
|
+
abs: The absolute tolerance (default: 1e-12)
|
|
31
|
+
|
|
32
|
+
By default, numbers are considered close if the difference between them is
|
|
33
|
+
less than or equal to:
|
|
34
|
+
abs(expected * rel) + abs_tolerance
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
expected: ApproxValue,
|
|
40
|
+
rel: float = 1e-6,
|
|
41
|
+
abs: float = 1e-12,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Initialize approx with expected value and tolerances.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
expected: The expected value to compare against
|
|
47
|
+
rel: The relative tolerance (default: 1e-6)
|
|
48
|
+
abs: The absolute tolerance (default: 1e-12)
|
|
49
|
+
"""
|
|
50
|
+
super().__init__()
|
|
51
|
+
self.expected = expected
|
|
52
|
+
self.rel = rel
|
|
53
|
+
self.abs = abs
|
|
54
|
+
|
|
55
|
+
def __repr__(self) -> str:
|
|
56
|
+
"""Return a string representation of the approx object."""
|
|
57
|
+
return f"approx({self.expected!r}, rel={self.rel}, abs={self.abs})"
|
|
58
|
+
|
|
59
|
+
def __eq__(self, actual: Any) -> bool:
|
|
60
|
+
"""Compare actual value with expected value within tolerance.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
actual: The actual value to compare
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
True if the values are approximately equal, False otherwise
|
|
67
|
+
"""
|
|
68
|
+
return self._approx_compare(actual, self.expected)
|
|
69
|
+
|
|
70
|
+
def _approx_compare(self, actual: Any, expected: Any) -> bool:
|
|
71
|
+
"""Recursively compare actual and expected values.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
actual: The actual value
|
|
75
|
+
expected: The expected value
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
True if values are approximately equal, False otherwise
|
|
79
|
+
"""
|
|
80
|
+
# Handle None
|
|
81
|
+
if actual is None or expected is None:
|
|
82
|
+
return actual == expected
|
|
83
|
+
|
|
84
|
+
# Handle dictionaries
|
|
85
|
+
if isinstance(expected, dict):
|
|
86
|
+
if not isinstance(actual, dict):
|
|
87
|
+
return False
|
|
88
|
+
if set(actual.keys()) != set(expected.keys()):
|
|
89
|
+
return False
|
|
90
|
+
return all(self._approx_compare(actual[k], expected[k]) for k in expected.keys())
|
|
91
|
+
|
|
92
|
+
# Handle sequences (lists, tuples, etc.) but not strings
|
|
93
|
+
if isinstance(expected, (list, tuple)) and not isinstance(expected, str):
|
|
94
|
+
# Check that actual is the same type (list vs tuple matters)
|
|
95
|
+
if type(actual) is not type(expected):
|
|
96
|
+
return False
|
|
97
|
+
if len(actual) != len(expected):
|
|
98
|
+
return False
|
|
99
|
+
return all(self._approx_compare(a, e) for a, e in zip(actual, expected))
|
|
100
|
+
|
|
101
|
+
# Handle numbers (float, int, complex)
|
|
102
|
+
if isinstance(expected, (float, int, complex)) and isinstance(
|
|
103
|
+
actual, (float, int, complex)
|
|
104
|
+
):
|
|
105
|
+
return self._is_close(actual, expected)
|
|
106
|
+
|
|
107
|
+
# For other types, use exact equality
|
|
108
|
+
return actual == expected
|
|
109
|
+
|
|
110
|
+
def _is_close(
|
|
111
|
+
self, actual: Union[float, int, complex], expected: Union[float, int, complex]
|
|
112
|
+
) -> bool:
|
|
113
|
+
"""Check if two numbers are close within tolerance.
|
|
114
|
+
|
|
115
|
+
Uses the formula: |actual - expected| <= max(rel * max(|actual|, |expected|), abs)
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
actual: The actual number
|
|
119
|
+
expected: The expected number
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
True if numbers are close, False otherwise
|
|
123
|
+
"""
|
|
124
|
+
# Handle infinities and NaN
|
|
125
|
+
if isinstance(actual, complex) or isinstance(expected, complex):
|
|
126
|
+
# For complex numbers, compare real and imaginary parts separately
|
|
127
|
+
if isinstance(actual, complex) and isinstance(expected, complex):
|
|
128
|
+
return self._is_close(actual.real, expected.real) and self._is_close(
|
|
129
|
+
actual.imag, expected.imag
|
|
130
|
+
)
|
|
131
|
+
# One is complex, the other is not
|
|
132
|
+
if isinstance(actual, complex):
|
|
133
|
+
return self._is_close(actual.real, expected) and abs(actual.imag) <= self.abs
|
|
134
|
+
else: # expected is complex
|
|
135
|
+
return self._is_close(actual, expected.real) and abs(expected.imag) <= self.abs
|
|
136
|
+
|
|
137
|
+
# Convert to float for comparison
|
|
138
|
+
actual_float = float(actual)
|
|
139
|
+
expected_float = float(expected)
|
|
140
|
+
|
|
141
|
+
# Handle special float values
|
|
142
|
+
if actual_float == expected_float:
|
|
143
|
+
# This handles infinities and zeros
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
# Check for NaN - NaN should never be equal to anything
|
|
147
|
+
import math
|
|
148
|
+
|
|
149
|
+
if math.isnan(actual_float) or math.isnan(expected_float):
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
# Check for infinities
|
|
153
|
+
if math.isinf(actual_float) or math.isinf(expected_float):
|
|
154
|
+
return actual_float == expected_float
|
|
155
|
+
|
|
156
|
+
# Calculate tolerance
|
|
157
|
+
abs_diff = abs(actual_float - expected_float)
|
|
158
|
+
tolerance = max(self.rel * max(abs(actual_float), abs(expected_float)), self.abs)
|
|
159
|
+
|
|
160
|
+
return abs_diff <= tolerance
|
rustest/{_cli.py → cli.py}
RENAMED
rustest/core.py
CHANGED
|
@@ -4,8 +4,8 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from collections.abc import Sequence
|
|
6
6
|
|
|
7
|
-
from . import
|
|
8
|
-
from .
|
|
7
|
+
from . import rust
|
|
8
|
+
from .reporting import RunReport
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def run(
|
|
@@ -17,5 +17,5 @@ def run(
|
|
|
17
17
|
) -> RunReport:
|
|
18
18
|
"""Execute tests and return a rich report."""
|
|
19
19
|
|
|
20
|
-
raw_report =
|
|
20
|
+
raw_report = rust.run(list(paths), pattern, workers, capture_output)
|
|
21
21
|
return RunReport.from_py(raw_report)
|
|
@@ -192,3 +192,159 @@ class MarkGenerator:
|
|
|
192
192
|
|
|
193
193
|
# Create a singleton instance
|
|
194
194
|
mark = MarkGenerator()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class ExceptionInfo:
|
|
198
|
+
"""Information about an exception caught by raises().
|
|
199
|
+
|
|
200
|
+
Attributes:
|
|
201
|
+
type: The exception type
|
|
202
|
+
value: The exception instance
|
|
203
|
+
traceback: The exception traceback
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
def __init__(
|
|
207
|
+
self, exc_type: type[BaseException], exc_value: BaseException, exc_tb: Any
|
|
208
|
+
) -> None:
|
|
209
|
+
super().__init__()
|
|
210
|
+
self.type = exc_type
|
|
211
|
+
self.value = exc_value
|
|
212
|
+
self.traceback = exc_tb
|
|
213
|
+
|
|
214
|
+
def __repr__(self) -> str:
|
|
215
|
+
return f"<ExceptionInfo {self.type.__name__}({self.value!r})>"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class RaisesContext:
|
|
219
|
+
"""Context manager for asserting that code raises a specific exception.
|
|
220
|
+
|
|
221
|
+
This mimics pytest.raises() behavior, supporting:
|
|
222
|
+
- Single or tuple of exception types
|
|
223
|
+
- Optional regex matching of exception messages
|
|
224
|
+
- Access to caught exception information
|
|
225
|
+
|
|
226
|
+
Usage:
|
|
227
|
+
with raises(ValueError):
|
|
228
|
+
int("not a number")
|
|
229
|
+
|
|
230
|
+
with raises(ValueError, match="invalid literal"):
|
|
231
|
+
int("not a number")
|
|
232
|
+
|
|
233
|
+
with raises((ValueError, TypeError)):
|
|
234
|
+
some_function()
|
|
235
|
+
|
|
236
|
+
# Access the caught exception
|
|
237
|
+
with raises(ValueError) as exc_info:
|
|
238
|
+
raise ValueError("oops")
|
|
239
|
+
assert "oops" in str(exc_info.value)
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
def __init__(
|
|
243
|
+
self,
|
|
244
|
+
exc_type: type[BaseException] | tuple[type[BaseException], ...],
|
|
245
|
+
*,
|
|
246
|
+
match: str | None = None,
|
|
247
|
+
) -> None:
|
|
248
|
+
super().__init__()
|
|
249
|
+
self.exc_type = exc_type
|
|
250
|
+
self.match_pattern = match
|
|
251
|
+
self.excinfo: ExceptionInfo | None = None
|
|
252
|
+
|
|
253
|
+
def __enter__(self) -> RaisesContext:
|
|
254
|
+
return self
|
|
255
|
+
|
|
256
|
+
def __exit__(
|
|
257
|
+
self,
|
|
258
|
+
exc_type: type[BaseException] | None,
|
|
259
|
+
exc_val: BaseException | None,
|
|
260
|
+
exc_tb: Any,
|
|
261
|
+
) -> bool:
|
|
262
|
+
# No exception was raised
|
|
263
|
+
if exc_type is None:
|
|
264
|
+
exc_name = self._format_exc_name()
|
|
265
|
+
msg = f"DID NOT RAISE {exc_name}"
|
|
266
|
+
raise AssertionError(msg)
|
|
267
|
+
|
|
268
|
+
# At this point, we know an exception was raised, so exc_val cannot be None
|
|
269
|
+
assert exc_val is not None, "exc_val must not be None when exc_type is not None"
|
|
270
|
+
|
|
271
|
+
# Check if the exception type matches
|
|
272
|
+
if not issubclass(exc_type, self.exc_type):
|
|
273
|
+
# Unexpected exception type - let it propagate
|
|
274
|
+
return False
|
|
275
|
+
|
|
276
|
+
# Store the exception information
|
|
277
|
+
self.excinfo = ExceptionInfo(exc_type, exc_val, exc_tb)
|
|
278
|
+
|
|
279
|
+
# Check if the message matches the pattern (if provided)
|
|
280
|
+
if self.match_pattern is not None:
|
|
281
|
+
import re
|
|
282
|
+
|
|
283
|
+
exc_message = str(exc_val)
|
|
284
|
+
if not re.search(self.match_pattern, exc_message):
|
|
285
|
+
msg = (
|
|
286
|
+
f"Pattern {self.match_pattern!r} does not match "
|
|
287
|
+
f"{exc_message!r}. Exception: {exc_type.__name__}: {exc_message}"
|
|
288
|
+
)
|
|
289
|
+
raise AssertionError(msg)
|
|
290
|
+
|
|
291
|
+
# Suppress the exception (it was expected)
|
|
292
|
+
return True
|
|
293
|
+
|
|
294
|
+
def _format_exc_name(self) -> str:
|
|
295
|
+
"""Format the expected exception name(s) for error messages."""
|
|
296
|
+
if isinstance(self.exc_type, tuple):
|
|
297
|
+
names = " or ".join(exc.__name__ for exc in self.exc_type)
|
|
298
|
+
return names
|
|
299
|
+
return self.exc_type.__name__
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def value(self) -> BaseException:
|
|
303
|
+
"""Access the caught exception value."""
|
|
304
|
+
if self.excinfo is None:
|
|
305
|
+
msg = "No exception was caught"
|
|
306
|
+
raise AttributeError(msg)
|
|
307
|
+
return self.excinfo.value
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def type(self) -> type[BaseException]:
|
|
311
|
+
"""Access the caught exception type."""
|
|
312
|
+
if self.excinfo is None:
|
|
313
|
+
msg = "No exception was caught"
|
|
314
|
+
raise AttributeError(msg)
|
|
315
|
+
return self.excinfo.type
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def raises(
|
|
319
|
+
exc_type: type[BaseException] | tuple[type[BaseException], ...],
|
|
320
|
+
*,
|
|
321
|
+
match: str | None = None,
|
|
322
|
+
) -> RaisesContext:
|
|
323
|
+
"""Assert that code raises a specific exception.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
exc_type: The expected exception type(s). Can be a single type or tuple of types.
|
|
327
|
+
match: Optional regex pattern to match against the exception message.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
A context manager that catches and validates the exception.
|
|
331
|
+
|
|
332
|
+
Raises:
|
|
333
|
+
AssertionError: If no exception is raised, or if the message doesn't match.
|
|
334
|
+
|
|
335
|
+
Usage:
|
|
336
|
+
with raises(ValueError):
|
|
337
|
+
int("not a number")
|
|
338
|
+
|
|
339
|
+
with raises(ValueError, match="invalid literal"):
|
|
340
|
+
int("not a number")
|
|
341
|
+
|
|
342
|
+
with raises((ValueError, TypeError)):
|
|
343
|
+
some_function()
|
|
344
|
+
|
|
345
|
+
# Access the caught exception
|
|
346
|
+
with raises(ValueError) as exc_info:
|
|
347
|
+
raise ValueError("oops")
|
|
348
|
+
assert "oops" in str(exc_info.value)
|
|
349
|
+
"""
|
|
350
|
+
return RaisesContext(exc_type, match=match)
|
rustest/py.typed
ADDED
|
File without changes
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from collections.abc import Iterable
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
|
|
8
|
-
from . import
|
|
8
|
+
from . import rust
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
@dataclass(slots=True)
|
|
@@ -23,7 +23,7 @@ class TestResult:
|
|
|
23
23
|
stderr: str | None
|
|
24
24
|
|
|
25
25
|
@classmethod
|
|
26
|
-
def from_py(cls, result:
|
|
26
|
+
def from_py(cls, result: rust.PyTestResult) -> "TestResult":
|
|
27
27
|
return cls(
|
|
28
28
|
name=result.name,
|
|
29
29
|
path=result.path,
|
|
@@ -47,7 +47,7 @@ class RunReport:
|
|
|
47
47
|
results: tuple[TestResult, ...]
|
|
48
48
|
|
|
49
49
|
@classmethod
|
|
50
|
-
def from_py(cls, report:
|
|
50
|
+
def from_py(cls, report: rust.PyRunReport) -> "RunReport":
|
|
51
51
|
return cls(
|
|
52
52
|
total=report.total,
|
|
53
53
|
passed=report.passed,
|
|
Binary file
|
rustest/{_rust.py → rust.py}
RENAMED
|
@@ -19,5 +19,5 @@ def run(
|
|
|
19
19
|
"""Placeholder implementation that mirrors the extension signature."""
|
|
20
20
|
|
|
21
21
|
raise NotImplementedError(
|
|
22
|
-
"The rustest native extension is unavailable. Tests must patch rustest.
|
|
22
|
+
"The rustest native extension is unavailable. Tests must patch rustest.rust.run."
|
|
23
23
|
)
|
rustest/{_rust.pyi → rust.pyi}
RENAMED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
"""Type stubs for the Rust extension module."""
|
|
1
|
+
"""Type stubs for the rustest Rust extension module."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from
|
|
5
|
+
from typing import Sequence
|
|
6
6
|
|
|
7
7
|
class PyTestResult:
|
|
8
|
-
"""
|
|
8
|
+
"""Individual test result from the Rust extension."""
|
|
9
9
|
|
|
10
10
|
name: str
|
|
11
11
|
path: str
|
|
@@ -16,20 +16,20 @@ class PyTestResult:
|
|
|
16
16
|
stderr: str | None
|
|
17
17
|
|
|
18
18
|
class PyRunReport:
|
|
19
|
-
"""
|
|
19
|
+
"""Test run report from the Rust extension."""
|
|
20
20
|
|
|
21
21
|
total: int
|
|
22
22
|
passed: int
|
|
23
23
|
failed: int
|
|
24
24
|
skipped: int
|
|
25
25
|
duration: float
|
|
26
|
-
results:
|
|
26
|
+
results: list[PyTestResult]
|
|
27
27
|
|
|
28
28
|
def run(
|
|
29
|
-
paths:
|
|
29
|
+
paths: Sequence[str],
|
|
30
30
|
pattern: str | None,
|
|
31
31
|
workers: int | None,
|
|
32
32
|
capture_output: bool,
|
|
33
33
|
) -> PyRunReport:
|
|
34
|
-
"""
|
|
34
|
+
"""Execute tests and return a report."""
|
|
35
35
|
...
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rustest
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Intended Audience :: Developers
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.14
|
|
13
13
|
Classifier: Programming Language :: Rust
|
|
14
14
|
Classifier: Topic :: Software Development :: Testing
|
|
15
|
-
Requires-Dist: typing-extensions>=4.
|
|
15
|
+
Requires-Dist: typing-extensions>=4.15
|
|
16
16
|
Requires-Dist: basedpyright>=1.19 ; extra == 'dev'
|
|
17
17
|
Requires-Dist: maturin>=1.4,<2 ; extra == 'dev'
|
|
18
18
|
Requires-Dist: poethepoet>=0.22 ; extra == 'dev'
|
|
@@ -30,40 +30,55 @@ Project-URL: Repository, https://github.com/Apex-Engineers-Inc/rustest
|
|
|
30
30
|
|
|
31
31
|
# rustest
|
|
32
32
|
|
|
33
|
-
Rustest (pronounced like Russ-Test) is a Rust-powered test runner that aims to provide the most common pytest ergonomics with a focus on raw performance. Get
|
|
33
|
+
Rustest (pronounced like Russ-Test) is a Rust-powered test runner that aims to provide the most common pytest ergonomics with a focus on raw performance. Get **~2x faster** test execution with familiar syntax and minimal setup.
|
|
34
34
|
|
|
35
35
|
## Why rustest?
|
|
36
36
|
|
|
37
|
-
- 🚀 **
|
|
37
|
+
- 🚀 **About 2x faster** than pytest on the rustest integration test suite
|
|
38
38
|
- ✅ Familiar `@fixture`, `@parametrize`, `@skip`, and `@mark` decorators
|
|
39
39
|
- 🔍 Automatic test discovery (`test_*.py` and `*_test.py` files)
|
|
40
40
|
- 🎯 Simple, clean API—if you know pytest, you already know rustest
|
|
41
|
+
- 🧮 Built-in `approx()` helper for tolerant numeric comparisons across scalars, collections, and complex numbers
|
|
42
|
+
- 🪤 `raises()` context manager for precise exception assertions with optional message matching
|
|
41
43
|
- 📦 Easy installation with pip or uv
|
|
42
|
-
- ⚡
|
|
44
|
+
- ⚡ Low-overhead execution keeps small suites feeling instant
|
|
43
45
|
|
|
44
46
|
## Performance
|
|
45
47
|
|
|
46
|
-
Rustest is designed for speed. Our benchmarks
|
|
48
|
+
Rustest is designed for speed. Our latest benchmarks on the rustest integration suite (~200 tests) show a consistent **2.1x wall-clock speedup** over pytest:
|
|
47
49
|
|
|
48
|
-
| Test Runner |
|
|
49
|
-
|
|
50
|
-
| pytest | 0.
|
|
51
|
-
| rustest | 0.
|
|
50
|
+
| Test Runner | Reported Runtime† | Wall Clock‡ | Speedup (wall) | Command |
|
|
51
|
+
|-------------|------------------|-------------|----------------|---------|
|
|
52
|
+
| pytest | 0.43–0.59s | 1.33–1.59s | 1.0x (baseline) | `pytest tests/ examples/tests/ -q`
|
|
53
|
+
| rustest | 0.003s | 0.69–0.70s | **~2.1x faster** | `python -m rustest tests/ examples/tests/`§
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
- **pytest**: 196 passed, 5 skipped in 0.39s
|
|
55
|
-
- **rustest**: 194 passed, 5 skipped in 0.005s
|
|
55
|
+
### Large parametrized stress test
|
|
56
56
|
|
|
57
|
-
**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
We also profiled an extreme case with **10,000 parametrized invocations** to ensure rustest scales on synthetic but heavy workloads. The test lives in [`benchmarks/test_large_parametrize.py`](benchmarks/test_large_parametrize.py) and simply asserts `value + value == 2 * value` across every case. Running the module on its own shows a dramatic gap:
|
|
58
|
+
|
|
59
|
+
| Test Runner | Avg. Wall Clock (3 runs) | Speedup | Command |
|
|
60
|
+
|-------------|--------------------------|---------|---------|
|
|
61
|
+
| pytest | 9.72s | 1.0x | `pytest benchmarks/test_large_parametrize.py -q`§
|
|
62
|
+
| rustest | 0.41s | **~24x** | `python -m rustest benchmarks/test_large_parametrize.py`§
|
|
63
|
+
|
|
64
|
+
† pytest and rustest both report only active test execution time; rustest's figure omits Python interpreter start-up overhead.
|
|
65
|
+
|
|
66
|
+
‡ Integration-suite wall-clock timing measured with the shell `time` builtin across two consecutive runs in the same environment.
|
|
67
|
+
|
|
68
|
+
§ Commands executed with `PYTHONPATH=python` in this repository checkout to exercise the local sources. Pytest relies on a small compatibility shim in [`benchmarks/conftest.py`](benchmarks/conftest.py) so it understands the rustest-style decorators. Large-parametrization timings come from averaging three `time.perf_counter()` measurements with output suppressed via `subprocess.DEVNULL`.
|
|
69
|
+
|
|
70
|
+
Rustest counts parametrized cases slightly differently than pytest, so you will see 199 executed cases vs. pytest's 201 discoveries on the same suite—the reported pass/skip counts still align.
|
|
71
|
+
|
|
72
|
+
**Why is rustest faster?**
|
|
73
|
+
- **Near-zero startup time**: Native Rust binary minimizes overhead before Python code starts running.
|
|
74
|
+
- **Rust-native test discovery**: Minimal imports until test execution keeps collection quick.
|
|
75
|
+
- **Optimized fixture resolution**: Efficient dependency graph resolution reduces per-test work.
|
|
76
|
+
- **Lean orchestration**: Rust handles scheduling and reporting so the Python interpreter focuses on running test bodies.
|
|
62
77
|
|
|
63
78
|
**Real-world impact:**
|
|
64
|
-
- **200 tests
|
|
65
|
-
- **1,000 tests
|
|
66
|
-
- **10,000 tests
|
|
79
|
+
- **200 tests** (this repository): 1.46s → 0.70s (average wall-clock, ~0.76s saved per run)
|
|
80
|
+
- **1,000 tests** (projected): ~7.3s → ~3.4s assuming similar scaling
|
|
81
|
+
- **10,000 tests** (projected): ~73s → ~34s—minutes saved across CI runs
|
|
67
82
|
|
|
68
83
|
See [BENCHMARKS.md](BENCHMARKS.md) for detailed performance analysis and methodology.
|
|
69
84
|
|
|
@@ -91,14 +106,14 @@ If you want to contribute to rustest, see [DEVELOPMENT.md](DEVELOPMENT.md) for s
|
|
|
91
106
|
Create a file `test_math.py`:
|
|
92
107
|
|
|
93
108
|
```python
|
|
94
|
-
from rustest import fixture, parametrize, mark
|
|
109
|
+
from rustest import fixture, parametrize, mark, approx, raises
|
|
95
110
|
|
|
96
111
|
@fixture
|
|
97
112
|
def numbers() -> list[int]:
|
|
98
113
|
return [1, 2, 3, 4, 5]
|
|
99
114
|
|
|
100
115
|
def test_sum(numbers: list[int]) -> None:
|
|
101
|
-
assert sum(numbers) == 15
|
|
116
|
+
assert sum(numbers) == approx(15)
|
|
102
117
|
|
|
103
118
|
@parametrize("value,expected", [(2, 4), (3, 9), (4, 16)])
|
|
104
119
|
def test_square(value: int, expected: int) -> None:
|
|
@@ -109,6 +124,10 @@ def test_expensive_operation() -> None:
|
|
|
109
124
|
# This test is marked as slow for filtering
|
|
110
125
|
result = sum(range(1000000))
|
|
111
126
|
assert result > 0
|
|
127
|
+
|
|
128
|
+
def test_division_by_zero_is_reported() -> None:
|
|
129
|
+
with raises(ZeroDivisionError, match="division by zero"):
|
|
130
|
+
1 / 0
|
|
112
131
|
```
|
|
113
132
|
|
|
114
133
|
### 2. Run Your Tests
|
|
@@ -229,6 +248,21 @@ def test_api_configuration(api_client: dict) -> None:
|
|
|
229
248
|
assert api_client["timeout"] == 30
|
|
230
249
|
```
|
|
231
250
|
|
|
251
|
+
#### Assertion Helpers
|
|
252
|
+
|
|
253
|
+
Rustest ships helpers for expressive assertions:
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
from rustest import approx, raises
|
|
257
|
+
|
|
258
|
+
def test_nearly_equal() -> None:
|
|
259
|
+
assert 0.1 + 0.2 == approx(0.3, rel=1e-9)
|
|
260
|
+
|
|
261
|
+
def test_raises_with_message() -> None:
|
|
262
|
+
with raises(ValueError, match="invalid configuration"):
|
|
263
|
+
raise ValueError("invalid configuration")
|
|
264
|
+
```
|
|
265
|
+
|
|
232
266
|
#### Yield Fixtures with Setup/Teardown
|
|
233
267
|
|
|
234
268
|
Fixtures can use `yield` to perform cleanup after tests:
|
|
@@ -407,6 +441,142 @@ def test_full_workflow() -> None:
|
|
|
407
441
|
pass
|
|
408
442
|
```
|
|
409
443
|
|
|
444
|
+
#### Test Classes
|
|
445
|
+
|
|
446
|
+
Rustest supports pytest-style test classes, allowing you to organize related tests together:
|
|
447
|
+
|
|
448
|
+
```python
|
|
449
|
+
from rustest import fixture, parametrize, mark
|
|
450
|
+
|
|
451
|
+
class TestBasicMath:
|
|
452
|
+
"""Group related tests in a class."""
|
|
453
|
+
|
|
454
|
+
def test_addition(self):
|
|
455
|
+
assert 1 + 1 == 2
|
|
456
|
+
|
|
457
|
+
def test_subtraction(self):
|
|
458
|
+
assert 5 - 3 == 2
|
|
459
|
+
|
|
460
|
+
def test_multiplication(self):
|
|
461
|
+
assert 3 * 4 == 12
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
**Using Fixtures in Test Classes:**
|
|
465
|
+
|
|
466
|
+
Test methods can inject fixtures just like standalone test functions:
|
|
467
|
+
|
|
468
|
+
```python
|
|
469
|
+
from rustest import fixture
|
|
470
|
+
|
|
471
|
+
@fixture
|
|
472
|
+
def calculator():
|
|
473
|
+
return {"add": lambda x, y: x + y, "multiply": lambda x, y: x * y}
|
|
474
|
+
|
|
475
|
+
class TestCalculator:
|
|
476
|
+
"""Test class using fixtures."""
|
|
477
|
+
|
|
478
|
+
def test_addition(self, calculator):
|
|
479
|
+
assert calculator["add"](2, 3) == 5
|
|
480
|
+
|
|
481
|
+
def test_multiplication(self, calculator):
|
|
482
|
+
assert calculator["multiply"](4, 5) == 20
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
**Class-Scoped Fixtures:**
|
|
486
|
+
|
|
487
|
+
Class-scoped fixtures are shared across all test methods in the same class, perfect for expensive setup operations:
|
|
488
|
+
|
|
489
|
+
```python
|
|
490
|
+
from rustest import fixture
|
|
491
|
+
|
|
492
|
+
@fixture(scope="class")
|
|
493
|
+
def database():
|
|
494
|
+
"""Expensive setup shared across all tests in a class."""
|
|
495
|
+
db = {"connection": "db://test", "data": []}
|
|
496
|
+
return db
|
|
497
|
+
|
|
498
|
+
class TestDatabase:
|
|
499
|
+
"""All tests share the same database fixture instance."""
|
|
500
|
+
|
|
501
|
+
def test_connection(self, database):
|
|
502
|
+
assert database["connection"] == "db://test"
|
|
503
|
+
|
|
504
|
+
def test_add_data(self, database):
|
|
505
|
+
database["data"].append("item1")
|
|
506
|
+
assert len(database["data"]) >= 1
|
|
507
|
+
|
|
508
|
+
def test_data_persists(self, database):
|
|
509
|
+
# Same database instance, so previous test's data is still there
|
|
510
|
+
assert len(database["data"]) >= 1
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
**Fixture Methods Within Test Classes:**
|
|
514
|
+
|
|
515
|
+
You can define fixtures as methods inside test classes, providing class-specific setup:
|
|
516
|
+
|
|
517
|
+
```python
|
|
518
|
+
from rustest import fixture
|
|
519
|
+
|
|
520
|
+
class TestWithFixtureMethod:
|
|
521
|
+
"""Test class with its own fixture methods."""
|
|
522
|
+
|
|
523
|
+
@fixture(scope="class")
|
|
524
|
+
def class_resource(self):
|
|
525
|
+
"""Fixture method shared across tests in this class."""
|
|
526
|
+
resource = {"value": 42, "name": "test_resource"}
|
|
527
|
+
yield resource
|
|
528
|
+
# Teardown happens after all tests in class
|
|
529
|
+
resource["closed"] = True
|
|
530
|
+
|
|
531
|
+
@fixture
|
|
532
|
+
def per_test_data(self, class_resource):
|
|
533
|
+
"""Fixture method that depends on another fixture."""
|
|
534
|
+
return {"id": id(self), "resource": class_resource}
|
|
535
|
+
|
|
536
|
+
def test_uses_class_resource(self, class_resource):
|
|
537
|
+
assert class_resource["value"] == 42
|
|
538
|
+
|
|
539
|
+
def test_uses_per_test_data(self, per_test_data):
|
|
540
|
+
assert "resource" in per_test_data
|
|
541
|
+
assert per_test_data["resource"]["value"] == 42
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
**Class Variables and Instance Variables:**
|
|
545
|
+
|
|
546
|
+
Test classes can use class variables for shared state and instance variables for per-test isolation:
|
|
547
|
+
|
|
548
|
+
```python
|
|
549
|
+
class TestWithVariables:
|
|
550
|
+
"""Test class with class and instance variables."""
|
|
551
|
+
|
|
552
|
+
class_variable = "shared_data" # Shared across all tests
|
|
553
|
+
|
|
554
|
+
def test_class_variable(self):
|
|
555
|
+
# Access class variable
|
|
556
|
+
assert self.class_variable == "shared_data"
|
|
557
|
+
assert TestWithVariables.class_variable == "shared_data"
|
|
558
|
+
|
|
559
|
+
def test_instance_variable(self):
|
|
560
|
+
# Each test gets a fresh instance
|
|
561
|
+
self.instance_var = "test_specific"
|
|
562
|
+
assert self.instance_var == "test_specific"
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
**Parametrized Test Methods:**
|
|
566
|
+
|
|
567
|
+
Use `@parametrize` on class methods just like regular test functions:
|
|
568
|
+
|
|
569
|
+
```python
|
|
570
|
+
from rustest import parametrize
|
|
571
|
+
|
|
572
|
+
class TestParametrized:
|
|
573
|
+
"""Test class with parametrized methods."""
|
|
574
|
+
|
|
575
|
+
@parametrize("value,expected", [(2, 4), (3, 9), (4, 16)])
|
|
576
|
+
def test_square(self, value, expected):
|
|
577
|
+
assert value ** 2 == expected
|
|
578
|
+
```
|
|
579
|
+
|
|
410
580
|
### Test Output
|
|
411
581
|
|
|
412
582
|
When you run rustest, you'll see clean, informative output:
|
|
@@ -436,13 +606,14 @@ Rustest aims to provide the most commonly-used pytest features with dramatically
|
|
|
436
606
|
| **Core Test Discovery** |
|
|
437
607
|
| `test_*.py` / `*_test.py` files | ✅ | ✅ | Rustest uses Rust for dramatically faster discovery |
|
|
438
608
|
| Test function detection (`test_*`) | ✅ | ✅ | |
|
|
439
|
-
| Test class detection (`Test*`) | ✅ | ✅ |
|
|
609
|
+
| Test class detection (`Test*`) | ✅ | ✅ | Full pytest-style class support with fixture methods |
|
|
440
610
|
| Pattern-based filtering | ✅ | ✅ | `-k` pattern matching |
|
|
441
611
|
| **Fixtures** |
|
|
442
612
|
| `@fixture` decorator | ✅ | ✅ | Rust-based dependency resolution |
|
|
443
613
|
| Fixture dependency injection | ✅ | ✅ | Much faster in rustest |
|
|
444
614
|
| Fixture scopes (function/class/module/session) | ✅ | ✅ | Full support for all scopes |
|
|
445
615
|
| Yield fixtures (setup/teardown) | ✅ | ✅ | Full support with cleanup |
|
|
616
|
+
| Fixture methods within test classes | ✅ | ✅ | Define fixtures as class methods |
|
|
446
617
|
| Fixture parametrization | ✅ | 🚧 | Planned |
|
|
447
618
|
| **Parametrization** |
|
|
448
619
|
| `@parametrize` decorator | ✅ | ✅ | Full support with custom IDs |
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
rustest-0.3.0.dist-info/METADATA,sha256=0A5bldOq84__CL4XWiYg5vnQdoHBiO-OmcVTXrpIcQo,20349
|
|
2
|
+
rustest-0.3.0.dist-info/WHEEL,sha256=nw13728gRokCivwI6SCanennWBgDOflh2lHvovMwAi8,104
|
|
3
|
+
rustest-0.3.0.dist-info/entry_points.txt,sha256=7fUa3LO8vudQ4dKG1sTRaDnxcMdBSZsWs9EyuxFQ7Lk,48
|
|
4
|
+
rustest-0.3.0.dist-info/licenses/LICENSE,sha256=s64ibUGtb6jEDBsYuxUFtMr_c4PaqYP-vj3YY6QtTGw,1075
|
|
5
|
+
rustest/__init__.py,sha256=0CkHfrmIjpGw6DMu2VcZLOUpBVtdwsN-aitn-RzOglo,514
|
|
6
|
+
rustest/__main__.py,sha256=bBvo5gsSluUzlDTDvn5bP_gZZEXMwJQZMqVA5W1M1v8,178
|
|
7
|
+
rustest/approx.py,sha256=MKmuorBBHqpH0h0QaIMVjbm3-mXJ0E90limEgSHHVfw,5744
|
|
8
|
+
rustest/cli.py,sha256=fO6D09b7Se6aqC8EHwA0jCy2tokrgX9fYcNhMni_5Xs,8699
|
|
9
|
+
rustest/core.py,sha256=8gZ1VsFtc3rUwyhwREm74e03E-sB8iNp4sJb2w8hUBk,485
|
|
10
|
+
rustest/decorators.py,sha256=nijzNG8NQXZd8kEfMjhSB-85gLoIjaIMxwZQNUNWgrE,11373
|
|
11
|
+
rustest/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
rustest/reporting.py,sha256=3-R8aljv2ZbmjOa9Q9KZeCPsMaitm8nZ96LoJS_NnUQ,1623
|
|
13
|
+
rustest/rust.cpython-312-darwin.so,sha256=yDbdByHqH9DPDzHTxnRO8zLRTtEUXMpwMWQN8uOPSNE,1466544
|
|
14
|
+
rustest/rust.py,sha256=tCIvjYd06VxoT_rKvv2o8CpXW_pFNua5VgcRDjLgU78,659
|
|
15
|
+
rustest/rust.pyi,sha256=lezCHLSIedCPQ1fc6Bd5Vxc34AhI8IOgIFNrNLRxX7Y,706
|
|
16
|
+
rustest-0.3.0.dist-info/RECORD,,
|
|
Binary file
|
rustest-0.1.0.dist-info/RECORD
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
rustest-0.1.0.dist-info/METADATA,sha256=MxCLrEAs8gtwnesn3cpHVwnD-lzCdw7A03a2aTmxrvI,13963
|
|
2
|
-
rustest-0.1.0.dist-info/WHEEL,sha256=nw13728gRokCivwI6SCanennWBgDOflh2lHvovMwAi8,104
|
|
3
|
-
rustest-0.1.0.dist-info/entry_points.txt,sha256=7fUa3LO8vudQ4dKG1sTRaDnxcMdBSZsWs9EyuxFQ7Lk,48
|
|
4
|
-
rustest-0.1.0.dist-info/licenses/LICENSE,sha256=s64ibUGtb6jEDBsYuxUFtMr_c4PaqYP-vj3YY6QtTGw,1075
|
|
5
|
-
rustest/__init__.py,sha256=K7MyGPARnn97RSjk-E_x_1KjqD49RK4fII_sZ_0rdcc,439
|
|
6
|
-
rustest/__main__.py,sha256=nqdz6DhrDze715SXxtzAYV2sie3CPoy7IvWCdcyHJEM,179
|
|
7
|
-
rustest/_cli.py,sha256=kq9LAwHaJmZ-gnAlTsz7Ov8r1fiDvNoLf4hEI3sxhng,8700
|
|
8
|
-
rustest/_decorators.py,sha256=EFcAmZfThiyj_J5l6fEx7Ix9LroXIBrOwa8QuxltNLI,6561
|
|
9
|
-
rustest/_reporting.py,sha256=6nVcccX1dgEBW72wCOeOIl5I-OE-ukjJD0VQs56pwjo,1626
|
|
10
|
-
rustest/_rust.cpython-312-darwin.so,sha256=8UZE-rrDiDzt935EmW22CWWSjS5dtNQisjKVzAKglVg,1466832
|
|
11
|
-
rustest/_rust.py,sha256=k3nXhGiehOVY_S6w28rIdrc0CEc3gFLgwWVOEMcPOZo,660
|
|
12
|
-
rustest/_rust.pyi,sha256=fDFLX0qj4G_bV1sHmTtRPI26grTDG_LFzPFEqp5vFGk,671
|
|
13
|
-
rustest/core.py,sha256=xmBUpuPs0r0HQthc9J5dCQYkZnXqfxqIfSGkHeoqQS4,488
|
|
14
|
-
rustest-0.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|