assertpy 2.35.0__py3-none-any.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.
Files changed (63) hide show
  1. assertpy/__init__.py +87 -0
  2. assertpy/_clustering.py +511 -0
  3. assertpy/_dangling.py +505 -0
  4. assertpy/_engine/__init__.py +1 -0
  5. assertpy/_engine/_builder_check_typing.py +1365 -0
  6. assertpy/_engine/_capable_typing.py +772 -0
  7. assertpy/_engine/_check_typing.py +753 -0
  8. assertpy/_engine/_compare.py +983 -0
  9. assertpy/_engine/_compat.py +38 -0
  10. assertpy/_engine/_contract.py +1926 -0
  11. assertpy/_engine/_diff.py +1058 -0
  12. assertpy/_engine/_equality.py +843 -0
  13. assertpy/_engine/_introspection.py +729 -0
  14. assertpy/_engine/_membership.py +522 -0
  15. assertpy/_engine/_mixin_base.py +258 -0
  16. assertpy/_engine/_negated_typing.py +2176 -0
  17. assertpy/_engine/_operations.py +75 -0
  18. assertpy/_engine/_ordering.py +884 -0
  19. assertpy/_engine/_pairing.py +70 -0
  20. assertpy/_engine/_path.py +158 -0
  21. assertpy/_engine/_poll_typing.py +6039 -0
  22. assertpy/_engine/_require.py +312 -0
  23. assertpy/_engine/_size.py +31 -0
  24. assertpy/_engine/_text.py +39 -0
  25. assertpy/_engine/_typing.py +1226 -0
  26. assertpy/_hints.py +1213 -0
  27. assertpy/_inline.py +484 -0
  28. assertpy/_matcher_impls.py +1876 -0
  29. assertpy/_passes.py +249 -0
  30. assertpy/_renamed.py +64 -0
  31. assertpy/_satisfies.py +835 -0
  32. assertpy/_snapshot_codec.py +290 -0
  33. assertpy/assertpy.py +2640 -0
  34. assertpy/async_assertions.py +864 -0
  35. assertpy/base.py +862 -0
  36. assertpy/behave_matchers.py +96 -0
  37. assertpy/bytes_mixin.py +158 -0
  38. assertpy/collection.py +528 -0
  39. assertpy/contains.py +1068 -0
  40. assertpy/dataframe.py +200 -0
  41. assertpy/date.py +327 -0
  42. assertpy/dict.py +249 -0
  43. assertpy/dynamic.py +222 -0
  44. assertpy/errors.py +782 -0
  45. assertpy/exception.py +838 -0
  46. assertpy/extracting.py +266 -0
  47. assertpy/file.py +324 -0
  48. assertpy/helpers.py +747 -0
  49. assertpy/http_mixin.py +218 -0
  50. assertpy/json_mixin.py +1265 -0
  51. assertpy/matchers.py +639 -0
  52. assertpy/numeric.py +696 -0
  53. assertpy/outcome.py +163 -0
  54. assertpy/py.typed +0 -0
  55. assertpy/pytest_plugin.py +1650 -0
  56. assertpy/snapshot.py +1031 -0
  57. assertpy/string.py +803 -0
  58. assertpy/warning.py +137 -0
  59. assertpy-2.35.0.dist-info/METADATA +324 -0
  60. assertpy-2.35.0.dist-info/RECORD +63 -0
  61. assertpy-2.35.0.dist-info/WHEEL +4 -0
  62. assertpy-2.35.0.dist-info/entry_points.txt +2 -0
  63. assertpy-2.35.0.dist-info/licenses/LICENSE +28 -0
