-
-
Notifications
You must be signed in to change notification settings - Fork 322
/
_raises_group.py
461 lines (386 loc) · 17.5 KB
/
_raises_group.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
from __future__ import annotations
import re
import sys
from typing import (
TYPE_CHECKING,
Callable,
ContextManager,
Generic,
Iterable,
Pattern,
TypeVar,
cast,
overload,
)
from trio._util import final
if TYPE_CHECKING:
import builtins
# sphinx will *only* work if we use types.TracebackType, and import
# *inside* TYPE_CHECKING. No other combination works.....
import types
from _pytest._code.code import ExceptionChainRepr, ReprExceptionInfo, Traceback
from typing_extensions import TypeGuard
if sys.version_info < (3, 11):
from exceptiongroup import BaseExceptionGroup
E = TypeVar("E", bound=BaseException)
@final
class _ExceptionInfo(Generic[E]):
"""Minimal re-implementation of pytest.ExceptionInfo, only used if pytest is not available. Supports a subset of its features necessary for functionality of :class:`trio.testing.RaisesGroup` and :class:`trio.testing.Matcher`."""
_excinfo: tuple[type[E], E, types.TracebackType] | None
def __init__(self, excinfo: tuple[type[E], E, types.TracebackType] | None):
self._excinfo = excinfo
def fill_unfilled(self, exc_info: tuple[type[E], E, types.TracebackType]) -> None:
"""Fill an unfilled ExceptionInfo created with ``for_later()``."""
assert self._excinfo is None, "ExceptionInfo was already filled"
self._excinfo = exc_info
@classmethod
def for_later(cls) -> _ExceptionInfo[E]:
"""Return an unfilled ExceptionInfo."""
return cls(None)
@property
def type(self) -> type[E]:
"""The exception class."""
assert (
self._excinfo is not None
), ".type can only be used after the context manager exits"
return self._excinfo[0]
@property
def value(self) -> E:
"""The exception value."""
assert (
self._excinfo is not None
), ".value can only be used after the context manager exits"
return self._excinfo[1]
@property
def tb(self) -> types.TracebackType:
"""The exception raw traceback."""
assert (
self._excinfo is not None
), ".tb can only be used after the context manager exits"
return self._excinfo[2]
def exconly(self, tryshort: bool = False) -> str:
raise NotImplementedError(
"This is a helper method only available if you use RaisesGroup with the pytest package installed"
)
def errisinstance(
self,
exc: builtins.type[BaseException] | tuple[builtins.type[BaseException], ...],
) -> bool:
raise NotImplementedError(
"This is a helper method only available if you use RaisesGroup with the pytest package installed"
)
def getrepr(
self,
showlocals: bool = False,
style: str = "long",
abspath: bool = False,
tbfilter: bool | Callable[[_ExceptionInfo[BaseException]], Traceback] = True,
funcargs: bool = False,
truncate_locals: bool = True,
chain: bool = True,
) -> ReprExceptionInfo | ExceptionChainRepr:
raise NotImplementedError(
"This is a helper method only available if you use RaisesGroup with the pytest package installed"
)
# Type checkers are not able to do conditional types depending on installed packages, so
# we've added signatures for all helpers to _ExceptionInfo, and then always use that.
# If this ends up leading to problems, we can resort to always using _ExceptionInfo and
# users that want to use getrepr/errisinstance/exconly can write helpers on their own, or
# we reimplement them ourselves...or get this merged in upstream pytest.
if TYPE_CHECKING:
ExceptionInfo = _ExceptionInfo
else:
try:
from pytest import ExceptionInfo # noqa: PT013
except ImportError: # pragma: no cover
ExceptionInfo = _ExceptionInfo
# copied from pytest.ExceptionInfo
def _stringify_exception(exc: BaseException) -> str:
return "\n".join(
[
str(exc),
*getattr(exc, "__notes__", []),
]
)
# String patterns default to including the unicode flag.
_regex_no_flags = re.compile("").flags
@final
class Matcher(Generic[E]):
"""Helper class to be used together with RaisesGroups when you want to specify requirements on sub-exceptions. Only specifying the type is redundant, and it's also unnecessary when the type is a nested `RaisesGroup` since it supports the same arguments.
The type is checked with `isinstance`, and does not need to be an exact match. If that is wanted you can use the ``check`` parameter.
:meth:`trio.testing.Matcher.matches` can also be used standalone to check individual exceptions.
Examples::
with RaisesGroups(Matcher(ValueError, match="string"))
...
with RaisesGroups(Matcher(check=lambda x: x.args == (3, "hello"))):
...
with RaisesGroups(Matcher(check=lambda x: type(x) is ValueError)):
...
"""
# At least one of the three parameters must be passed.
@overload
def __init__(
self: Matcher[E],
exception_type: type[E],
match: str | Pattern[str] = ...,
check: Callable[[E], bool] = ...,
):
...
@overload
def __init__(
self: Matcher[BaseException], # Give E a value.
*,
match: str | Pattern[str],
# If exception_type is not provided, check() must do any typechecks itself.
check: Callable[[BaseException], bool] = ...,
):
...
@overload
def __init__(self, *, check: Callable[[BaseException], bool]):
...
def __init__(
self,
exception_type: type[E] | None = None,
match: str | Pattern[str] | None = None,
check: Callable[[E], bool] | None = None,
):
if exception_type is None and match is None and check is None:
raise ValueError("You must specify at least one parameter to match on.")
if exception_type is not None and not issubclass(exception_type, BaseException):
raise ValueError(
f"exception_type {exception_type} must be a subclass of BaseException"
)
self.exception_type = exception_type
self.match: Pattern[str] | None
if isinstance(match, str):
self.match = re.compile(match)
else:
self.match = match
self.check = check
def matches(self, exception: BaseException) -> TypeGuard[E]:
"""Check if an exception matches the requirements of this Matcher.
Examples::
assert Matcher(ValueError).matches(my_exception):
# is equivalent to
assert isinstance(my_exception, ValueError)
# this can be useful when checking e.g. the ``__cause__`` of an exception.
with pytest.raises(ValueError) as excinfo:
...
assert Matcher(SyntaxError, match="foo").matches(excinfo.value.__cause__)
# above line is equivalent to
assert isinstance(excinfo.value.__cause__, SyntaxError)
assert re.search("foo", str(excinfo.value.__cause__)
"""
if self.exception_type is not None and not isinstance(
exception, self.exception_type
):
return False
if self.match is not None and not re.search(
self.match, _stringify_exception(exception)
):
return False
# If exception_type is None check() accepts BaseException.
# If non-none, we have done an isinstance check above.
if self.check is not None and not self.check(cast(E, exception)):
return False
return True
def __str__(self) -> str:
reqs = []
if self.exception_type is not None:
reqs.append(self.exception_type.__name__)
if (match := self.match) is not None:
# If no flags were specified, discard the redundant re.compile() here.
reqs.append(
f"match={match.pattern if match.flags == _regex_no_flags else match!r}"
)
if self.check is not None:
reqs.append(f"check={self.check!r}")
return f'Matcher({", ".join(reqs)})'
# typing this has been somewhat of a nightmare, with the primary difficulty making
# the return type of __enter__ correct. Ideally it would function like this
# with RaisesGroup(RaisesGroup(ValueError)) as excinfo:
# ...
# assert_type(excinfo.value, ExceptionGroup[ExceptionGroup[ValueError]])
# in addition to all the simple cases, but getting all the way to the above seems maybe
# impossible. The type being RaisesGroup[RaisesGroup[ValueError]] is probably also fine,
# as long as I add fake properties corresponding to the properties of exceptiongroup. But
# I had trouble with it handling recursive cases properly.
# Current solution settles on the above giving BaseExceptionGroup[RaisesGroup[ValueError]], and it not
# being a type error to do `with RaisesGroup(ValueError()): ...` - but that will error on runtime.
# We lie to type checkers that we inherit, so excinfo.value and sub-exceptiongroups can be treated as ExceptionGroups
if TYPE_CHECKING:
SuperClass = BaseExceptionGroup
# Inheriting at runtime leads to a series of TypeErrors, so we do not want to do that.
else:
SuperClass = Generic
@final
class RaisesGroup(ContextManager[ExceptionInfo[BaseExceptionGroup[E]]], SuperClass[E]):
"""Contextmanager for checking for an expected `ExceptionGroup`.
This works similar to ``pytest.raises``, and a version of it will hopefully be added upstream, after which this can be deprecated and removed. See https://github.com/pytest-dev/pytest/issues/11538
This differs from :ref:`except* <except_star>` in that all specified exceptions must be present, *and no others*. It will similarly not catch exceptions *not* wrapped in an exceptiongroup.
If you don't care for the nesting level of the exceptions you can pass ``strict=False``.
It currently does not care about the order of the exceptions, so ``RaisesGroups(ValueError, TypeError)`` is equivalent to ``RaisesGroups(TypeError, ValueError)``.
This class is not as polished as ``pytest.raises``, and is currently not as helpful in e.g. printing diffs when strings don't match, suggesting you use ``re.escape``, etc.
Examples::
with RaisesGroups(ValueError):
raise ExceptionGroup("", (ValueError(),))
with RaisesGroups(ValueError, ValueError, Matcher(TypeError, match="expected int")):
...
with RaisesGroups(KeyboardInterrupt, match="hello", check=lambda x: type(x) is BaseExceptionGroup):
...
with RaisesGroups(RaisesGroups(ValueError)):
raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),))
with RaisesGroups(ValueError, strict=False):
raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),))
`RaisesGroup.matches` can also be used directly to check a standalone exception group.
This class is also not perfectly smart, e.g. this will likely fail currently::
with RaisesGroups(ValueError, Matcher(ValueError, match="hello")):
raise ExceptionGroup("", (ValueError("hello"), ValueError("goodbye")))
even though it generally does not care about the order of the exceptions in the group.
To avoid the above you should specify the first ValueError with a Matcher as well.
It is also not typechecked perfectly, and that's likely not possible with the current approach. Most common usage should work without issue though.
"""
# needed for pyright, since BaseExceptionGroup.__new__ takes two arguments
if TYPE_CHECKING:
def __new__(cls, *args: object, **kwargs: object) -> RaisesGroup[E]:
...
def __init__(
self,
exception: type[E] | Matcher[E] | E,
*other_exceptions: type[E] | Matcher[E] | E,
strict: bool = True,
match: str | Pattern[str] | None = None,
check: Callable[[BaseExceptionGroup[E]], bool] | None = None,
):
self.expected_exceptions: tuple[type[E] | Matcher[E] | E, ...] = (
exception,
*other_exceptions,
)
self.strict = strict
self.match_expr = match
self.check = check
self.is_baseexceptiongroup = False
for exc in self.expected_exceptions:
if isinstance(exc, RaisesGroup):
if not strict:
raise ValueError(
"You cannot specify a nested structure inside a RaisesGroup with"
" strict=False"
)
self.is_baseexceptiongroup |= exc.is_baseexceptiongroup
elif isinstance(exc, Matcher):
if exc.exception_type is None:
continue
# Matcher __init__ assures it's a subclass of BaseException
self.is_baseexceptiongroup |= not issubclass(
exc.exception_type, Exception
)
elif isinstance(exc, type) and issubclass(exc, BaseException):
self.is_baseexceptiongroup |= not issubclass(exc, Exception)
else:
raise ValueError(
f'Invalid argument "{exc!r}" must be exception type, Matcher, or'
" RaisesGroup."
)
def __enter__(self) -> ExceptionInfo[BaseExceptionGroup[E]]:
self.excinfo: ExceptionInfo[BaseExceptionGroup[E]] = ExceptionInfo.for_later()
return self.excinfo
def _unroll_exceptions(
self, exceptions: Iterable[BaseException]
) -> Iterable[BaseException]:
"""Used in non-strict mode."""
res: list[BaseException] = []
for exc in exceptions:
if isinstance(exc, BaseExceptionGroup):
res.extend(self._unroll_exceptions(exc.exceptions))
else:
res.append(exc)
return res
def matches(
self,
exc_val: BaseException | None,
) -> TypeGuard[BaseExceptionGroup[E]]:
"""Check if an exception matches the requirements of this RaisesGroup.
Example::
with pytest.raises(TypeError) as excinfo:
...
assert RaisesGroups(ValueError).matches(excinfo.value.__cause__)
# the above line is equivalent to
myexc = excinfo.value.__cause
assert isinstance(myexc, BaseExceptionGroup)
assert len(myexc.exceptions) == 1
assert isinstance(myexc.exceptions[0], ValueError)
"""
if exc_val is None:
return False
# TODO: print/raise why a match fails, in a way that works properly in nested cases
# maybe have a list of strings logging failed matches, that __exit__ can
# recursively step through and print on a failing match.
if not isinstance(exc_val, BaseExceptionGroup):
return False
if len(exc_val.exceptions) != len(self.expected_exceptions):
return False
if self.match_expr is not None and not re.search(
self.match_expr, _stringify_exception(exc_val)
):
return False
if self.check is not None and not self.check(exc_val):
return False
remaining_exceptions = list(self.expected_exceptions)
actual_exceptions: Iterable[BaseException] = exc_val.exceptions
if not self.strict:
actual_exceptions = self._unroll_exceptions(actual_exceptions)
# it should be possible to get RaisesGroup.matches typed so as not to
# need these type: ignores, but I'm not sure that's possible while also having it
# transparent for the end user.
for e in actual_exceptions:
for rem_e in remaining_exceptions:
if (
(isinstance(rem_e, type) and isinstance(e, rem_e))
or (
isinstance(e, BaseExceptionGroup)
and isinstance(rem_e, RaisesGroup)
and rem_e.matches(e)
)
or (isinstance(rem_e, Matcher) and rem_e.matches(e))
):
remaining_exceptions.remove(rem_e) # type: ignore[arg-type]
break
else:
return False
return True
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: types.TracebackType | None,
) -> bool:
__tracebackhide__ = True
assert (
exc_type is not None
), f"DID NOT RAISE any exception, expected {self.expected_type()}"
assert (
self.excinfo is not None
), "Internal error - should have been constructed in __enter__"
if not self.matches(exc_val):
return False
# Cast to narrow the exception type now that it's verified.
exc_info = cast(
"tuple[type[BaseExceptionGroup[E]], BaseExceptionGroup[E], types.TracebackType]",
(exc_type, exc_val, exc_tb),
)
self.excinfo.fill_unfilled(exc_info)
return True
def expected_type(self) -> str:
subexcs = []
for e in self.expected_exceptions:
if isinstance(e, Matcher):
subexcs.append(str(e))
elif isinstance(e, RaisesGroup):
subexcs.append(e.expected_type())
elif isinstance(e, type):
subexcs.append(e.__name__)
else: # pragma: no cover
raise AssertionError("unknown type")
group_type = "Base" if self.is_baseexceptiongroup else ""
return f"{group_type}ExceptionGroup({', '.join(subexcs)})"