1import re
2from types import TracebackType
3from typing import Self
4
5__all__ = ["raises"]
6
7
8class raises[E: BaseException]:
9 """
10 Assert that a block raises one of the given exception types.
11
12 with raises(ValidationError):
13 validate_email("nope")
14
15 The caught exception is available afterward, typed as what was caught —
16 so its own attributes are reachable without a cast:
17
18 with raises(ValidationError) as caught:
19 validate_email("nope")
20 assert "email" in caught.exception.messages
21
22 Pass `match=` to also require the exception message to match a regex:
23
24 with raises(ValueError, match="expected .* to be positive"):
25 ...
26 """
27
28 def __init__(
29 self,
30 *exceptions: type[E],
31 match: str | None = None,
32 ) -> None:
33 if not exceptions:
34 raise TypeError("raises() requires at least one exception type")
35 self.expected = exceptions
36 self.match = match
37 self._exception: E | None = None
38
39 @property
40 def exception(self) -> E:
41 """
42 The exception the block raised.
43
44 Only readable once the block has exited — inside the block nothing has
45 been caught yet, so reading it is a mistake worth saying out loud
46 rather than handing back a None that fails somewhere later.
47 """
48 if self._exception is None:
49 raise AttributeError(
50 "raises(...).exception is only available after the `with` block "
51 "exits — inside the block, the exception hasn't been raised yet."
52 )
53 return self._exception
54
55 def __enter__(self) -> Self:
56 return self
57
58 def __exit__(
59 self,
60 exc_type: type[BaseException] | None,
61 exc: BaseException | None,
62 tb: TracebackType | None,
63 ) -> bool:
64 if exc_type is None:
65 expected_names = " or ".join(e.__name__ for e in self.expected)
66 raise AssertionError(f"{expected_names} was not raised")
67
68 if not issubclass(exc_type, self.expected):
69 # Let the unexpected exception propagate.
70 return False
71
72 assert exc is not None
73 self._exception = exc # ty: ignore[invalid-assignment] (narrowed by issubclass above)
74
75 if self.match is not None and not re.search(self.match, str(exc)):
76 raise AssertionError(
77 f"{exc_type.__name__} was raised, but its message did not match {self.match!r}\n"
78 f" message: {str(exc)!r}"
79 ) from exc
80
81 return True