rustest 0.1.0__cp311-cp311-macosx_11_0_arm64.whl → 0.5.0__cp311-cp311-macosx_11_0_arm64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rustest/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
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from collections.abc import Iterable
6
6
  from dataclasses import dataclass
7
7
 
8
- from . import _rust
8
+ from . import rust
9
9
 
10
10
 
11
11
  @dataclass(slots=True)
@@ -23,7 +23,7 @@ class TestResult:
23
23
  stderr: str | None
24
24
 
25
25
  @classmethod
26
- def from_py(cls, result: _rust.PyTestResult) -> "TestResult":
26
+ def from_py(cls, result: rust.PyTestResult) -> "TestResult":
27
27
  return cls(
28
28
  name=result.name,
29
29
  path=result.path,
@@ -47,7 +47,7 @@ class RunReport:
47
47
  results: tuple[TestResult, ...]
48
48
 
49
49
  @classmethod
50
- def from_py(cls, report: _rust.PyRunReport) -> "RunReport":
50
+ def from_py(cls, report: rust.PyRunReport) -> "RunReport":
51
51
  return cls(
52
52
  total=report.total,
53
53
  passed=report.passed,
Binary file
@@ -19,5 +19,5 @@ def run(
19
19
  """Placeholder implementation that mirrors the extension signature."""
20
20
 
21
21
  raise NotImplementedError(
22
- "The rustest native extension is unavailable. Tests must patch rustest._rust.run."
22
+ "The rustest native extension is unavailable. Tests must patch rustest.rust.run."
23
23
  )
@@ -1,11 +1,11 @@
1
- """Type stubs for the Rust extension module."""
1
+ """Type stubs for the rustest Rust extension module."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from collections.abc import Sequence
5
+ from typing import Sequence
6
6
 
7
7
  class PyTestResult:
8
- """Test result from Rust layer."""
8
+ """Individual test result from the Rust extension."""
9
9
 
10
10
  name: str
11
11
  path: str
@@ -16,20 +16,22 @@ class PyTestResult:
16
16
  stderr: str | None
17
17
 
18
18
  class PyRunReport:
19
- """Run report from Rust layer."""
19
+ """Test run report from the Rust extension."""
20
20
 
21
21
  total: int
22
22
  passed: int
23
23
  failed: int
24
24
  skipped: int
25
25
  duration: float
26
- results: Sequence[PyTestResult]
26
+ results: list[PyTestResult]
27
27
 
28
28
  def run(
29
- paths: list[str],
29
+ paths: Sequence[str],
30
30
  pattern: str | None,
31
+ mark_expr: str | None,
31
32
  workers: int | None,
32
33
  capture_output: bool,
34
+ enable_codeblocks: bool,
33
35
  ) -> PyRunReport:
34
- """Run tests and return a report."""
36
+ """Execute tests and return a report."""
35
37
  ...