rustest 0.6.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.
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 +537 -0
- rustest/py.typed +0 -0
- rustest/reporting.py +63 -0
- rustest/rust.cp311-win_amd64.pyd +0 -0
- rustest/rust.py +23 -0
- rustest/rust.pyi +37 -0
- rustest-0.6.0.dist-info/METADATA +222 -0
- rustest-0.6.0.dist-info/RECORD +16 -0
- rustest-0.6.0.dist-info/WHEEL +4 -0
- rustest-0.6.0.dist-info/entry_points.txt +2 -0
- rustest-0.6.0.dist-info/licenses/LICENSE +21 -0
rustest/decorators.py
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
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
|
+
@mark.asyncio(loop_scope="function")
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def asyncio(
|
|
169
|
+
self,
|
|
170
|
+
func: Callable[..., Any] | None = None,
|
|
171
|
+
*,
|
|
172
|
+
loop_scope: str = "function",
|
|
173
|
+
) -> Callable[..., Any]:
|
|
174
|
+
"""Mark an async test function to be executed with asyncio.
|
|
175
|
+
|
|
176
|
+
This decorator allows you to write async test functions that will be
|
|
177
|
+
automatically executed in an asyncio event loop. The loop_scope parameter
|
|
178
|
+
controls the scope of the event loop used for execution.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
func: The function to decorate (when used without parentheses)
|
|
182
|
+
loop_scope: The scope of the event loop. One of:
|
|
183
|
+
- "function": New loop for each test function (default)
|
|
184
|
+
- "class": Shared loop across all test methods in a class
|
|
185
|
+
- "module": Shared loop across all tests in a module
|
|
186
|
+
- "session": Shared loop across all tests in the session
|
|
187
|
+
|
|
188
|
+
Usage:
|
|
189
|
+
@mark.asyncio
|
|
190
|
+
async def test_async_function():
|
|
191
|
+
result = await some_async_operation()
|
|
192
|
+
assert result == expected
|
|
193
|
+
|
|
194
|
+
@mark.asyncio(loop_scope="module")
|
|
195
|
+
async def test_with_module_loop():
|
|
196
|
+
await another_async_operation()
|
|
197
|
+
|
|
198
|
+
Note:
|
|
199
|
+
This decorator should only be applied to async functions (coroutines).
|
|
200
|
+
Applying it to regular functions will raise a TypeError.
|
|
201
|
+
"""
|
|
202
|
+
import asyncio
|
|
203
|
+
import inspect
|
|
204
|
+
from functools import wraps
|
|
205
|
+
|
|
206
|
+
valid_scopes = {"function", "class", "module", "session"}
|
|
207
|
+
if loop_scope not in valid_scopes:
|
|
208
|
+
valid = ", ".join(sorted(valid_scopes))
|
|
209
|
+
msg = f"Invalid loop_scope '{loop_scope}'. Must be one of: {valid}"
|
|
210
|
+
raise ValueError(msg)
|
|
211
|
+
|
|
212
|
+
def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
|
|
213
|
+
# Handle class decoration - apply mark to all async methods
|
|
214
|
+
if inspect.isclass(f):
|
|
215
|
+
# Apply the mark to the class itself
|
|
216
|
+
mark_decorator = MarkDecorator("asyncio", (), {"loop_scope": loop_scope})
|
|
217
|
+
marked_class = mark_decorator(f)
|
|
218
|
+
|
|
219
|
+
# Wrap all async methods in the class
|
|
220
|
+
for name, method in inspect.getmembers(
|
|
221
|
+
marked_class, predicate=inspect.iscoroutinefunction
|
|
222
|
+
):
|
|
223
|
+
wrapped_method = _wrap_async_function(method, loop_scope)
|
|
224
|
+
setattr(marked_class, name, wrapped_method)
|
|
225
|
+
return marked_class
|
|
226
|
+
|
|
227
|
+
# Validate that the function is a coroutine
|
|
228
|
+
if not inspect.iscoroutinefunction(f):
|
|
229
|
+
msg = f"@mark.asyncio can only be applied to async functions or test classes, but '{f.__name__}' is not async"
|
|
230
|
+
raise TypeError(msg)
|
|
231
|
+
|
|
232
|
+
# Store the asyncio mark
|
|
233
|
+
mark_decorator = MarkDecorator("asyncio", (), {"loop_scope": loop_scope})
|
|
234
|
+
marked_f = mark_decorator(f)
|
|
235
|
+
|
|
236
|
+
# Wrap the async function to run it synchronously
|
|
237
|
+
return _wrap_async_function(marked_f, loop_scope)
|
|
238
|
+
|
|
239
|
+
def _wrap_async_function(f: Callable[..., Any], loop_scope: str) -> Callable[..., Any]:
|
|
240
|
+
"""Wrap an async function to run it synchronously in an event loop."""
|
|
241
|
+
|
|
242
|
+
@wraps(f)
|
|
243
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
244
|
+
# Get or create event loop based on scope
|
|
245
|
+
# For now, we'll always create a new loop - scope handling will be
|
|
246
|
+
# implemented in a future enhancement via fixtures
|
|
247
|
+
loop = asyncio.new_event_loop()
|
|
248
|
+
asyncio.set_event_loop(loop)
|
|
249
|
+
try:
|
|
250
|
+
# Run the coroutine in the event loop
|
|
251
|
+
# Get the original async function
|
|
252
|
+
original_func = getattr(f, "__wrapped__", f)
|
|
253
|
+
coro = original_func(*args, **kwargs)
|
|
254
|
+
return loop.run_until_complete(coro)
|
|
255
|
+
finally:
|
|
256
|
+
# Clean up the loop
|
|
257
|
+
try:
|
|
258
|
+
# Cancel any pending tasks
|
|
259
|
+
pending = asyncio.all_tasks(loop)
|
|
260
|
+
for task in pending:
|
|
261
|
+
task.cancel()
|
|
262
|
+
# Run the loop one more time to let tasks finish cancellation
|
|
263
|
+
if pending:
|
|
264
|
+
loop.run_until_complete(
|
|
265
|
+
asyncio.gather(*pending, return_exceptions=True)
|
|
266
|
+
)
|
|
267
|
+
except Exception:
|
|
268
|
+
pass
|
|
269
|
+
finally:
|
|
270
|
+
loop.close()
|
|
271
|
+
|
|
272
|
+
# Store reference to original async function
|
|
273
|
+
sync_wrapper.__wrapped__ = f
|
|
274
|
+
return sync_wrapper
|
|
275
|
+
|
|
276
|
+
# Support both @mark.asyncio and @mark.asyncio(loop_scope="...")
|
|
277
|
+
if func is not None:
|
|
278
|
+
return decorator(func)
|
|
279
|
+
return decorator
|
|
280
|
+
|
|
281
|
+
def skipif(
|
|
282
|
+
self,
|
|
283
|
+
condition: bool | str,
|
|
284
|
+
*,
|
|
285
|
+
reason: str | None = None,
|
|
286
|
+
) -> MarkDecorator:
|
|
287
|
+
"""Skip test if condition is true.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
condition: Boolean or string condition to evaluate
|
|
291
|
+
reason: Explanation for why the test is skipped
|
|
292
|
+
|
|
293
|
+
Usage:
|
|
294
|
+
@mark.skipif(sys.platform == "win32", reason="Not supported on Windows")
|
|
295
|
+
def test_unix_only():
|
|
296
|
+
pass
|
|
297
|
+
"""
|
|
298
|
+
return MarkDecorator("skipif", (condition,), {"reason": reason})
|
|
299
|
+
|
|
300
|
+
def xfail(
|
|
301
|
+
self,
|
|
302
|
+
condition: bool | str | None = None,
|
|
303
|
+
*,
|
|
304
|
+
reason: str | None = None,
|
|
305
|
+
raises: type[BaseException] | tuple[type[BaseException], ...] | None = None,
|
|
306
|
+
run: bool = True,
|
|
307
|
+
strict: bool = False,
|
|
308
|
+
) -> MarkDecorator:
|
|
309
|
+
"""Mark test as expected to fail.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
condition: Optional condition - if False, mark is ignored
|
|
313
|
+
reason: Explanation for why the test is expected to fail
|
|
314
|
+
raises: Expected exception type(s)
|
|
315
|
+
run: Whether to run the test (False means skip it)
|
|
316
|
+
strict: If True, passing test will fail the suite
|
|
317
|
+
|
|
318
|
+
Usage:
|
|
319
|
+
@mark.xfail(reason="Known bug in backend")
|
|
320
|
+
def test_known_bug():
|
|
321
|
+
assert False
|
|
322
|
+
|
|
323
|
+
@mark.xfail(sys.platform == "win32", reason="Not implemented on Windows")
|
|
324
|
+
def test_feature():
|
|
325
|
+
pass
|
|
326
|
+
"""
|
|
327
|
+
kwargs = {
|
|
328
|
+
"reason": reason,
|
|
329
|
+
"raises": raises,
|
|
330
|
+
"run": run,
|
|
331
|
+
"strict": strict,
|
|
332
|
+
}
|
|
333
|
+
args = () if condition is None else (condition,)
|
|
334
|
+
return MarkDecorator("xfail", args, kwargs)
|
|
335
|
+
|
|
336
|
+
def usefixtures(self, *names: str) -> MarkDecorator:
|
|
337
|
+
"""Use fixtures without explicitly requesting them as parameters.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
*names: Names of fixtures to use
|
|
341
|
+
|
|
342
|
+
Usage:
|
|
343
|
+
@mark.usefixtures("setup_db", "cleanup")
|
|
344
|
+
def test_with_fixtures():
|
|
345
|
+
pass
|
|
346
|
+
"""
|
|
347
|
+
return MarkDecorator("usefixtures", names, {})
|
|
348
|
+
|
|
349
|
+
def __getattr__(self, name: str) -> Any:
|
|
350
|
+
"""Create a mark decorator for the given name."""
|
|
351
|
+
# Return a callable that can be used as @mark.name or @mark.name(args)
|
|
352
|
+
return self._create_mark(name)
|
|
353
|
+
|
|
354
|
+
def _create_mark(self, name: str) -> Any:
|
|
355
|
+
"""Create a MarkDecorator that can be called with or without arguments."""
|
|
356
|
+
|
|
357
|
+
class _MarkDecoratorFactory:
|
|
358
|
+
"""Factory that allows @mark.name or @mark.name(args)."""
|
|
359
|
+
|
|
360
|
+
def __init__(self, mark_name: str) -> None:
|
|
361
|
+
super().__init__()
|
|
362
|
+
self.mark_name = mark_name
|
|
363
|
+
|
|
364
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
365
|
+
# If called with a single argument that's a function, it's @mark.name
|
|
366
|
+
if (
|
|
367
|
+
len(args) == 1
|
|
368
|
+
and not kwargs
|
|
369
|
+
and callable(args[0])
|
|
370
|
+
and hasattr(args[0], "__name__")
|
|
371
|
+
):
|
|
372
|
+
decorator = MarkDecorator(self.mark_name, (), {})
|
|
373
|
+
return decorator(args[0])
|
|
374
|
+
# Otherwise it's @mark.name(args) - return a decorator
|
|
375
|
+
return MarkDecorator(self.mark_name, args, kwargs)
|
|
376
|
+
|
|
377
|
+
return _MarkDecoratorFactory(name)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# Create a singleton instance
|
|
381
|
+
mark = MarkGenerator()
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class ExceptionInfo:
|
|
385
|
+
"""Information about an exception caught by raises().
|
|
386
|
+
|
|
387
|
+
Attributes:
|
|
388
|
+
type: The exception type
|
|
389
|
+
value: The exception instance
|
|
390
|
+
traceback: The exception traceback
|
|
391
|
+
"""
|
|
392
|
+
|
|
393
|
+
def __init__(
|
|
394
|
+
self, exc_type: type[BaseException], exc_value: BaseException, exc_tb: Any
|
|
395
|
+
) -> None:
|
|
396
|
+
super().__init__()
|
|
397
|
+
self.type = exc_type
|
|
398
|
+
self.value = exc_value
|
|
399
|
+
self.traceback = exc_tb
|
|
400
|
+
|
|
401
|
+
def __repr__(self) -> str:
|
|
402
|
+
return f"<ExceptionInfo {self.type.__name__}({self.value!r})>"
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class RaisesContext:
|
|
406
|
+
"""Context manager for asserting that code raises a specific exception.
|
|
407
|
+
|
|
408
|
+
This mimics pytest.raises() behavior, supporting:
|
|
409
|
+
- Single or tuple of exception types
|
|
410
|
+
- Optional regex matching of exception messages
|
|
411
|
+
- Access to caught exception information
|
|
412
|
+
|
|
413
|
+
Usage:
|
|
414
|
+
with raises(ValueError):
|
|
415
|
+
int("not a number")
|
|
416
|
+
|
|
417
|
+
with raises(ValueError, match="invalid literal"):
|
|
418
|
+
int("not a number")
|
|
419
|
+
|
|
420
|
+
with raises((ValueError, TypeError)):
|
|
421
|
+
some_function()
|
|
422
|
+
|
|
423
|
+
# Access the caught exception
|
|
424
|
+
with raises(ValueError) as exc_info:
|
|
425
|
+
raise ValueError("oops")
|
|
426
|
+
assert "oops" in str(exc_info.value)
|
|
427
|
+
"""
|
|
428
|
+
|
|
429
|
+
def __init__(
|
|
430
|
+
self,
|
|
431
|
+
exc_type: type[BaseException] | tuple[type[BaseException], ...],
|
|
432
|
+
*,
|
|
433
|
+
match: str | None = None,
|
|
434
|
+
) -> None:
|
|
435
|
+
super().__init__()
|
|
436
|
+
self.exc_type = exc_type
|
|
437
|
+
self.match_pattern = match
|
|
438
|
+
self.excinfo: ExceptionInfo | None = None
|
|
439
|
+
|
|
440
|
+
def __enter__(self) -> RaisesContext:
|
|
441
|
+
return self
|
|
442
|
+
|
|
443
|
+
def __exit__(
|
|
444
|
+
self,
|
|
445
|
+
exc_type: type[BaseException] | None,
|
|
446
|
+
exc_val: BaseException | None,
|
|
447
|
+
exc_tb: Any,
|
|
448
|
+
) -> bool:
|
|
449
|
+
# No exception was raised
|
|
450
|
+
if exc_type is None:
|
|
451
|
+
exc_name = self._format_exc_name()
|
|
452
|
+
msg = f"DID NOT RAISE {exc_name}"
|
|
453
|
+
raise AssertionError(msg)
|
|
454
|
+
|
|
455
|
+
# At this point, we know an exception was raised, so exc_val cannot be None
|
|
456
|
+
assert exc_val is not None, "exc_val must not be None when exc_type is not None"
|
|
457
|
+
|
|
458
|
+
# Check if the exception type matches
|
|
459
|
+
if not issubclass(exc_type, self.exc_type):
|
|
460
|
+
# Unexpected exception type - let it propagate
|
|
461
|
+
return False
|
|
462
|
+
|
|
463
|
+
# Store the exception information
|
|
464
|
+
self.excinfo = ExceptionInfo(exc_type, exc_val, exc_tb)
|
|
465
|
+
|
|
466
|
+
# Check if the message matches the pattern (if provided)
|
|
467
|
+
if self.match_pattern is not None:
|
|
468
|
+
import re
|
|
469
|
+
|
|
470
|
+
exc_message = str(exc_val)
|
|
471
|
+
if not re.search(self.match_pattern, exc_message):
|
|
472
|
+
msg = (
|
|
473
|
+
f"Pattern {self.match_pattern!r} does not match "
|
|
474
|
+
f"{exc_message!r}. Exception: {exc_type.__name__}: {exc_message}"
|
|
475
|
+
)
|
|
476
|
+
raise AssertionError(msg)
|
|
477
|
+
|
|
478
|
+
# Suppress the exception (it was expected)
|
|
479
|
+
return True
|
|
480
|
+
|
|
481
|
+
def _format_exc_name(self) -> str:
|
|
482
|
+
"""Format the expected exception name(s) for error messages."""
|
|
483
|
+
if isinstance(self.exc_type, tuple):
|
|
484
|
+
names = " or ".join(exc.__name__ for exc in self.exc_type)
|
|
485
|
+
return names
|
|
486
|
+
return self.exc_type.__name__
|
|
487
|
+
|
|
488
|
+
@property
|
|
489
|
+
def value(self) -> BaseException:
|
|
490
|
+
"""Access the caught exception value."""
|
|
491
|
+
if self.excinfo is None:
|
|
492
|
+
msg = "No exception was caught"
|
|
493
|
+
raise AttributeError(msg)
|
|
494
|
+
return self.excinfo.value
|
|
495
|
+
|
|
496
|
+
@property
|
|
497
|
+
def type(self) -> type[BaseException]:
|
|
498
|
+
"""Access the caught exception type."""
|
|
499
|
+
if self.excinfo is None:
|
|
500
|
+
msg = "No exception was caught"
|
|
501
|
+
raise AttributeError(msg)
|
|
502
|
+
return self.excinfo.type
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def raises(
|
|
506
|
+
exc_type: type[BaseException] | tuple[type[BaseException], ...],
|
|
507
|
+
*,
|
|
508
|
+
match: str | None = None,
|
|
509
|
+
) -> RaisesContext:
|
|
510
|
+
"""Assert that code raises a specific exception.
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
exc_type: The expected exception type(s). Can be a single type or tuple of types.
|
|
514
|
+
match: Optional regex pattern to match against the exception message.
|
|
515
|
+
|
|
516
|
+
Returns:
|
|
517
|
+
A context manager that catches and validates the exception.
|
|
518
|
+
|
|
519
|
+
Raises:
|
|
520
|
+
AssertionError: If no exception is raised, or if the message doesn't match.
|
|
521
|
+
|
|
522
|
+
Usage:
|
|
523
|
+
with raises(ValueError):
|
|
524
|
+
int("not a number")
|
|
525
|
+
|
|
526
|
+
with raises(ValueError, match="invalid literal"):
|
|
527
|
+
int("not a number")
|
|
528
|
+
|
|
529
|
+
with raises((ValueError, TypeError)):
|
|
530
|
+
some_function()
|
|
531
|
+
|
|
532
|
+
# Access the caught exception
|
|
533
|
+
with raises(ValueError) as exc_info:
|
|
534
|
+
raise ValueError("oops")
|
|
535
|
+
assert "oops" in str(exc_info.value)
|
|
536
|
+
"""
|
|
537
|
+
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
|
+
...
|