rustest 0.5.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.
Potentially problematic release.
This version of rustest might be problematic. Click here for more details.
- rustest/__init__.py +28 -0
- rustest/__main__.py +10 -0
- rustest/approx.py +160 -0
- rustest/cli.py +273 -0
- rustest/core.py +38 -0
- rustest/decorators.py +423 -0
- rustest/py.typed +0 -0
- rustest/reporting.py +63 -0
- rustest/rust.cp312-win_amd64.pyd +0 -0
- rustest/rust.py +23 -0
- rustest/rust.pyi +37 -0
- rustest-0.5.0.dist-info/METADATA +208 -0
- rustest-0.5.0.dist-info/RECORD +16 -0
- rustest-0.5.0.dist-info/WHEEL +4 -0
- rustest-0.5.0.dist-info/entry_points.txt +2 -0
- rustest-0.5.0.dist-info/licenses/LICENSE +21 -0
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
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
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Command line interface helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from .reporting import RunReport, TestResult
|
|
9
|
+
from .core import run
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ANSI color codes
|
|
13
|
+
class _ColorsNamespace:
|
|
14
|
+
"""Namespace for ANSI color codes."""
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None: # pyright: ignore[reportMissingSuperCall]
|
|
17
|
+
self.green = "\033[92m"
|
|
18
|
+
self.red = "\033[91m"
|
|
19
|
+
self.yellow = "\033[93m"
|
|
20
|
+
self.cyan = "\033[96m"
|
|
21
|
+
self.bold = "\033[1m"
|
|
22
|
+
self.dim = "\033[2m"
|
|
23
|
+
self.reset = "\033[0m"
|
|
24
|
+
|
|
25
|
+
def disable(self) -> None:
|
|
26
|
+
"""Disable all colors."""
|
|
27
|
+
self.green = ""
|
|
28
|
+
self.red = ""
|
|
29
|
+
self.yellow = ""
|
|
30
|
+
self.cyan = ""
|
|
31
|
+
self.bold = ""
|
|
32
|
+
self.dim = ""
|
|
33
|
+
self.reset = ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
Colors = _ColorsNamespace()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
40
|
+
parser = argparse.ArgumentParser(
|
|
41
|
+
prog="rustest",
|
|
42
|
+
description="Run Python tests at blazing speed with a Rust powered core.",
|
|
43
|
+
)
|
|
44
|
+
_ = parser.add_argument(
|
|
45
|
+
"paths",
|
|
46
|
+
nargs="*",
|
|
47
|
+
default=(".",),
|
|
48
|
+
help="Files or directories to collect tests from.",
|
|
49
|
+
)
|
|
50
|
+
_ = parser.add_argument(
|
|
51
|
+
"-k",
|
|
52
|
+
"--pattern",
|
|
53
|
+
help="Substring to filter tests by (case insensitive).",
|
|
54
|
+
)
|
|
55
|
+
_ = parser.add_argument(
|
|
56
|
+
"-m",
|
|
57
|
+
"--marks",
|
|
58
|
+
dest="mark_expr",
|
|
59
|
+
help='Run tests matching the given mark expression (e.g., "slow", "not slow", "slow and integration").',
|
|
60
|
+
)
|
|
61
|
+
_ = parser.add_argument(
|
|
62
|
+
"-n",
|
|
63
|
+
"--workers",
|
|
64
|
+
type=int,
|
|
65
|
+
help="Number of worker slots to use (experimental).",
|
|
66
|
+
)
|
|
67
|
+
_ = parser.add_argument(
|
|
68
|
+
"--no-capture",
|
|
69
|
+
dest="capture_output",
|
|
70
|
+
action="store_false",
|
|
71
|
+
help="Do not capture stdout/stderr during test execution.",
|
|
72
|
+
)
|
|
73
|
+
_ = parser.add_argument(
|
|
74
|
+
"-v",
|
|
75
|
+
"--verbose",
|
|
76
|
+
action="store_true",
|
|
77
|
+
help="Show verbose output with hierarchical test structure.",
|
|
78
|
+
)
|
|
79
|
+
_ = parser.add_argument(
|
|
80
|
+
"--ascii",
|
|
81
|
+
action="store_true",
|
|
82
|
+
help="Use ASCII characters instead of Unicode symbols for output.",
|
|
83
|
+
)
|
|
84
|
+
_ = parser.add_argument(
|
|
85
|
+
"--no-color",
|
|
86
|
+
dest="color",
|
|
87
|
+
action="store_false",
|
|
88
|
+
help="Disable colored output.",
|
|
89
|
+
)
|
|
90
|
+
_ = parser.add_argument(
|
|
91
|
+
"--no-codeblocks",
|
|
92
|
+
dest="enable_codeblocks",
|
|
93
|
+
action="store_false",
|
|
94
|
+
help="Disable code block tests from markdown files.",
|
|
95
|
+
)
|
|
96
|
+
_ = parser.set_defaults(capture_output=True, color=True, enable_codeblocks=True)
|
|
97
|
+
return parser
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
101
|
+
parser = build_parser()
|
|
102
|
+
args = parser.parse_args(argv)
|
|
103
|
+
|
|
104
|
+
# Disable colors if requested
|
|
105
|
+
if not args.color:
|
|
106
|
+
Colors.disable()
|
|
107
|
+
|
|
108
|
+
report = run(
|
|
109
|
+
paths=list(args.paths),
|
|
110
|
+
pattern=args.pattern,
|
|
111
|
+
mark_expr=args.mark_expr,
|
|
112
|
+
workers=args.workers,
|
|
113
|
+
capture_output=args.capture_output,
|
|
114
|
+
enable_codeblocks=args.enable_codeblocks,
|
|
115
|
+
)
|
|
116
|
+
_print_report(report, verbose=args.verbose, ascii_mode=args.ascii)
|
|
117
|
+
return 0 if report.failed == 0 else 1
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _print_report(report: RunReport, verbose: bool = False, ascii_mode: bool = False) -> None:
|
|
121
|
+
"""Print test report with configurable output format.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
report: The test run report
|
|
125
|
+
verbose: If True, show hierarchical verbose output (vitest-style)
|
|
126
|
+
ascii_mode: If True, use ASCII characters instead of Unicode symbols
|
|
127
|
+
"""
|
|
128
|
+
if verbose:
|
|
129
|
+
_print_verbose_report(report, ascii_mode)
|
|
130
|
+
else:
|
|
131
|
+
_print_default_report(report, ascii_mode)
|
|
132
|
+
|
|
133
|
+
# Print summary line with colors
|
|
134
|
+
passed_str = (
|
|
135
|
+
f"{Colors.green}{report.passed} passed{Colors.reset}"
|
|
136
|
+
if report.passed > 0
|
|
137
|
+
else f"{report.passed} passed"
|
|
138
|
+
)
|
|
139
|
+
failed_str = (
|
|
140
|
+
f"{Colors.red}{report.failed} failed{Colors.reset}"
|
|
141
|
+
if report.failed > 0
|
|
142
|
+
else f"{report.failed} failed"
|
|
143
|
+
)
|
|
144
|
+
skipped_str = (
|
|
145
|
+
f"{Colors.yellow}{report.skipped} skipped{Colors.reset}"
|
|
146
|
+
if report.skipped > 0
|
|
147
|
+
else f"{report.skipped} skipped"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
summary = (
|
|
151
|
+
f"\n{Colors.bold}{report.total} tests:{Colors.reset} "
|
|
152
|
+
f"{passed_str}, "
|
|
153
|
+
f"{failed_str}, "
|
|
154
|
+
f"{skipped_str} in {Colors.dim}{report.duration:.3f}s{Colors.reset}"
|
|
155
|
+
)
|
|
156
|
+
print(summary)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _print_default_report(report: RunReport, ascii_mode: bool) -> None:
|
|
160
|
+
"""Print pytest-style progress indicators followed by failure details."""
|
|
161
|
+
# Define symbols
|
|
162
|
+
if ascii_mode:
|
|
163
|
+
# pytest-style: . (pass), F (fail), s (skip)
|
|
164
|
+
pass_symbol = "."
|
|
165
|
+
fail_symbol = "F"
|
|
166
|
+
skip_symbol = "s"
|
|
167
|
+
else:
|
|
168
|
+
# Unicode symbols (no spaces, with colors)
|
|
169
|
+
pass_symbol = f"{Colors.green}✓{Colors.reset}"
|
|
170
|
+
fail_symbol = f"{Colors.red}✗{Colors.reset}"
|
|
171
|
+
skip_symbol = f"{Colors.yellow}⊘{Colors.reset}"
|
|
172
|
+
|
|
173
|
+
# Print progress indicators
|
|
174
|
+
for result in report.results:
|
|
175
|
+
if result.status == "passed":
|
|
176
|
+
print(pass_symbol, end="")
|
|
177
|
+
elif result.status == "failed":
|
|
178
|
+
print(fail_symbol, end="")
|
|
179
|
+
elif result.status == "skipped":
|
|
180
|
+
print(skip_symbol, end="")
|
|
181
|
+
print() # Newline after progress indicators
|
|
182
|
+
|
|
183
|
+
# Print failure details
|
|
184
|
+
failures = [r for r in report.results if r.status == "failed"]
|
|
185
|
+
if failures:
|
|
186
|
+
print(f"\n{Colors.red}{'=' * 70}")
|
|
187
|
+
print(f"{Colors.bold}FAILURES{Colors.reset}")
|
|
188
|
+
print(f"{Colors.red}{'=' * 70}{Colors.reset}")
|
|
189
|
+
for result in failures:
|
|
190
|
+
print(
|
|
191
|
+
f"\n{Colors.bold}{result.name}{Colors.reset} ({Colors.cyan}{result.path}{Colors.reset})"
|
|
192
|
+
)
|
|
193
|
+
print(f"{Colors.red}{'-' * 70}{Colors.reset}")
|
|
194
|
+
if result.message:
|
|
195
|
+
print(result.message.rstrip())
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _print_verbose_report(report: RunReport, ascii_mode: bool) -> None:
|
|
199
|
+
"""Print vitest-style hierarchical output with nesting and timing."""
|
|
200
|
+
# Define symbols
|
|
201
|
+
if ascii_mode:
|
|
202
|
+
pass_symbol = "PASS"
|
|
203
|
+
fail_symbol = "FAIL"
|
|
204
|
+
skip_symbol = "SKIP"
|
|
205
|
+
else:
|
|
206
|
+
pass_symbol = f"{Colors.green}✓{Colors.reset}"
|
|
207
|
+
fail_symbol = f"{Colors.red}✗{Colors.reset}"
|
|
208
|
+
skip_symbol = f"{Colors.yellow}⊘{Colors.reset}"
|
|
209
|
+
|
|
210
|
+
# Group tests by file path and organize hierarchically
|
|
211
|
+
from collections import defaultdict
|
|
212
|
+
|
|
213
|
+
tests_by_file: dict[str, list[TestResult]] = defaultdict(list)
|
|
214
|
+
for result in report.results:
|
|
215
|
+
tests_by_file[result.path].append(result)
|
|
216
|
+
|
|
217
|
+
# Print hierarchical structure
|
|
218
|
+
for file_path in sorted(tests_by_file.keys()):
|
|
219
|
+
print(f"\n{Colors.bold}{file_path}{Colors.reset}")
|
|
220
|
+
|
|
221
|
+
# Group tests by class within this file
|
|
222
|
+
tests_by_class: dict[str | None, list[tuple[TestResult, str | None]]] = defaultdict(list)
|
|
223
|
+
for result in tests_by_file[file_path]:
|
|
224
|
+
# Parse test name to extract class if present
|
|
225
|
+
# Format can be: "test_name" or "ClassName.test_name" or "module::Class::test"
|
|
226
|
+
class_name: str | None
|
|
227
|
+
if "::" in result.name:
|
|
228
|
+
parts = result.name.split("::")
|
|
229
|
+
class_name = "::".join(parts[:-1]) if len(parts) > 1 else None
|
|
230
|
+
elif "." in result.name:
|
|
231
|
+
parts = result.name.split(".")
|
|
232
|
+
class_name = parts[0] if len(parts) > 1 else None
|
|
233
|
+
else:
|
|
234
|
+
class_name = None
|
|
235
|
+
tests_by_class[class_name].append((result, class_name))
|
|
236
|
+
|
|
237
|
+
# Print tests organized by class
|
|
238
|
+
for class_name in sorted(tests_by_class.keys(), key=lambda x: (x is None, x)):
|
|
239
|
+
# Print class name if present
|
|
240
|
+
if class_name:
|
|
241
|
+
print(f" {Colors.cyan}{class_name}{Colors.reset}")
|
|
242
|
+
|
|
243
|
+
for result, _ in tests_by_class[class_name]:
|
|
244
|
+
# Get symbol based on status
|
|
245
|
+
if result.status == "passed":
|
|
246
|
+
symbol = pass_symbol
|
|
247
|
+
elif result.status == "failed":
|
|
248
|
+
symbol = fail_symbol
|
|
249
|
+
elif result.status == "skipped":
|
|
250
|
+
symbol = skip_symbol
|
|
251
|
+
else:
|
|
252
|
+
symbol = "?"
|
|
253
|
+
|
|
254
|
+
# Extract just the test method name
|
|
255
|
+
if "::" in result.name:
|
|
256
|
+
display_name = result.name.split("::")[-1]
|
|
257
|
+
elif "." in result.name:
|
|
258
|
+
display_name = result.name.split(".")[-1]
|
|
259
|
+
else:
|
|
260
|
+
display_name = result.name
|
|
261
|
+
|
|
262
|
+
# Indent based on whether it's in a class
|
|
263
|
+
indent = " " if class_name else " "
|
|
264
|
+
|
|
265
|
+
# Print with symbol, name, and timing
|
|
266
|
+
duration_str = f"{Colors.dim}{result.duration * 1000:.0f}ms{Colors.reset}"
|
|
267
|
+
print(f"{indent}{symbol} {display_name} {duration_str}")
|
|
268
|
+
|
|
269
|
+
# Show error message for failures
|
|
270
|
+
if result.status == "failed" and result.message:
|
|
271
|
+
error_lines = result.message.rstrip().split("\n")
|
|
272
|
+
for line in error_lines:
|
|
273
|
+
print(f"{indent} {line}")
|
rustest/core.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""High level Python API wrapping the Rust extension."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from . import rust
|
|
8
|
+
from .reporting import RunReport
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run(
|
|
12
|
+
*,
|
|
13
|
+
paths: Sequence[str],
|
|
14
|
+
pattern: str | None = None,
|
|
15
|
+
mark_expr: str | None = None,
|
|
16
|
+
workers: int | None = None,
|
|
17
|
+
capture_output: bool = True,
|
|
18
|
+
enable_codeblocks: bool = True,
|
|
19
|
+
) -> RunReport:
|
|
20
|
+
"""Execute tests and return a rich report.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
paths: Files or directories to collect tests from
|
|
24
|
+
pattern: Substring to filter tests by (case insensitive)
|
|
25
|
+
mark_expr: Mark expression to filter tests (e.g., "slow", "not slow", "slow and integration")
|
|
26
|
+
workers: Number of worker slots to use (experimental)
|
|
27
|
+
capture_output: Whether to capture stdout/stderr during test execution
|
|
28
|
+
enable_codeblocks: Whether to enable code block tests from markdown files
|
|
29
|
+
"""
|
|
30
|
+
raw_report = rust.run(
|
|
31
|
+
paths=list(paths),
|
|
32
|
+
pattern=pattern,
|
|
33
|
+
mark_expr=mark_expr,
|
|
34
|
+
workers=workers,
|
|
35
|
+
capture_output=capture_output,
|
|
36
|
+
enable_codeblocks=enable_codeblocks,
|
|
37
|
+
)
|
|
38
|
+
return RunReport.from_py(raw_report)
|
rustest/decorators.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""User facing decorators mirroring the most common pytest helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
6
|
+
from typing import Any, TypeVar
|
|
7
|
+
|
|
8
|
+
F = TypeVar("F", bound=Callable[..., object])
|
|
9
|
+
|
|
10
|
+
# Valid fixture scopes
|
|
11
|
+
VALID_SCOPES = frozenset(["function", "class", "module", "session"])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def fixture(
|
|
15
|
+
func: F | None = None,
|
|
16
|
+
*,
|
|
17
|
+
scope: str = "function",
|
|
18
|
+
) -> F | Callable[[F], F]:
|
|
19
|
+
"""Mark a function as a fixture with a specific scope.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
func: The function to decorate (when used without parentheses)
|
|
23
|
+
scope: The scope of the fixture. One of:
|
|
24
|
+
- "function": New instance for each test function (default)
|
|
25
|
+
- "class": Shared across all test methods in a class
|
|
26
|
+
- "module": Shared across all tests in a module
|
|
27
|
+
- "session": Shared across all tests in the session
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
@fixture
|
|
31
|
+
def my_fixture():
|
|
32
|
+
return 42
|
|
33
|
+
|
|
34
|
+
@fixture(scope="module")
|
|
35
|
+
def shared_fixture():
|
|
36
|
+
return expensive_setup()
|
|
37
|
+
"""
|
|
38
|
+
if scope not in VALID_SCOPES:
|
|
39
|
+
valid = ", ".join(sorted(VALID_SCOPES))
|
|
40
|
+
msg = f"Invalid fixture scope '{scope}'. Must be one of: {valid}"
|
|
41
|
+
raise ValueError(msg)
|
|
42
|
+
|
|
43
|
+
def decorator(f: F) -> F:
|
|
44
|
+
setattr(f, "__rustest_fixture__", True)
|
|
45
|
+
setattr(f, "__rustest_fixture_scope__", scope)
|
|
46
|
+
return f
|
|
47
|
+
|
|
48
|
+
# Support both @fixture and @fixture(scope="...")
|
|
49
|
+
if func is not None:
|
|
50
|
+
return decorator(func)
|
|
51
|
+
return decorator
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def skip(reason: str | None = None) -> Callable[[F], F]:
|
|
55
|
+
"""Skip a test or fixture."""
|
|
56
|
+
|
|
57
|
+
def decorator(func: F) -> F:
|
|
58
|
+
setattr(func, "__rustest_skip__", reason or "skipped via rustest.skip")
|
|
59
|
+
return func
|
|
60
|
+
|
|
61
|
+
return decorator
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parametrize(
|
|
65
|
+
arg_names: str | Sequence[str],
|
|
66
|
+
values: Sequence[Sequence[object] | Mapping[str, object]],
|
|
67
|
+
*,
|
|
68
|
+
ids: Sequence[str] | None = None,
|
|
69
|
+
) -> Callable[[F], F]:
|
|
70
|
+
"""Parametrise a test function."""
|
|
71
|
+
|
|
72
|
+
normalized_names = _normalize_arg_names(arg_names)
|
|
73
|
+
|
|
74
|
+
def decorator(func: F) -> F:
|
|
75
|
+
cases = _build_cases(normalized_names, values, ids)
|
|
76
|
+
setattr(func, "__rustest_parametrization__", cases)
|
|
77
|
+
return func
|
|
78
|
+
|
|
79
|
+
return decorator
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _normalize_arg_names(arg_names: str | Sequence[str]) -> tuple[str, ...]:
|
|
83
|
+
if isinstance(arg_names, str):
|
|
84
|
+
parts = [part.strip() for part in arg_names.split(",") if part.strip()]
|
|
85
|
+
if not parts:
|
|
86
|
+
msg = "parametrize() expected at least one argument name"
|
|
87
|
+
raise ValueError(msg)
|
|
88
|
+
return tuple(parts)
|
|
89
|
+
return tuple(arg_names)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _build_cases(
|
|
93
|
+
names: tuple[str, ...],
|
|
94
|
+
values: Sequence[Sequence[object] | Mapping[str, object]],
|
|
95
|
+
ids: Sequence[str] | None,
|
|
96
|
+
) -> tuple[dict[str, object], ...]:
|
|
97
|
+
case_payloads: list[dict[str, object]] = []
|
|
98
|
+
if ids is not None and len(ids) != len(values):
|
|
99
|
+
msg = "ids must match the number of value sets"
|
|
100
|
+
raise ValueError(msg)
|
|
101
|
+
|
|
102
|
+
for index, case in enumerate(values):
|
|
103
|
+
# Mappings are only treated as parameter mappings when there are multiple parameters
|
|
104
|
+
# For single parameters, dicts/mappings are treated as values
|
|
105
|
+
if isinstance(case, Mapping) and len(names) > 1:
|
|
106
|
+
data = {name: case[name] for name in names}
|
|
107
|
+
elif isinstance(case, tuple) and len(case) == len(names):
|
|
108
|
+
# Tuples are unpacked to match parameter names (pytest convention)
|
|
109
|
+
# This handles both single and multiple parameters
|
|
110
|
+
data = {name: case[pos] for pos, name in enumerate(names)}
|
|
111
|
+
else:
|
|
112
|
+
# Everything else is treated as a single value
|
|
113
|
+
# This includes: primitives, lists (even if len==names), dicts (single param), objects
|
|
114
|
+
if len(names) == 1:
|
|
115
|
+
data = {names[0]: case}
|
|
116
|
+
else:
|
|
117
|
+
raise ValueError("Parametrized value does not match argument names")
|
|
118
|
+
case_id = ids[index] if ids is not None else f"case_{index}"
|
|
119
|
+
case_payloads.append({"id": case_id, "values": data})
|
|
120
|
+
return tuple(case_payloads)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class MarkDecorator:
|
|
124
|
+
"""A decorator for applying a mark to a test function."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None:
|
|
127
|
+
super().__init__()
|
|
128
|
+
self.name = name
|
|
129
|
+
self.args = args
|
|
130
|
+
self.kwargs = kwargs
|
|
131
|
+
|
|
132
|
+
def __call__(self, func: F) -> F:
|
|
133
|
+
"""Apply this mark to the given function."""
|
|
134
|
+
# Get existing marks or create a new list
|
|
135
|
+
existing_marks: list[dict[str, Any]] = getattr(func, "__rustest_marks__", [])
|
|
136
|
+
|
|
137
|
+
# Add this mark to the list
|
|
138
|
+
mark_data = {
|
|
139
|
+
"name": self.name,
|
|
140
|
+
"args": self.args,
|
|
141
|
+
"kwargs": self.kwargs,
|
|
142
|
+
}
|
|
143
|
+
existing_marks.append(mark_data)
|
|
144
|
+
|
|
145
|
+
# Store the marks list on the function
|
|
146
|
+
setattr(func, "__rustest_marks__", existing_marks)
|
|
147
|
+
return func
|
|
148
|
+
|
|
149
|
+
def __repr__(self) -> str:
|
|
150
|
+
return f"Mark({self.name!r}, {self.args!r}, {self.kwargs!r})"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class MarkGenerator:
|
|
154
|
+
"""Namespace for dynamically creating marks like pytest.mark.
|
|
155
|
+
|
|
156
|
+
Usage:
|
|
157
|
+
@mark.slow
|
|
158
|
+
@mark.integration
|
|
159
|
+
@mark.timeout(seconds=30)
|
|
160
|
+
|
|
161
|
+
Standard marks:
|
|
162
|
+
@mark.skipif(condition, *, reason="...")
|
|
163
|
+
@mark.xfail(condition=None, *, reason=None, raises=None, run=True, strict=False)
|
|
164
|
+
@mark.usefixtures("fixture1", "fixture2")
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
def skipif(
|
|
168
|
+
self,
|
|
169
|
+
condition: bool | str,
|
|
170
|
+
*,
|
|
171
|
+
reason: str | None = None,
|
|
172
|
+
) -> MarkDecorator:
|
|
173
|
+
"""Skip test if condition is true.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
condition: Boolean or string condition to evaluate
|
|
177
|
+
reason: Explanation for why the test is skipped
|
|
178
|
+
|
|
179
|
+
Usage:
|
|
180
|
+
@mark.skipif(sys.platform == "win32", reason="Not supported on Windows")
|
|
181
|
+
def test_unix_only():
|
|
182
|
+
pass
|
|
183
|
+
"""
|
|
184
|
+
return MarkDecorator("skipif", (condition,), {"reason": reason})
|
|
185
|
+
|
|
186
|
+
def xfail(
|
|
187
|
+
self,
|
|
188
|
+
condition: bool | str | None = None,
|
|
189
|
+
*,
|
|
190
|
+
reason: str | None = None,
|
|
191
|
+
raises: type[BaseException] | tuple[type[BaseException], ...] | None = None,
|
|
192
|
+
run: bool = True,
|
|
193
|
+
strict: bool = False,
|
|
194
|
+
) -> MarkDecorator:
|
|
195
|
+
"""Mark test as expected to fail.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
condition: Optional condition - if False, mark is ignored
|
|
199
|
+
reason: Explanation for why the test is expected to fail
|
|
200
|
+
raises: Expected exception type(s)
|
|
201
|
+
run: Whether to run the test (False means skip it)
|
|
202
|
+
strict: If True, passing test will fail the suite
|
|
203
|
+
|
|
204
|
+
Usage:
|
|
205
|
+
@mark.xfail(reason="Known bug in backend")
|
|
206
|
+
def test_known_bug():
|
|
207
|
+
assert False
|
|
208
|
+
|
|
209
|
+
@mark.xfail(sys.platform == "win32", reason="Not implemented on Windows")
|
|
210
|
+
def test_feature():
|
|
211
|
+
pass
|
|
212
|
+
"""
|
|
213
|
+
kwargs = {
|
|
214
|
+
"reason": reason,
|
|
215
|
+
"raises": raises,
|
|
216
|
+
"run": run,
|
|
217
|
+
"strict": strict,
|
|
218
|
+
}
|
|
219
|
+
args = () if condition is None else (condition,)
|
|
220
|
+
return MarkDecorator("xfail", args, kwargs)
|
|
221
|
+
|
|
222
|
+
def usefixtures(self, *names: str) -> MarkDecorator:
|
|
223
|
+
"""Use fixtures without explicitly requesting them as parameters.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
*names: Names of fixtures to use
|
|
227
|
+
|
|
228
|
+
Usage:
|
|
229
|
+
@mark.usefixtures("setup_db", "cleanup")
|
|
230
|
+
def test_with_fixtures():
|
|
231
|
+
pass
|
|
232
|
+
"""
|
|
233
|
+
return MarkDecorator("usefixtures", names, {})
|
|
234
|
+
|
|
235
|
+
def __getattr__(self, name: str) -> Any:
|
|
236
|
+
"""Create a mark decorator for the given name."""
|
|
237
|
+
# Return a callable that can be used as @mark.name or @mark.name(args)
|
|
238
|
+
return self._create_mark(name)
|
|
239
|
+
|
|
240
|
+
def _create_mark(self, name: str) -> Any:
|
|
241
|
+
"""Create a MarkDecorator that can be called with or without arguments."""
|
|
242
|
+
|
|
243
|
+
class _MarkDecoratorFactory:
|
|
244
|
+
"""Factory that allows @mark.name or @mark.name(args)."""
|
|
245
|
+
|
|
246
|
+
def __init__(self, mark_name: str) -> None:
|
|
247
|
+
super().__init__()
|
|
248
|
+
self.mark_name = mark_name
|
|
249
|
+
|
|
250
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
251
|
+
# If called with a single argument that's a function, it's @mark.name
|
|
252
|
+
if (
|
|
253
|
+
len(args) == 1
|
|
254
|
+
and not kwargs
|
|
255
|
+
and callable(args[0])
|
|
256
|
+
and hasattr(args[0], "__name__")
|
|
257
|
+
):
|
|
258
|
+
decorator = MarkDecorator(self.mark_name, (), {})
|
|
259
|
+
return decorator(args[0])
|
|
260
|
+
# Otherwise it's @mark.name(args) - return a decorator
|
|
261
|
+
return MarkDecorator(self.mark_name, args, kwargs)
|
|
262
|
+
|
|
263
|
+
return _MarkDecoratorFactory(name)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# Create a singleton instance
|
|
267
|
+
mark = MarkGenerator()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class ExceptionInfo:
|
|
271
|
+
"""Information about an exception caught by raises().
|
|
272
|
+
|
|
273
|
+
Attributes:
|
|
274
|
+
type: The exception type
|
|
275
|
+
value: The exception instance
|
|
276
|
+
traceback: The exception traceback
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
def __init__(
|
|
280
|
+
self, exc_type: type[BaseException], exc_value: BaseException, exc_tb: Any
|
|
281
|
+
) -> None:
|
|
282
|
+
super().__init__()
|
|
283
|
+
self.type = exc_type
|
|
284
|
+
self.value = exc_value
|
|
285
|
+
self.traceback = exc_tb
|
|
286
|
+
|
|
287
|
+
def __repr__(self) -> str:
|
|
288
|
+
return f"<ExceptionInfo {self.type.__name__}({self.value!r})>"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class RaisesContext:
|
|
292
|
+
"""Context manager for asserting that code raises a specific exception.
|
|
293
|
+
|
|
294
|
+
This mimics pytest.raises() behavior, supporting:
|
|
295
|
+
- Single or tuple of exception types
|
|
296
|
+
- Optional regex matching of exception messages
|
|
297
|
+
- Access to caught exception information
|
|
298
|
+
|
|
299
|
+
Usage:
|
|
300
|
+
with raises(ValueError):
|
|
301
|
+
int("not a number")
|
|
302
|
+
|
|
303
|
+
with raises(ValueError, match="invalid literal"):
|
|
304
|
+
int("not a number")
|
|
305
|
+
|
|
306
|
+
with raises((ValueError, TypeError)):
|
|
307
|
+
some_function()
|
|
308
|
+
|
|
309
|
+
# Access the caught exception
|
|
310
|
+
with raises(ValueError) as exc_info:
|
|
311
|
+
raise ValueError("oops")
|
|
312
|
+
assert "oops" in str(exc_info.value)
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
def __init__(
|
|
316
|
+
self,
|
|
317
|
+
exc_type: type[BaseException] | tuple[type[BaseException], ...],
|
|
318
|
+
*,
|
|
319
|
+
match: str | None = None,
|
|
320
|
+
) -> None:
|
|
321
|
+
super().__init__()
|
|
322
|
+
self.exc_type = exc_type
|
|
323
|
+
self.match_pattern = match
|
|
324
|
+
self.excinfo: ExceptionInfo | None = None
|
|
325
|
+
|
|
326
|
+
def __enter__(self) -> RaisesContext:
|
|
327
|
+
return self
|
|
328
|
+
|
|
329
|
+
def __exit__(
|
|
330
|
+
self,
|
|
331
|
+
exc_type: type[BaseException] | None,
|
|
332
|
+
exc_val: BaseException | None,
|
|
333
|
+
exc_tb: Any,
|
|
334
|
+
) -> bool:
|
|
335
|
+
# No exception was raised
|
|
336
|
+
if exc_type is None:
|
|
337
|
+
exc_name = self._format_exc_name()
|
|
338
|
+
msg = f"DID NOT RAISE {exc_name}"
|
|
339
|
+
raise AssertionError(msg)
|
|
340
|
+
|
|
341
|
+
# At this point, we know an exception was raised, so exc_val cannot be None
|
|
342
|
+
assert exc_val is not None, "exc_val must not be None when exc_type is not None"
|
|
343
|
+
|
|
344
|
+
# Check if the exception type matches
|
|
345
|
+
if not issubclass(exc_type, self.exc_type):
|
|
346
|
+
# Unexpected exception type - let it propagate
|
|
347
|
+
return False
|
|
348
|
+
|
|
349
|
+
# Store the exception information
|
|
350
|
+
self.excinfo = ExceptionInfo(exc_type, exc_val, exc_tb)
|
|
351
|
+
|
|
352
|
+
# Check if the message matches the pattern (if provided)
|
|
353
|
+
if self.match_pattern is not None:
|
|
354
|
+
import re
|
|
355
|
+
|
|
356
|
+
exc_message = str(exc_val)
|
|
357
|
+
if not re.search(self.match_pattern, exc_message):
|
|
358
|
+
msg = (
|
|
359
|
+
f"Pattern {self.match_pattern!r} does not match "
|
|
360
|
+
f"{exc_message!r}. Exception: {exc_type.__name__}: {exc_message}"
|
|
361
|
+
)
|
|
362
|
+
raise AssertionError(msg)
|
|
363
|
+
|
|
364
|
+
# Suppress the exception (it was expected)
|
|
365
|
+
return True
|
|
366
|
+
|
|
367
|
+
def _format_exc_name(self) -> str:
|
|
368
|
+
"""Format the expected exception name(s) for error messages."""
|
|
369
|
+
if isinstance(self.exc_type, tuple):
|
|
370
|
+
names = " or ".join(exc.__name__ for exc in self.exc_type)
|
|
371
|
+
return names
|
|
372
|
+
return self.exc_type.__name__
|
|
373
|
+
|
|
374
|
+
@property
|
|
375
|
+
def value(self) -> BaseException:
|
|
376
|
+
"""Access the caught exception value."""
|
|
377
|
+
if self.excinfo is None:
|
|
378
|
+
msg = "No exception was caught"
|
|
379
|
+
raise AttributeError(msg)
|
|
380
|
+
return self.excinfo.value
|
|
381
|
+
|
|
382
|
+
@property
|
|
383
|
+
def type(self) -> type[BaseException]:
|
|
384
|
+
"""Access the caught exception type."""
|
|
385
|
+
if self.excinfo is None:
|
|
386
|
+
msg = "No exception was caught"
|
|
387
|
+
raise AttributeError(msg)
|
|
388
|
+
return self.excinfo.type
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def raises(
|
|
392
|
+
exc_type: type[BaseException] | tuple[type[BaseException], ...],
|
|
393
|
+
*,
|
|
394
|
+
match: str | None = None,
|
|
395
|
+
) -> RaisesContext:
|
|
396
|
+
"""Assert that code raises a specific exception.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
exc_type: The expected exception type(s). Can be a single type or tuple of types.
|
|
400
|
+
match: Optional regex pattern to match against the exception message.
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
A context manager that catches and validates the exception.
|
|
404
|
+
|
|
405
|
+
Raises:
|
|
406
|
+
AssertionError: If no exception is raised, or if the message doesn't match.
|
|
407
|
+
|
|
408
|
+
Usage:
|
|
409
|
+
with raises(ValueError):
|
|
410
|
+
int("not a number")
|
|
411
|
+
|
|
412
|
+
with raises(ValueError, match="invalid literal"):
|
|
413
|
+
int("not a number")
|
|
414
|
+
|
|
415
|
+
with raises((ValueError, TypeError)):
|
|
416
|
+
some_function()
|
|
417
|
+
|
|
418
|
+
# Access the caught exception
|
|
419
|
+
with raises(ValueError) as exc_info:
|
|
420
|
+
raise ValueError("oops")
|
|
421
|
+
assert "oops" in str(exc_info.value)
|
|
422
|
+
"""
|
|
423
|
+
return RaisesContext(exc_type, match=match)
|
rustest/py.typed
ADDED
|
File without changes
|
rustest/reporting.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Utilities for converting raw results from the Rust layer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from . import rust
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class TestResult:
|
|
13
|
+
"""Structured view of a single test outcome."""
|
|
14
|
+
|
|
15
|
+
__test__ = False # Tell pytest this is not a test class
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
path: str
|
|
19
|
+
status: str
|
|
20
|
+
duration: float
|
|
21
|
+
message: str | None
|
|
22
|
+
stdout: str | None
|
|
23
|
+
stderr: str | None
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_py(cls, result: rust.PyTestResult) -> "TestResult":
|
|
27
|
+
return cls(
|
|
28
|
+
name=result.name,
|
|
29
|
+
path=result.path,
|
|
30
|
+
status=result.status,
|
|
31
|
+
duration=result.duration,
|
|
32
|
+
message=result.message,
|
|
33
|
+
stdout=result.stdout,
|
|
34
|
+
stderr=result.stderr,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class RunReport:
|
|
40
|
+
"""Aggregate statistics for an entire test session."""
|
|
41
|
+
|
|
42
|
+
total: int
|
|
43
|
+
passed: int
|
|
44
|
+
failed: int
|
|
45
|
+
skipped: int
|
|
46
|
+
duration: float
|
|
47
|
+
results: tuple[TestResult, ...]
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_py(cls, report: rust.PyRunReport) -> "RunReport":
|
|
51
|
+
return cls(
|
|
52
|
+
total=report.total,
|
|
53
|
+
passed=report.passed,
|
|
54
|
+
failed=report.failed,
|
|
55
|
+
skipped=report.skipped,
|
|
56
|
+
duration=report.duration,
|
|
57
|
+
results=tuple(TestResult.from_py(result) for result in report.results),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def iter_status(self, status: str) -> Iterable[TestResult]:
|
|
61
|
+
"""Yield results with the requested status."""
|
|
62
|
+
|
|
63
|
+
return (result for result in self.results if result.status == status)
|
|
Binary file
|
rustest/rust.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Fallback stub for the compiled rustest extension.
|
|
2
|
+
|
|
3
|
+
This module is packaged with the Python distribution so unit tests can import the
|
|
4
|
+
package without building the Rust extension. Individual tests are expected to
|
|
5
|
+
monkeypatch the functions they exercise.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Sequence
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run(
|
|
14
|
+
_paths: Sequence[str],
|
|
15
|
+
_pattern: str | None,
|
|
16
|
+
_workers: int | None,
|
|
17
|
+
_capture_output: bool,
|
|
18
|
+
) -> Any:
|
|
19
|
+
"""Placeholder implementation that mirrors the extension signature."""
|
|
20
|
+
|
|
21
|
+
raise NotImplementedError(
|
|
22
|
+
"The rustest native extension is unavailable. Tests must patch rustest.rust.run."
|
|
23
|
+
)
|
rustest/rust.pyi
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Type stubs for the rustest Rust extension module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Sequence
|
|
6
|
+
|
|
7
|
+
class PyTestResult:
|
|
8
|
+
"""Individual test result from the Rust extension."""
|
|
9
|
+
|
|
10
|
+
name: str
|
|
11
|
+
path: str
|
|
12
|
+
status: str
|
|
13
|
+
duration: float
|
|
14
|
+
message: str | None
|
|
15
|
+
stdout: str | None
|
|
16
|
+
stderr: str | None
|
|
17
|
+
|
|
18
|
+
class PyRunReport:
|
|
19
|
+
"""Test run report from the Rust extension."""
|
|
20
|
+
|
|
21
|
+
total: int
|
|
22
|
+
passed: int
|
|
23
|
+
failed: int
|
|
24
|
+
skipped: int
|
|
25
|
+
duration: float
|
|
26
|
+
results: list[PyTestResult]
|
|
27
|
+
|
|
28
|
+
def run(
|
|
29
|
+
paths: Sequence[str],
|
|
30
|
+
pattern: str | None,
|
|
31
|
+
mark_expr: str | None,
|
|
32
|
+
workers: int | None,
|
|
33
|
+
capture_output: bool,
|
|
34
|
+
enable_codeblocks: bool,
|
|
35
|
+
) -> PyRunReport:
|
|
36
|
+
"""Execute tests and return a report."""
|
|
37
|
+
...
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rustest
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Classifier: Development Status :: 3 - Alpha
|
|
5
|
+
Classifier: Intended Audience :: Developers
|
|
6
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Classifier: Programming Language :: Rust
|
|
14
|
+
Classifier: Topic :: Software Development :: Testing
|
|
15
|
+
Requires-Dist: typing-extensions>=4.15
|
|
16
|
+
Requires-Dist: basedpyright>=1.19 ; extra == 'dev'
|
|
17
|
+
Requires-Dist: maturin>=1.4,<2 ; extra == 'dev'
|
|
18
|
+
Requires-Dist: poethepoet>=0.22 ; extra == 'dev'
|
|
19
|
+
Requires-Dist: pre-commit>=3.5 ; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest>=7.0 ; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff>=0.1.9 ; extra == 'dev'
|
|
22
|
+
Requires-Dist: mkdocs>=1.5.0 ; extra == 'docs'
|
|
23
|
+
Requires-Dist: mkdocs-material>=9.5.0 ; extra == 'docs'
|
|
24
|
+
Requires-Dist: mkdocstrings[python]>=0.24.0 ; extra == 'docs'
|
|
25
|
+
Requires-Dist: mkdocs-autorefs>=0.5.0 ; extra == 'docs'
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Provides-Extra: docs
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Summary: Rust powered pytest-compatible runner
|
|
30
|
+
Author: rustest contributors
|
|
31
|
+
Requires-Python: >=3.10
|
|
32
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
33
|
+
Project-URL: Homepage, https://github.com/Apex-Engineers-Inc/rustest
|
|
34
|
+
Project-URL: Repository, https://github.com/Apex-Engineers-Inc/rustest
|
|
35
|
+
Project-URL: Documentation, https://apex-engineers-inc.github.io/rustest
|
|
36
|
+
|
|
37
|
+
# rustest
|
|
38
|
+
|
|
39
|
+
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.
|
|
40
|
+
|
|
41
|
+
📚 **[Full Documentation](https://apex-engineers-inc.github.io/rustest)** | [Getting Started](https://apex-engineers-inc.github.io/rustest/getting-started/quickstart/) | [User Guide](https://apex-engineers-inc.github.io/rustest/guide/writing-tests/) | [API Reference](https://apex-engineers-inc.github.io/rustest/api/overview/)
|
|
42
|
+
|
|
43
|
+
## Why rustest?
|
|
44
|
+
|
|
45
|
+
- 🚀 **About 2x faster** than pytest on the rustest integration test suite
|
|
46
|
+
- ✅ Familiar `@fixture`, `@parametrize`, `@skip`, and `@mark` decorators
|
|
47
|
+
- 🔍 Automatic test discovery (`test_*.py` and `*_test.py` files)
|
|
48
|
+
- 📝 **Built-in markdown code block testing** (like pytest-codeblocks, but faster)
|
|
49
|
+
- 🎯 Simple, clean API—if you know pytest, you already know rustest
|
|
50
|
+
- 🧮 Built-in `approx()` helper for tolerant numeric comparisons
|
|
51
|
+
- 🪤 `raises()` context manager for precise exception assertions
|
|
52
|
+
- 📦 Easy installation with pip or uv
|
|
53
|
+
- ⚡ Low-overhead execution keeps small suites feeling instant
|
|
54
|
+
|
|
55
|
+
## Performance
|
|
56
|
+
|
|
57
|
+
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:
|
|
58
|
+
|
|
59
|
+
| Test Runner | Wall Clock | Speedup | Command |
|
|
60
|
+
|-------------|------------|---------|---------|
|
|
61
|
+
| pytest | 1.33–1.59s | 1.0x (baseline) | `pytest tests/ examples/tests/ -q` |
|
|
62
|
+
| rustest | 0.69–0.70s | **~2.1x faster** | `python -m rustest tests/ examples/tests/` |
|
|
63
|
+
|
|
64
|
+
### Large Parametrized Stress Test
|
|
65
|
+
|
|
66
|
+
With **10,000 parametrized invocations**:
|
|
67
|
+
|
|
68
|
+
| Test Runner | Avg. Wall Clock | Speedup | Command |
|
|
69
|
+
|-------------|-----------------|---------|---------|
|
|
70
|
+
| pytest | 9.72s | 1.0x | `pytest benchmarks/test_large_parametrize.py -q` |
|
|
71
|
+
| rustest | 0.41s | **~24x faster** | `python -m rustest benchmarks/test_large_parametrize.py` |
|
|
72
|
+
|
|
73
|
+
**[📊 View Detailed Performance Analysis →](https://apex-engineers-inc.github.io/rustest/advanced/performance/)**
|
|
74
|
+
|
|
75
|
+
## Installation
|
|
76
|
+
|
|
77
|
+
Rustest supports Python **3.10 through 3.14**.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# Using pip
|
|
81
|
+
pip install rustest
|
|
82
|
+
|
|
83
|
+
# Using uv
|
|
84
|
+
uv add rustest
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**[📖 Installation Guide →](https://apex-engineers-inc.github.io/rustest/getting-started/installation/)**
|
|
88
|
+
|
|
89
|
+
## Quick Start
|
|
90
|
+
|
|
91
|
+
### 1. Write Your Tests
|
|
92
|
+
|
|
93
|
+
Create a file `test_math.py`:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from rustest import fixture, parametrize, mark, approx, raises
|
|
97
|
+
|
|
98
|
+
@fixture
|
|
99
|
+
def numbers() -> list[int]:
|
|
100
|
+
return [1, 2, 3, 4, 5]
|
|
101
|
+
|
|
102
|
+
def test_sum(numbers: list[int]) -> None:
|
|
103
|
+
assert sum(numbers) == approx(15)
|
|
104
|
+
|
|
105
|
+
@parametrize("value,expected", [(2, 4), (3, 9), (4, 16)])
|
|
106
|
+
def test_square(value: int, expected: int) -> None:
|
|
107
|
+
assert value ** 2 == expected
|
|
108
|
+
|
|
109
|
+
@mark.slow
|
|
110
|
+
def test_expensive_operation() -> None:
|
|
111
|
+
result = sum(range(1000000))
|
|
112
|
+
assert result > 0
|
|
113
|
+
|
|
114
|
+
def test_division_by_zero() -> None:
|
|
115
|
+
with raises(ZeroDivisionError, match="division by zero"):
|
|
116
|
+
1 / 0
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 2. Run Your Tests
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
# Run all tests
|
|
123
|
+
rustest
|
|
124
|
+
|
|
125
|
+
# Run specific tests
|
|
126
|
+
rustest tests/
|
|
127
|
+
|
|
128
|
+
# Filter by test name pattern
|
|
129
|
+
rustest -k "test_sum"
|
|
130
|
+
|
|
131
|
+
# Filter by marks
|
|
132
|
+
rustest -m "slow" # Run only slow tests
|
|
133
|
+
rustest -m "not slow" # Skip slow tests
|
|
134
|
+
rustest -m "slow and integration" # Run tests with both marks
|
|
135
|
+
|
|
136
|
+
# Show output during execution
|
|
137
|
+
rustest --no-capture
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
**[📖 Full Quick Start Guide →](https://apex-engineers-inc.github.io/rustest/getting-started/quickstart/)**
|
|
141
|
+
|
|
142
|
+
## Documentation
|
|
143
|
+
|
|
144
|
+
**[📚 Full Documentation](https://apex-engineers-inc.github.io/rustest)**
|
|
145
|
+
|
|
146
|
+
### Getting Started
|
|
147
|
+
- [Installation](https://apex-engineers-inc.github.io/rustest/getting-started/installation/)
|
|
148
|
+
- [Quick Start](https://apex-engineers-inc.github.io/rustest/getting-started/quickstart/)
|
|
149
|
+
|
|
150
|
+
### User Guide
|
|
151
|
+
- [Writing Tests](https://apex-engineers-inc.github.io/rustest/guide/writing-tests/)
|
|
152
|
+
- [Fixtures](https://apex-engineers-inc.github.io/rustest/guide/fixtures/)
|
|
153
|
+
- [Parametrization](https://apex-engineers-inc.github.io/rustest/guide/parametrization/)
|
|
154
|
+
- [Marks & Skipping](https://apex-engineers-inc.github.io/rustest/guide/marks/)
|
|
155
|
+
- [Test Classes](https://apex-engineers-inc.github.io/rustest/guide/test-classes/)
|
|
156
|
+
- [Assertion Helpers](https://apex-engineers-inc.github.io/rustest/guide/assertions/)
|
|
157
|
+
- [Markdown Testing](https://apex-engineers-inc.github.io/rustest/guide/markdown-testing/)
|
|
158
|
+
- [CLI Usage](https://apex-engineers-inc.github.io/rustest/guide/cli/)
|
|
159
|
+
- [Python API](https://apex-engineers-inc.github.io/rustest/guide/python-api/)
|
|
160
|
+
|
|
161
|
+
### API Reference
|
|
162
|
+
- [API Overview](https://apex-engineers-inc.github.io/rustest/api/overview/)
|
|
163
|
+
- [Decorators](https://apex-engineers-inc.github.io/rustest/api/decorators/)
|
|
164
|
+
- [Test Execution](https://apex-engineers-inc.github.io/rustest/api/core/)
|
|
165
|
+
- [Reporting](https://apex-engineers-inc.github.io/rustest/api/reporting/)
|
|
166
|
+
- [Assertion Utilities](https://apex-engineers-inc.github.io/rustest/api/approx/)
|
|
167
|
+
|
|
168
|
+
### Advanced Topics
|
|
169
|
+
- [Performance](https://apex-engineers-inc.github.io/rustest/advanced/performance/)
|
|
170
|
+
- [Comparison with pytest](https://apex-engineers-inc.github.io/rustest/advanced/comparison/)
|
|
171
|
+
- [Development Guide](https://apex-engineers-inc.github.io/rustest/advanced/development/)
|
|
172
|
+
|
|
173
|
+
## Feature Comparison with pytest
|
|
174
|
+
|
|
175
|
+
Rustest implements the 20% of pytest features that cover 80% of use cases, with a focus on raw speed and simplicity.
|
|
176
|
+
|
|
177
|
+
**[📋 View Full Feature Comparison →](https://apex-engineers-inc.github.io/rustest/advanced/comparison/)**
|
|
178
|
+
|
|
179
|
+
✅ **Supported:** Fixtures, parametrization, marks, test classes, conftest.py, markdown testing
|
|
180
|
+
🚧 **Planned:** Parallel execution, mark filtering, JUnit XML output
|
|
181
|
+
❌ **Not Planned:** Plugins, hooks, custom collectors (keeps rustest simple)
|
|
182
|
+
|
|
183
|
+
## Contributing
|
|
184
|
+
|
|
185
|
+
We welcome contributions! See the [Development Guide](https://apex-engineers-inc.github.io/rustest/advanced/development/) for setup instructions.
|
|
186
|
+
|
|
187
|
+
Quick reference:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
# Setup
|
|
191
|
+
git clone https://github.com/Apex-Engineers-Inc/rustest.git
|
|
192
|
+
cd rustest
|
|
193
|
+
uv sync --all-extras
|
|
194
|
+
uv run maturin develop
|
|
195
|
+
|
|
196
|
+
# Run tests
|
|
197
|
+
uv run poe pytests # Python tests
|
|
198
|
+
cargo test # Rust tests
|
|
199
|
+
|
|
200
|
+
# Format and lint
|
|
201
|
+
uv run pre-commit install # One-time setup
|
|
202
|
+
git commit -m "message" # Pre-commit hooks run automatically
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
rustest is distributed under the terms of the MIT license. See [LICENSE](LICENSE).
|
|
208
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
rustest-0.5.0.dist-info/METADATA,sha256=SkRHcV4Pv7s5Hfo9ywd77-b3wuJtgbPvtrYY6LQoaYk,8225
|
|
2
|
+
rustest-0.5.0.dist-info/WHEEL,sha256=n8ZdhGDUio-M1d1jdBhS3kD6MKEN1SRxo8BOjWwZdsg,96
|
|
3
|
+
rustest-0.5.0.dist-info/entry_points.txt,sha256=7fUa3LO8vudQ4dKG1sTRaDnxcMdBSZsWs9EyuxFQ7Lk,48
|
|
4
|
+
rustest-0.5.0.dist-info/licenses/LICENSE,sha256=Ci0bB0T1ZGkqIV237Zp_Bv8LIJJ0Vxwc-AhLhgDgAoQ,1096
|
|
5
|
+
rustest/__init__.py,sha256=LL9UloOClzeNO6A-iMkEFtHDrBTAhRLko3sXy55H0QA,542
|
|
6
|
+
rustest/__main__.py,sha256=yMhaWvxGAV46BYY8fB6GoRy9oh8Z8YrS9wlZI3LmoyY,188
|
|
7
|
+
rustest/approx.py,sha256=sGaH15n3vSSv84dmR_QAIDV-xwaUrX-MqwpWIp5ihjk,5904
|
|
8
|
+
rustest/cli.py,sha256=Y7I3sLMA7icsHFLdcGJMy0LElEcoqzrVrTkedE_vGTs,9474
|
|
9
|
+
rustest/core.py,sha256=2_kav-3XPhrtXnayQBDSc_tCaaaEj07rFHq5ln4Em8Q,1227
|
|
10
|
+
rustest/decorators.py,sha256=mCiqNJXYpbQMCMaVEdeiebN9KWLvgtRmqZ6nbUXpm6E,14138
|
|
11
|
+
rustest/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
rustest/reporting.py,sha256=g4jdlwjFoKF_fWA_sKfeDwmhDeHxPJQUqypa_E2XQlc,1686
|
|
13
|
+
rustest/rust.cp312-win_amd64.pyd,sha256=RFiwEJBXWgLvajST9qNaP7RV5QbL7L6BWIMPAPgOAYQ,1341440
|
|
14
|
+
rustest/rust.py,sha256=N_1C-uXRiC2qkV7ecKVcb51-XXyfhYNepd5zs-RIYOo,682
|
|
15
|
+
rustest/rust.pyi,sha256=D2PL2GamLpcwdBDZxFeWC1ZgJ01CkvWycU62LnVTMD0,799
|
|
16
|
+
rustest-0.5.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Apex Engineers Inc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|