assertpy/exception.py ADDED
@@ -0,0 +1,838 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import types
5
+ from collections import Counter
6
+ from typing import TYPE_CHECKING, Any, Final, TypeVar, cast, get_origin
7
+
8
+ from ._engine._compat import BaseExceptionGroup
9
+ from ._engine._mixin_base import _MixinBase
10
+ from ._engine._pairing import maximum_pairing
11
+ from ._engine._require import argument, refuse
12
+ from ._matcher_impls import _UNION_ORIGINS, _require_class_info
13
+ from .errors import _callable_name, _safe_str, _type_expression_name
14
+ from .outcome import AssertionOutcome
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Iterator
18
+
19
+ from ._engine._compat import Self
20
+ from ._matcher_impls import ClassInfo, ExceptionInfo
21
+
22
+ __tracebackhide__ = True
23
+
24
+ _UNSET: Final = object() # sentinel: no return value / exception captured yet
25
+
26
+ _E = TypeVar("_E", bound=BaseException)
27
+
28
+
29
+ def _nodes(exc: BaseException) -> Iterator[BaseException]:
30
+ """Every exception in the tree, groups included, the group itself before its members.
31
+
32
+ What `BaseExceptionGroup.subgroup()` matches against, in the order it meets them.
33
+
34
+ Iterative, and lazy, for the depth: `subgroup()` is written in C and answers on a group nested three
35
+ thousand deep, where a recursive walk in Python gives up around five hundred. Yielding also lets a
36
+ caller looking for one exception stop at it instead of building the whole tree first.
37
+ """
38
+ pending = [exc]
39
+ while pending:
40
+ node = pending.pop()
41
+ yield node
42
+ if isinstance(node, BaseExceptionGroup):
43
+ pending.extend(reversed(node.exceptions))
44
+
45
+
46
+ def _leaves(exc: BaseException) -> list[BaseException]:
47
+ """The same walk without the groups: what actually failed, rather than what holds it."""
48
+ return [node for node in _nodes(exc) if not isinstance(node, BaseExceptionGroup)]
49
+
50
+
51
+ def _require_exception_type(ex: object) -> type[BaseException]:
52
+ """Refuse anything that is not an exception class, in the shape `raises()` already refuses it.
53
+
54
+ `isinstance()` and `issubclass()` each report their own argument position and neither names the
55
+ assertion, and `isinstance(node, str)` does not complain at all: it answers False, so a mistyped
56
+ argument would read as "the group does not contain <str>" rather than as the mistake it is.
57
+ """
58
+ if not (isinstance(ex, type) and issubclass(ex, BaseException)):
59
+ refuse(ex, "an exception type", subject=argument("exception"))
60
+ return ex
61
+
62
+
63
+ _AN_EXCEPTION: Final = "an exception type, or a union or a non-empty tuple of them"
64
+
65
+
66
+ def named_classes(
67
+ spec: object, base: type[_E], expectation: str, *, subject: str, allow_empty: bool = False
68
+ ) -> tuple[type[_E], ...]:
69
+ """Every class *spec* names, through tuples and unions to any depth, each refused unless it subclasses *base*.
70
+
71
+ Read whole before anything is called, so a member that is no class, after one that is, is refused where
72
+ it is written rather than once the call raised something it would have been asked about. A tuple is read
73
+ from its storage, as `isinstance` reads it. An empty one names nothing that could ever be met, and is
74
+ refused unless *allow_empty* says nothing is a choice there, as `pytest.raises(())` refuses it.
75
+ """
76
+ pending: list[object] = [spec]
77
+ found: list[type[_E]] = []
78
+ while pending:
79
+ member = pending.pop()
80
+ if isinstance(member, tuple):
81
+ pending.extend(tuple.__getitem__(member, slice(None, None, -1)))
82
+ elif isinstance(member, types.UnionType) or get_origin(member) in _UNION_ORIGINS:
83
+ pending.extend(cast("Any", member).__args__[::-1])
84
+ elif isinstance(member, type) and issubclass(member, base):
85
+ found.append(member)
86
+ else:
87
+ refuse(spec, expectation, subject=argument(subject))
88
+ if not found and not allow_empty:
89
+ refuse(spec, expectation, subject=argument(subject))
90
+ return tuple(found)
91
+
92
+
93
+ def _expected_classes(spec: object) -> type[BaseException] | ExceptionInfo:
94
+ """*spec* as it was written, once every class it names is read as an exception.
95
+
96
+ Kept as written for the message and for `expected`, as `caused_by` keeps it: `issubclass` and `isinstance`
97
+ read a tuple and either spelling of a union on every Python this package supports.
98
+ """
99
+ named_classes(spec, BaseException, _AN_EXCEPTION, subject="exception")
100
+ return cast("type[BaseException] | ExceptionInfo", spec)
101
+
102
+
103
+ def _shaped(spec: object, at: str) -> object:
104
+ """Validate one node of a tree spec, returning it, and refuse anything the matcher cannot read.
105
+
106
+ A `list` is a subgroup and anything else has to be an exception type. A `tuple` is refused by name:
107
+ elsewhere in this library a tuple of types means "any of these", and reading it as a subgroup here
108
+ would give one spelling two meanings.
109
+ """
110
+ if isinstance(spec, list):
111
+ if not spec:
112
+ refuse(
113
+ spec,
114
+ f"a non-empty subgroup at {at}, since a group with no members cannot be raised",
115
+ subject=argument("expected"),
116
+ )
117
+ return [_shaped(member, f"{at}[{index}]") for index, member in enumerate(spec)]
118
+ if isinstance(spec, tuple):
119
+ refuse(spec, f"a list for a subgroup at {at}, not a tuple", subject=argument("expected"))
120
+ return _require_exception_type(spec)
121
+
122
+
123
+ def _matches_shape(spec: list[Any], exceptions: tuple[BaseException, ...]) -> bool:
124
+ """Whether every spec entry pairs with a distinct exception, and none is left over.
125
+
126
+ A complete matching rather than a greedy walk, and the difference is observable: pairing in the
127
+ order written makes `(Exception, ValueError)` refuse a group of `ValueError` and `KeyError` while
128
+ `(ValueError, Exception)` accepts it, so the same claim would depend on how it was typed. Measured
129
+ against `pytest.RaisesGroup`, which does pair greedily and refuses the first spelling.
130
+
131
+ Recursion is over the *spec*, not the tree: a type entry stops the descent whatever is under it, so
132
+ the depth is what somebody wrote by hand rather than what a task group nested.
133
+ """
134
+ if len(spec) != len(exceptions):
135
+ return False
136
+ candidates = [
137
+ {
138
+ index
139
+ for index, exc in enumerate(exceptions)
140
+ if isinstance(exc, BaseExceptionGroup) and _matches_shape(entry, exc.exceptions)
141
+ }
142
+ if isinstance(entry, list)
143
+ else {index for index, exc in enumerate(exceptions) if isinstance(exc, entry)}
144
+ for entry in spec
145
+ ]
146
+ return len(maximum_pairing(candidates)) == len(spec)
147
+
148
+
149
+ def _naming(spec: list[Any], exc: BaseException) -> dict[int, str]:
150
+ """A name per class across both halves of the message, distinct however alike the classes are.
151
+
152
+ Two classes called `Error` from different modules otherwise render a mismatch as two identical trees,
153
+ which reads as the assertion contradicting itself. Qualifying is enough for those and is applied only
154
+ where the short names collide, following `_disambiguated`: the common case keeps the short name.
155
+
156
+ Module and qualified name do not settle it either. A class defined inside a function has both fixed,
157
+ so two calls to that function give two classes indistinguishable by name, and a reloaded module gives
158
+ the same. Those get an ordinal, ordered by where they were met rather than by identity, since a set
159
+ of classes iterates differently between runs and a failure message has to read the same twice.
160
+
161
+ Keyed by `id`, because a class is only usually hashable: a metaclass setting `__hash__ = None` turned
162
+ the failure into a `TypeError`, where the other three group assertions reported it and carried on.
163
+ """
164
+
165
+ def in_spec(entries: list[Any]) -> Iterator[type]:
166
+ for entry in entries:
167
+ yield from in_spec(entry) if isinstance(entry, list) else (cast("type", entry),)
168
+
169
+ kinds: dict[int, type] = {}
170
+ for kind in in_spec(spec):
171
+ kinds.setdefault(id(kind), kind)
172
+ for node in _nodes(exc):
173
+ kinds.setdefault(id(type(node)), type(node))
174
+ short = Counter(kind.__name__ for kind in kinds.values())
175
+ qualified = {
176
+ key: f"{kind.__module__}.{kind.__qualname__}" if short[kind.__name__] > 1 else kind.__name__
177
+ for key, kind in kinds.items()
178
+ }
179
+ still_alike = Counter(qualified.values())
180
+ ordinals: Counter[str] = Counter()
181
+ names = {}
182
+ for key, label in qualified.items():
183
+ ordinals[label] += 1
184
+ names[key] = label if still_alike[label] == 1 else f"{label}#{ordinals[label]}"
185
+ return names
186
+
187
+
188
+ def _shape_of(exc: BaseException, names: dict[int, str]) -> str:
189
+ """The tree as names, for the message: a group becomes a bracketed list of what it holds.
190
+
191
+ Built as text rather than as a list of strings, because the list would print its names in quotes and
192
+ a reader comparing two trees would be reading around them.
193
+
194
+ Iterative, because this runs on the failure path over a tree the program under test built rather than
195
+ over anything written by hand: a group 3000 deep turned the assertion into a `RecursionError`.
196
+
197
+ Emitted as pieces joined once, rather than as each node's finished text, because the latter has every
198
+ ancestor holding a copy of its child: 16000 deep cost 123 ms and 258 MB of peak for 32 KB of output.
199
+ """
200
+ pieces: list[str] = []
201
+ pending: list[BaseException | str] = [exc]
202
+ while pending:
203
+ node = pending.pop()
204
+ if isinstance(node, str):
205
+ pieces.append(node)
206
+ elif isinstance(node, BaseExceptionGroup):
207
+ pieces.append("[")
208
+ pending.append("]")
209
+ for position, member in enumerate(reversed(node.exceptions)):
210
+ if position:
211
+ pending.append(", ")
212
+ pending.append(member)
213
+ else:
214
+ pieces.append(names[id(type(node))])
215
+ return "".join(pieces)
216
+
217
+
218
+ def _spec_shape(spec: object, names: dict[int, str]) -> str:
219
+ """The same rendering for a spec, so the two halves of the message line up."""
220
+ if isinstance(spec, list):
221
+ return f"[{', '.join(_spec_shape(entry, names) for entry in spec)}]"
222
+ return names[id(spec)]
223
+
224
+
225
+ def _escaped(exc: BaseException) -> bool:
226
+ """Whether *exc* is the runner's to act on rather than a mismatch to report.
227
+
228
+ A probe interrupted by ``KeyboardInterrupt``, or one that called ``sys.exit()``, did not answer the
229
+ question the assertion asked. Reported as a mismatch, Ctrl+C turned into a red test, and
230
+ ``does_not_raise()`` swallowed it whole, so neither reached the runner. Asking for one of them by
231
+ name still works: the caller's own branch catches what it named before this is consulted.
232
+ """
233
+ return not isinstance(exc, Exception)
234
+
235
+
236
+ def _first_of(exc: BaseException, ex: type | ExceptionInfo) -> BaseException | None:
237
+ """The first exception of type *ex* in the tree, or ``None``.
238
+
239
+ The one place the three group assertions ask their question, so they cannot answer it differently.
240
+ `BaseExceptionGroup.subgroup()` answers the same on a stock group, but it is a method a subclass may
241
+ override, which would split the verdicts between whichever assertions called it.
242
+ """
243
+ return next((node for node in _nodes(exc) if isinstance(node, ex)), None)
244
+
245
+
246
+ def _effective_cause(exc: BaseException) -> BaseException | None:
247
+ """The exception that chained into *exc*: explicit ``__cause__`` (``raise ... from``), else the
248
+ implicit ``__context__`` (a raise during handling), unless the context was suppressed."""
249
+ if exc.__cause__ is not None:
250
+ return exc.__cause__
251
+ if not exc.__suppress_context__:
252
+ return exc.__context__
253
+ return None
254
+
255
+
256
+ def _require_synchronous_result(result: object, func: object) -> None:
257
+ """Refuse a call that handed back something whose outcome comes later: an awaitable or an async generator.
258
+
259
+ Judged as it stood, `does_not_raise()` passed on an `async def` that raises when awaited, and `raises()`
260
+ failed on it. Awaitable means what `await` accepts, a type with `__await__` or a generator-based
261
+ coroutine, not a class registered with the ABC and lacking one. A plain generator is not refused: the
262
+ call does not run its body either, but it can be consumed synchronously, which assertpy always allowed.
263
+
264
+ A coroutine the call created, from a coroutine function, is closed so it does not warn that it was
265
+ never awaited. One handed back from elsewhere, and a future or a task, is left alone: closing or
266
+ cancelling it would be a side effect on somebody else's work.
267
+ """
268
+ # the type is asked first: `inspect.isawaitable` on a plain value cost 157 ns, this 38
269
+ if not (hasattr(type(result), "__await__") or isinstance(result, (types.GeneratorType, types.AsyncGeneratorType))):
270
+ return
271
+ if inspect.isasyncgen(result):
272
+ remedy = "iterate it with `async for` to assert on what it does"
273
+ elif inspect.isawaitable(result):
274
+ remedy = "await the call yourself to assert on its result, exception or warning"
275
+ created = inspect.iscoroutinefunction(func) or inspect.iscoroutinefunction(type(func).__call__)
276
+ if created and inspect.iscoroutine(result):
277
+ result.close()
278
+ else:
279
+ return
280
+ raise TypeError(
281
+ f"<{_callable_name(func)}> returned {type(result).__name__}, whose outcome when_called_with() cannot see;"
282
+ f" {remedy}, and use eventually() only to poll an async value"
283
+ )
284
+
285
+
286
+ class _InertBuilder:
287
+ """No-op builder returned after a failed raises/when_called_with in soft mode.
288
+
289
+ Silently absorbs all chained assertions so they don't crash on wrong val type. What it must not
290
+ absorb is a name the typed surface declares as data: every lambda is truthy, so `passed` read as a
291
+ pass and `.value` read as a value that had been checked.
292
+ """
293
+
294
+ def __init__(self, reason: str | None = None) -> None:
295
+ self._reason = reason
296
+
297
+ def _refusal(self, name: str) -> str:
298
+ """Why the accessor refuses, carrying the failure that made this builder inert.
299
+
300
+ The reason and not only the rule: the ordinary builder's `.value` names the root failure, and a
301
+ refusal without it sends the reader looking for which assertion went wrong.
302
+ """
303
+ because = f" - {self._reason}" if self._reason else ""
304
+ return (
305
+ f"cannot extract .{name}: the underlying assertion failed under soft or warn mode{because}"
306
+ f" (read .{name} in strict mode, or after the soft-assertions block)"
307
+ )
308
+
309
+ @property
310
+ def value(self) -> object:
311
+ """The refusal `.value` promises after a failure under soft or warn mode.
312
+
313
+ Absorbed like everything else, it handed back the absorbing lambda, which is truthy and reads as
314
+ a value that passed.
315
+ """
316
+ raise TypeError(self._refusal("value"))
317
+
318
+ @property
319
+ def val(self) -> object:
320
+ """The same refusal, for the name a polling chain hands the value back under.
321
+
322
+ A chain declares `val` where the builder declares `value`, so absorbing it handed a typed
323
+ accessor the absorbing lambda instead of the value it promises.
324
+ """
325
+ raise TypeError(self._refusal("val"))
326
+
327
+ def check(self) -> _InertVerdict:
328
+ """A verdict about a chain that already failed, which is what made this builder inert.
329
+
330
+ Absorbed, it answered with this builder, whose `passed` and `message` are the absorbing lambda:
331
+ `passed` is declared `bool` and every lambda is truthy, so a failed chain read as a pass. It
332
+ answers rather than raising, because reading a verdict is the one thing `check()` promises and
333
+ there is a real failure to report.
334
+ """
335
+ return _InertVerdict(self._reason or "")
336
+
337
+ @property
338
+ def not_(self) -> _InertBuilder:
339
+ """This chain again: nothing after its failure runs, so there is nothing to invert either.
340
+
341
+ Absorbed, it was the absorbing lambda, and the assertion named after it an `AttributeError`.
342
+ """
343
+ return self
344
+
345
+ def __getattr__(self, name):
346
+ return lambda *args, **kwargs: self
347
+
348
+
349
+ class _InertVerdict:
350
+ """The verdict of a chain that failed before the assertion being asked about ran.
351
+
352
+ Every assertion on it answers with the same outcome: the failure that made the chain inert is the
353
+ one a reader acts on, and the assertion after it never ran to have a verdict of its own.
354
+ """
355
+
356
+ def __init__(self, reason: str) -> None:
357
+ self._outcome = AssertionOutcome(passed=False, message=reason)
358
+
359
+ @property
360
+ def not_(self) -> _InertVerdict:
361
+ """The same verdict, since a negated assertion after the failure never ran either."""
362
+ return self
363
+
364
+ def __getattr__(self, name: str) -> Any:
365
+ return lambda *args, **kwargs: self._outcome
366
+
367
+
368
+ class ExceptionMixin(_MixinBase):
369
+ """Expected exception mixin."""
370
+
371
+ def raises(self, ex: type | ExceptionInfo) -> Self:
372
+ """Asserts that val is callable and set the expected exception.
373
+
374
+ Just sets the expected exception, but never calls val, and therefore never fails. You must
375
+ chain to [`when_called_with()`][assertpy.exception.ExceptionMixin.when_called_with] to invoke ``val()``.
376
+
377
+ Args:
378
+ ex: the expected exception, or a union or a tuple of them any one of which will do
379
+
380
+ Examples:
381
+ Usage:
382
+
383
+ assert_that(some_func).raises(RuntimeError).when_called_with('foo')
384
+
385
+ Returns:
386
+ AssertionBuilder: returns a new instance (with the expected exception) to chain the next assertion
387
+ """
388
+ if not callable(self.val):
389
+ refuse(self.val, "callable")
390
+ expected = _expected_classes(ex)
391
+
392
+ return self.builder(self.val, self.description, self.kind, expected, self.logger)
393
+
394
+ def when_called_with(self, *some_args: object, **some_kwargs: object) -> Self:
395
+ """Asserts that val, when invoked with the given args and kwargs, meets the set expectation.
396
+
397
+ Invokes ``val()`` with the given args and kwargs. You must first set an expectation with
398
+ [`raises()`][assertpy.exception.ExceptionMixin.raises] or
399
+ [`does_not_raise()`][assertpy.exception.ExceptionMixin.does_not_raise] (expected exception),
400
+ or with
401
+ [`warns()`][assertpy.warning.WarningMixin.warns] or
402
+ [`does_not_warn()`][assertpy.warning.WarningMixin.does_not_warn] (expected warning).
403
+
404
+ Only what happens before the call returns is judged. A call that hands back an awaitable or an
405
+ async generator is refused with ``TypeError``, since what it does when awaited or iterated comes
406
+ later. A plain generator is judged as returned: its body runs only when it is consumed.
407
+
408
+ Args:
409
+ *some_args: the args to call ``val()``
410
+ **some_kwargs: the kwargs to call ``val()``
411
+
412
+ Examples:
413
+ Usage:
414
+
415
+ def some_func(a):
416
+ raise RuntimeError('some error!')
417
+
418
+ assert_that(some_func).raises(RuntimeError).when_called_with('foo')
419
+
420
+ Returns:
421
+ AssertionBuilder: returns a new instance (now with the captured exception or warning
422
+ message as the val) to chain to the next assertion
423
+
424
+ Raises:
425
+ AssertionError: if val does **not** meet the set expectation
426
+ TypeError: if no expectation set first
427
+ """
428
+ if self._expected_warning is not None:
429
+ if self._not_expected:
430
+ return self._when_called_with_not_warning(self._expected_warning, *some_args, **some_kwargs)
431
+ return self._when_called_with_warning(self._expected_warning, *some_args, **some_kwargs)
432
+
433
+ if not self.expected:
434
+ raise TypeError("no expectation set; call raises(), warns() or a does_not_* method first")
435
+
436
+ if getattr(self, "_not_expected", False):
437
+ return self._when_called_with_not_expected(*some_args, **some_kwargs)
438
+
439
+ try:
440
+ result = self.val(*some_args, **some_kwargs)
441
+ except BaseException as e:
442
+ if issubclass(type(e), self.expected):
443
+ captured = self.builder(_safe_str(e), self.description, self.kind, logger=self.logger)
444
+ captured._raised_exception = e
445
+ return captured
446
+ elif _escaped(e):
447
+ raise
448
+ else:
449
+ self.error(
450
+ f"Expected <{_callable_name(self.val)}> to raise <{_type_expression_name(self.expected)}>"
451
+ f" when called with ({self._fmt_args_kwargs(*some_args, **some_kwargs)}),"
452
+ f" but raised <{type(e).__name__}>.",
453
+ expected=self.expected,
454
+ )
455
+ return cast("Self", _InertBuilder(self._value_taint_reason))
456
+
457
+ _require_synchronous_result(result, self.val)
458
+ self.error(
459
+ f"Expected <{_callable_name(self.val)}> to raise <{_type_expression_name(self.expected)}>"
460
+ f" when called with ({self._fmt_args_kwargs(*some_args, **some_kwargs)}).",
461
+ expected=self.expected,
462
+ )
463
+ return cast("Self", _InertBuilder(self._value_taint_reason))
464
+
465
+ def returned(self) -> Self:
466
+ """Pivots the chain to the value ``val()`` returned during
467
+ [`when_called_with()`][assertpy.exception.ExceptionMixin.when_called_with].
468
+
469
+ Use after a call that completed normally ([`warns()`][assertpy.warning.WarningMixin.warns],
470
+ [`does_not_warn()`][assertpy.warning.WarningMixin.does_not_warn], or
471
+ [`does_not_raise()`][assertpy.exception.ExceptionMixin.does_not_raise]) to assert
472
+ on the return value in the same chain.
473
+
474
+ Examples:
475
+ Usage:
476
+
477
+ assert_that(make_client).warns(DeprecationWarning).when_called_with().returned().is_instance_of(Client)
478
+ assert_that(adder).does_not_raise(TypeError).when_called_with(1, 2).returned().is_equal_to(3)
479
+
480
+ Returns:
481
+ AssertionBuilder: a new instance wrapping the captured return value
482
+
483
+ Raises:
484
+ TypeError: if no return value was captured (the call raised, or
485
+ [`when_called_with()`][assertpy.exception.ExceptionMixin.when_called_with]
486
+ was not invoked first)
487
+ """
488
+ if self._return_value is _UNSET:
489
+ raise TypeError("no return value captured; returned() is only valid after a call that completed normally")
490
+ return self.builder(self._return_value, self.description, self.kind, logger=self.logger)
491
+
492
+ def raised(self) -> Self:
493
+ """Pivots the chain to the exception object caught by
494
+ [`when_called_with()`][assertpy.exception.ExceptionMixin.when_called_with], to assert on its type,
495
+ ``args``, or custom attributes - not only its message string.
496
+
497
+ Examples:
498
+ Usage:
499
+
500
+ err = assert_that(load).raises(ConfigError).when_called_with("bad").raised().value
501
+ assert_that(err.code).is_equal_to(42)
502
+
503
+ Returns:
504
+ AssertionBuilder: a new instance wrapping the caught exception object
505
+
506
+ Raises:
507
+ TypeError: if no exception was captured (the call did not raise, or
508
+ [`when_called_with()`][assertpy.exception.ExceptionMixin.when_called_with] was not invoked first)
509
+ """
510
+ exc = self._require_raised("raised")
511
+ return self.builder(exc, self.description, self.kind, logger=self.logger)
512
+
513
+ def caused_by(self, ex: ClassInfo) -> Self:
514
+ """Asserts the caught exception was chained from a cause of type ``ex`` (``raise ... from``, or an
515
+ exception raised during handling), then pivots the chain to that cause's message.
516
+
517
+ Examples:
518
+ Usage:
519
+
520
+ assert_that(save).raises(ServiceError).when_called_with(row).caused_by(TimeoutError)
521
+
522
+ Args:
523
+ ex: the expected cause type, or a union or tuple of them as `isinstance` takes
524
+
525
+ Returns:
526
+ AssertionBuilder: a new instance wrapping the cause's message (chain on it, or walk deeper)
527
+
528
+ Raises:
529
+ TypeError: if ``ex`` holds anything `isinstance` cannot take, checked member by member
530
+ """
531
+ exc = self._require_raised("caused_by")
532
+ cause = _effective_cause(exc)
533
+ _require_class_info(ex, name="exception", probe=cause)
534
+ if cause is None or not isinstance(cause, ex):
535
+ found = "no cause" if cause is None else f"<{type(cause).__name__}>"
536
+ expected_name = _type_expression_name(ex)
537
+ self.error(
538
+ f"Expected <{type(exc).__name__}> to be caused by <{expected_name}>, but the cause was {found}.",
539
+ expected=ex,
540
+ )
541
+ return cast("Self", _InertBuilder(self._value_taint_reason))
542
+ pivoted = self.builder(_safe_str(cause), self.description, self.kind, logger=self.logger)
543
+ pivoted._raised_exception = cause
544
+ return pivoted
545
+
546
+ def has_root_cause(self, ex: ClassInfo) -> Self:
547
+ """Asserts the *root* of the caught exception's cause chain is of type ``ex``, then pivots the chain
548
+ to that root cause's message.
549
+
550
+ Args:
551
+ ex: the expected root-cause type, or a union or tuple of them as `isinstance` takes
552
+
553
+ Returns:
554
+ AssertionBuilder: a new instance wrapping the root cause's message
555
+
556
+ Raises:
557
+ TypeError: if ``ex`` holds anything `isinstance` cannot take, checked member by member
558
+ """
559
+ exc = self._require_raised("has_root_cause")
560
+ root = exc
561
+ seen = {id(root)}
562
+ while (nxt := _effective_cause(root)) is not None and id(nxt) not in seen:
563
+ root = nxt
564
+ seen.add(id(root))
565
+ _require_class_info(ex, name="exception", probe=root)
566
+ if not isinstance(root, ex):
567
+ self.error(
568
+ f"Expected <{type(exc).__name__}> to have root cause <{_type_expression_name(ex)}>,"
569
+ f" but the root cause was <{type(root).__name__}>.",
570
+ expected=ex,
571
+ )
572
+ return cast("Self", _InertBuilder(self._value_taint_reason))
573
+ pivoted = self.builder(_safe_str(root), self.description, self.kind, logger=self.logger)
574
+ pivoted._raised_exception = root
575
+ return pivoted
576
+
577
+ def contains_error(self, *ex_types: type | ExceptionInfo) -> Self:
578
+ """Asserts the caught exception is an exception group that contains, recursively, an exception of
579
+ each given type (for [`raises(ExceptionGroup)`][assertpy.exception.ExceptionMixin.raises]).
580
+
581
+ A caught exception that is not a group fails with ``not_`` as well: negation inverts what the group
582
+ holds, never whether there is a group.
583
+
584
+ Examples:
585
+ Usage:
586
+
587
+ assert_that(run_tasks).raises(ExceptionGroup).when_called_with().contains_error(ValueError, KeyError)
588
+
589
+ Args:
590
+ *ex_types: the exception types the group must contain, each of which may be a union or a tuple
591
+ of them that any one class answers
592
+
593
+ Returns:
594
+ AssertionBuilder: this instance, to chain further assertions on the group
595
+
596
+ Raises:
597
+ ValueError: if called with no types at all, which nothing could fail
598
+ TypeError: if given anything but an exception class
599
+ """
600
+ if len(ex_types) == 0:
601
+ raise ValueError("one or more args must be given")
602
+ wanted = [_expected_classes(ex) for ex in ex_types]
603
+ exc = self._require_group("contains_error")
604
+ if exc is None:
605
+ return cast("Self", _InertBuilder(self._value_taint_reason))
606
+ for ex in wanted:
607
+ if _first_of(exc, ex) is None:
608
+ self.error(
609
+ f"Expected the raised exception group to contain <{_type_expression_name(ex)}>, but it did not.",
610
+ expected=ex,
611
+ )
612
+ return cast("Self", _InertBuilder(self._value_taint_reason))
613
+ return self
614
+
615
+ def does_not_contain_error(self, *ex_types: type | ExceptionInfo) -> Self:
616
+ """Asserts the caught exception group holds none of the given types, at any depth.
617
+
618
+ The none-of counterpart to [`contains_error()`][assertpy.exception.ExceptionMixin.contains_error]
619
+ rather than its negation: that one asks for every type given, this one refuses every type given.
620
+ With several arguments both can fail on the same group, which is what "some but not all" means.
621
+ A caught exception that is not a group fails it with or without ``not_``.
622
+
623
+ Examples:
624
+ Usage:
625
+
626
+ assert_that(run_tasks).raises(ExceptionGroup).when_called_with().does_not_contain_error(KeyError)
627
+
628
+ Args:
629
+ *ex_types: the exception types the group must not contain, each of which may be a union or a
630
+ tuple of them
631
+
632
+ Returns:
633
+ AssertionBuilder: this instance, to chain further assertions on the group
634
+
635
+ Raises:
636
+ ValueError: if called with no types at all, which nothing could fail
637
+ TypeError: if given anything but an exception class
638
+ """
639
+ if len(ex_types) == 0:
640
+ raise ValueError("one or more args must be given")
641
+ unwanted = [_expected_classes(ex) for ex in ex_types]
642
+ exc = self._require_group("does_not_contain_error")
643
+ if exc is None:
644
+ return cast("Self", _InertBuilder(self._value_taint_reason))
645
+ for ex in unwanted:
646
+ if _first_of(exc, ex) is not None:
647
+ self.error(
648
+ f"Expected the raised exception group to not contain <{_type_expression_name(ex)}>, but it did."
649
+ )
650
+ return cast("Self", _InertBuilder(self._value_taint_reason))
651
+ return self
652
+
653
+ def errors(self) -> Self:
654
+ """Pivots the chain to the list of leaf exceptions in the caught group, nested ones flattened.
655
+
656
+ Flattened rather than one level deep, because that is what
657
+ [`contains_error()`][assertpy.exception.ExceptionMixin.contains_error] already searches: a group
658
+ holding a group is an implementation detail of whoever raised it, and a suite asking "what
659
+ failed" means the leaves. The view this was reached from still holds the group, so
660
+ [`raised()`][assertpy.exception.ExceptionMixin.raised] on *that* answers with the whole tree when
661
+ the shape itself is the point. It is not offered on the leaves, which are a collection.
662
+
663
+ Examples:
664
+ Usage:
665
+
666
+ assert_that(run_tasks).raises(ExceptionGroup).when_called_with().errors().is_length(2)
667
+ assert_that(run_tasks).raises(ExceptionGroup).when_called_with().errors().extracting(
668
+ "args"
669
+ ).contains(("bad id",))
670
+
671
+ Returns:
672
+ AssertionBuilder: a new instance wrapping the leaves as a list, to ask a collection anything
673
+
674
+ Raises:
675
+ TypeError: if no exception was captured
676
+ """
677
+ exc = self._require_group("errors")
678
+ if exc is None:
679
+ return cast("Self", _InertBuilder(self._value_taint_reason))
680
+ return self.builder(_leaves(exc), self.description, self.kind, logger=self.logger)
681
+
682
+ def error_of(self, ex: type) -> Self:
683
+ """Asserts the caught group holds an exception of type ``ex``, then pivots to that one's message.
684
+
685
+ The step [`contains_error()`][assertpy.exception.ExceptionMixin.contains_error] cannot take: after
686
+ it the chain still holds the *group's* message, so asking what one failure said meant reaching into
687
+ the tree by hand.
688
+
689
+ Both search the same exceptions, groups included, so whatever one finds the other pivots to. Asking
690
+ for a group type therefore answers with that group, and for the outermost one that is the message
691
+ the chain already held. A caught exception that is not a group fails it with or without ``not_``.
692
+
693
+ Examples:
694
+ Usage:
695
+
696
+ assert_that(run_tasks).raises(ExceptionGroup).when_called_with().error_of(ValueError).contains("bad id")
697
+
698
+ Args:
699
+ ex: the type to pivot to, the first one found, the group itself before its members
700
+
701
+ Returns:
702
+ AssertionBuilder: a new instance wrapping that exception's message (chain on it, or
703
+ `raised()` to reach the object itself)
704
+
705
+ Raises:
706
+ TypeError: if given anything but an exception class
707
+ """
708
+ _require_exception_type(ex)
709
+ exc = self._require_group("error_of")
710
+ if exc is None:
711
+ return cast("Self", _InertBuilder(self._value_taint_reason))
712
+ found = _first_of(exc, ex)
713
+ if found is None:
714
+ self.error(f"Expected the raised exception group to contain <{ex.__name__}>, but it did not.", expected=ex)
715
+ return cast("Self", _InertBuilder(self._value_taint_reason))
716
+ pivoted = self.builder(_safe_str(found), self.description, self.kind, logger=self.logger)
717
+ pivoted._raised_exception = found
718
+ return pivoted
719
+
720
+ def matches_error_tree(self, *expected: type | list[Any]) -> Self:
721
+ """Asserts the caught group holds exactly these exceptions, in exactly this nesting.
722
+
723
+ The question [`contains_error()`][assertpy.exception.ExceptionMixin.contains_error] does not ask.
724
+ That one searches the whole tree for a type and says nothing about how many others there are or
725
+ how deep it sat, so a group that grew an extra failure, or one whose members moved into a
726
+ subgroup, still passes it. This one reads the shape.
727
+
728
+ A `list` is a subgroup and a type is one exception, so `(ValueError, [KeyError])` means a group of
729
+ two members whose second is a subgroup holding one `KeyError`. A type matches by `isinstance`,
730
+ the way the rest of the family does, so `Exception` matches a subgroup node as readily as a leaf,
731
+ and stops there: only a nested list looks inside one, and what a matched subgroup holds is unsaid.
732
+
733
+ Order is not part of the shape. Whoever raised the group usually did not choose it: an
734
+ `asyncio.TaskGroup` reports its failures in the order the tasks finished. A caught exception that
735
+ is not a group fails it with or without ``not_``.
736
+
737
+ Examples:
738
+ Usage:
739
+
740
+ assert_that(run_tasks).raises(ExceptionGroup).when_called_with().matches_error_tree(
741
+ ValueError, [KeyError, KeyError]
742
+ )
743
+
744
+ Args:
745
+ *expected: one entry per member of the group, a type for an exception and a list for a
746
+ subgroup
747
+
748
+ Returns:
749
+ AssertionBuilder: this instance, to chain further assertions on the group
750
+
751
+ Raises:
752
+ ValueError: if called with no entries at all, as the rest of the family does
753
+ TypeError: if an entry is neither an exception class nor a list, or is an empty list, which
754
+ is a group with no members and cannot be raised
755
+ """
756
+ if not expected:
757
+ raise ValueError("one or more args must be given")
758
+ spec = [_shaped(entry, f"[{index}]") for index, entry in enumerate(expected)]
759
+ exc = self._require_group("matches_error_tree")
760
+ if exc is None:
761
+ return cast("Self", _InertBuilder(self._value_taint_reason))
762
+ if not _matches_shape(spec, exc.exceptions):
763
+ names = _naming(spec, exc)
764
+ self.error(
765
+ f"Expected the raised exception group to match <{_spec_shape(spec, names)}>,"
766
+ f" but it was <{_shape_of(exc, names)}>.",
767
+ expected=spec,
768
+ )
769
+ return cast("Self", _InertBuilder(self._value_taint_reason))
770
+ return self
771
+
772
+ def _require_group(self, method: str) -> Any:
773
+ """The caught exception as a group, or ``None`` after reporting that it was not one.
774
+
775
+ Reported as a prerequisite, so `not_` fails on it too: the group family asks about what a group
776
+ holds, and a plain exception answers neither that question nor its negation.
777
+
778
+ Returns ``Any`` rather than the group type: on the 3.10 floor the compat name falls back to an
779
+ empty tuple, which is a value and not a type, so an annotation naming it is rejected outright.
780
+ Callers walk `.exceptions` on the result, which is why the check has to happen before they do.
781
+ """
782
+ exc = self._require_raised(method)
783
+ if not isinstance(exc, BaseExceptionGroup):
784
+ self._unmet(f"Expected the raised <{type(exc).__name__}> to be an exception group, but it was not.")
785
+ return None
786
+ return exc
787
+
788
+ def _require_raised(self, method: str) -> BaseException:
789
+ if self._raised_exception is _UNSET:
790
+ raise TypeError(
791
+ f"no exception captured; {method}() is only valid after raises()...when_called_with() caught one"
792
+ )
793
+ return cast("BaseException", self._raised_exception)
794
+
795
+ def _when_called_with_not_expected(self, *some_args, **some_kwargs) -> Self:
796
+ # when_called_with() rejects an unset expectation
797
+ expected = cast("type[BaseException] | ExceptionInfo", self.expected)
798
+ try:
799
+ result = self.val(*some_args, **some_kwargs)
800
+ except BaseException as e:
801
+ if issubclass(type(e), expected):
802
+ self.error(
803
+ f"Expected <{_callable_name(self.val)}> to not raise <{_type_expression_name(expected)}>"
804
+ f" when called with ({self._fmt_args_kwargs(*some_args, **some_kwargs)}),"
805
+ f" but did raise <{type(e).__name__}>."
806
+ )
807
+ return cast("Self", _InertBuilder(self._value_taint_reason))
808
+ if _escaped(e):
809
+ raise
810
+ return self
811
+ _require_synchronous_result(result, self.val)
812
+ self._return_value = result
813
+ return self
814
+
815
+ def does_not_raise(self, ex: type | ExceptionInfo) -> Self:
816
+ """Asserts that val is callable and sets the not-expected exception.
817
+
818
+ Just sets the not-expected exception, but never calls val. You must
819
+ chain to [`when_called_with()`][assertpy.exception.ExceptionMixin.when_called_with] to invoke ``val()``.
820
+
821
+ Args:
822
+ ex: the exception that should **not** be raised, or a union or a tuple of them
823
+
824
+ Examples:
825
+ Usage:
826
+
827
+ assert_that(some_func).does_not_raise(RuntimeError).when_called_with('foo')
828
+
829
+ Returns:
830
+ AssertionBuilder: returns a new instance to chain to the next assertion
831
+ """
832
+ if not callable(self.val):
833
+ refuse(self.val, "callable")
834
+ unwanted = _expected_classes(ex)
835
+
836
+ new_builder = self.builder(self.val, self.description, self.kind, unwanted, self.logger)
837
+ new_builder._not_expected = True
838
+ return new_builder