rustest 0.3.0__cp311-cp311-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,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,259 @@
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
+ "-n",
57
+ "--workers",
58
+ type=int,
59
+ help="Number of worker slots to use (experimental).",
60
+ )
61
+ _ = parser.add_argument(
62
+ "--no-capture",
63
+ dest="capture_output",
64
+ action="store_false",
65
+ help="Do not capture stdout/stderr during test execution.",
66
+ )
67
+ _ = parser.add_argument(
68
+ "-v",
69
+ "--verbose",
70
+ action="store_true",
71
+ help="Show verbose output with hierarchical test structure.",
72
+ )
73
+ _ = parser.add_argument(
74
+ "--ascii",
75
+ action="store_true",
76
+ help="Use ASCII characters instead of Unicode symbols for output.",
77
+ )
78
+ _ = parser.add_argument(
79
+ "--no-color",
80
+ dest="color",
81
+ action="store_false",
82
+ help="Disable colored output.",
83
+ )
84
+ _ = parser.set_defaults(capture_output=True, color=True)
85
+ return parser
86
+
87
+
88
+ def main(argv: Sequence[str] | None = None) -> int:
89
+ parser = build_parser()
90
+ args = parser.parse_args(argv)
91
+
92
+ # Disable colors if requested
93
+ if not args.color:
94
+ Colors.disable()
95
+
96
+ report = run(
97
+ paths=tuple(args.paths),
98
+ pattern=args.pattern,
99
+ workers=args.workers,
100
+ capture_output=args.capture_output,
101
+ )
102
+ _print_report(report, verbose=args.verbose, ascii_mode=args.ascii)
103
+ return 0 if report.failed == 0 else 1
104
+
105
+
106
+ def _print_report(report: RunReport, verbose: bool = False, ascii_mode: bool = False) -> None:
107
+ """Print test report with configurable output format.
108
+
109
+ Args:
110
+ report: The test run report
111
+ verbose: If True, show hierarchical verbose output (vitest-style)
112
+ ascii_mode: If True, use ASCII characters instead of Unicode symbols
113
+ """
114
+ if verbose:
115
+ _print_verbose_report(report, ascii_mode)
116
+ else:
117
+ _print_default_report(report, ascii_mode)
118
+
119
+ # Print summary line with colors
120
+ passed_str = (
121
+ f"{Colors.green}{report.passed} passed{Colors.reset}"
122
+ if report.passed > 0
123
+ else f"{report.passed} passed"
124
+ )
125
+ failed_str = (
126
+ f"{Colors.red}{report.failed} failed{Colors.reset}"
127
+ if report.failed > 0
128
+ else f"{report.failed} failed"
129
+ )
130
+ skipped_str = (
131
+ f"{Colors.yellow}{report.skipped} skipped{Colors.reset}"
132
+ if report.skipped > 0
133
+ else f"{report.skipped} skipped"
134
+ )
135
+
136
+ summary = (
137
+ f"\n{Colors.bold}{report.total} tests:{Colors.reset} "
138
+ f"{passed_str}, "
139
+ f"{failed_str}, "
140
+ f"{skipped_str} in {Colors.dim}{report.duration:.3f}s{Colors.reset}"
141
+ )
142
+ print(summary)
143
+
144
+
145
+ def _print_default_report(report: RunReport, ascii_mode: bool) -> None:
146
+ """Print pytest-style progress indicators followed by failure details."""
147
+ # Define symbols
148
+ if ascii_mode:
149
+ # pytest-style: . (pass), F (fail), s (skip)
150
+ pass_symbol = "."
151
+ fail_symbol = "F"
152
+ skip_symbol = "s"
153
+ else:
154
+ # Unicode symbols (no spaces, with colors)
155
+ pass_symbol = f"{Colors.green}✓{Colors.reset}"
156
+ fail_symbol = f"{Colors.red}✗{Colors.reset}"
157
+ skip_symbol = f"{Colors.yellow}⊘{Colors.reset}"
158
+
159
+ # Print progress indicators
160
+ for result in report.results:
161
+ if result.status == "passed":
162
+ print(pass_symbol, end="")
163
+ elif result.status == "failed":
164
+ print(fail_symbol, end="")
165
+ elif result.status == "skipped":
166
+ print(skip_symbol, end="")
167
+ print() # Newline after progress indicators
168
+
169
+ # Print failure details
170
+ failures = [r for r in report.results if r.status == "failed"]
171
+ if failures:
172
+ print(f"\n{Colors.red}{'=' * 70}")
173
+ print(f"{Colors.bold}FAILURES{Colors.reset}")
174
+ print(f"{Colors.red}{'=' * 70}{Colors.reset}")
175
+ for result in failures:
176
+ print(
177
+ f"\n{Colors.bold}{result.name}{Colors.reset} ({Colors.cyan}{result.path}{Colors.reset})"
178
+ )
179
+ print(f"{Colors.red}{'-' * 70}{Colors.reset}")
180
+ if result.message:
181
+ print(result.message.rstrip())
182
+
183
+
184
+ def _print_verbose_report(report: RunReport, ascii_mode: bool) -> None:
185
+ """Print vitest-style hierarchical output with nesting and timing."""
186
+ # Define symbols
187
+ if ascii_mode:
188
+ pass_symbol = "PASS"
189
+ fail_symbol = "FAIL"
190
+ skip_symbol = "SKIP"
191
+ else:
192
+ pass_symbol = f"{Colors.green}✓{Colors.reset}"
193
+ fail_symbol = f"{Colors.red}✗{Colors.reset}"
194
+ skip_symbol = f"{Colors.yellow}⊘{Colors.reset}"
195
+
196
+ # Group tests by file path and organize hierarchically
197
+ from collections import defaultdict
198
+
199
+ tests_by_file: dict[str, list[TestResult]] = defaultdict(list)
200
+ for result in report.results:
201
+ tests_by_file[result.path].append(result)
202
+
203
+ # Print hierarchical structure
204
+ for file_path in sorted(tests_by_file.keys()):
205
+ print(f"\n{Colors.bold}{file_path}{Colors.reset}")
206
+
207
+ # Group tests by class within this file
208
+ tests_by_class: dict[str | None, list[tuple[TestResult, str | None]]] = defaultdict(list)
209
+ for result in tests_by_file[file_path]:
210
+ # Parse test name to extract class if present
211
+ # Format can be: "test_name" or "ClassName.test_name" or "module::Class::test"
212
+ class_name: str | None
213
+ if "::" in result.name:
214
+ parts = result.name.split("::")
215
+ class_name = "::".join(parts[:-1]) if len(parts) > 1 else None
216
+ elif "." in result.name:
217
+ parts = result.name.split(".")
218
+ class_name = parts[0] if len(parts) > 1 else None
219
+ else:
220
+ class_name = None
221
+ tests_by_class[class_name].append((result, class_name))
222
+
223
+ # Print tests organized by class
224
+ for class_name in sorted(tests_by_class.keys(), key=lambda x: (x is None, x)):
225
+ # Print class name if present
226
+ if class_name:
227
+ print(f" {Colors.cyan}{class_name}{Colors.reset}")
228
+
229
+ for result, _ in tests_by_class[class_name]:
230
+ # Get symbol based on status
231
+ if result.status == "passed":
232
+ symbol = pass_symbol
233
+ elif result.status == "failed":
234
+ symbol = fail_symbol
235
+ elif result.status == "skipped":
236
+ symbol = skip_symbol
237
+ else:
238
+ symbol = "?"
239
+
240
+ # Extract just the test method name
241
+ if "::" in result.name:
242
+ display_name = result.name.split("::")[-1]
243
+ elif "." in result.name:
244
+ display_name = result.name.split(".")[-1]
245
+ else:
246
+ display_name = result.name
247
+
248
+ # Indent based on whether it's in a class
249
+ indent = " " if class_name else " "
250
+
251
+ # Print with symbol, name, and timing
252
+ duration_str = f"{Colors.dim}{result.duration * 1000:.0f}ms{Colors.reset}"
253
+ print(f"{indent}{symbol} {display_name} {duration_str}")
254
+
255
+ # Show error message for failures
256
+ if result.status == "failed" and result.message:
257
+ error_lines = result.message.rstrip().split("\n")
258
+ for line in error_lines:
259
+ print(f"{indent} {line}")
rustest/core.py ADDED
@@ -0,0 +1,21 @@
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,
15
+ workers: int | None,
16
+ capture_output: bool,
17
+ ) -> RunReport:
18
+ """Execute tests and return a rich report."""
19
+
20
+ raw_report = rust.run(list(paths), pattern, workers, capture_output)
21
+ return RunReport.from_py(raw_report)