v0.166.0
 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