Skip to content

Commit 150cac8

Browse files
authored
Merge pull request #13192 from jakkdl/raisesgroup
add RaisesGroup & RaisesExc. Make raises use RaisesExc. Allow xfail to accept a RaisesGroup/RaisesExc
2 parents d622fdb + 4c6ded7 commit 150cac8

File tree

17 files changed

+3077
-195
lines changed

17 files changed

+3077
-195
lines changed

changelog/11538.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added :class:`pytest.RaisesGroup` as an equivalent to :func:`pytest.raises` for expecting :exc:`ExceptionGroup`. Also adds :class:`pytest.RaisesExc` which is now the logic behind :func:`pytest.raises` and used as parameter to :class:`pytest.RaisesGroup`. ``RaisesGroup`` includes the ability to specify multiple different expected exceptions, the structure of nested exception groups, and flags for emulating :ref:`except* <except_star>`. See :ref:`assert-matching-exception-groups` and docstrings for more information.

changelog/12504.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:func:`pytest.mark.xfail` now accepts :class:`pytest.RaisesGroup` for the ``raises`` parameter when you expect an exception group. You can also pass a :class:`pytest.RaisesExc` if you e.g. want to make use of the ``check`` parameter.

doc/en/conf.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@
106106
("py:obj", "_pytest.fixtures.FixtureValue"),
107107
("py:obj", "_pytest.stash.T"),
108108
("py:class", "_ScopeName"),
109+
("py:class", "BaseExcT_1"),
110+
("py:class", "ExcT_1"),
109111
]
110112

111113
add_module_names = False

doc/en/getting-started.rst

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -97,30 +97,6 @@ Use the :ref:`raises <assertraises>` helper to assert that some code raises an e
9797
with pytest.raises(SystemExit):
9898
f()
9999
100-
You can also use the context provided by :ref:`raises <assertraises>` to
101-
assert that an expected exception is part of a raised :class:`ExceptionGroup`:
102-
103-
.. code-block:: python
104-
105-
# content of test_exceptiongroup.py
106-
import pytest
107-
108-
109-
def f():
110-
raise ExceptionGroup(
111-
"Group message",
112-
[
113-
RuntimeError(),
114-
],
115-
)
116-
117-
118-
def test_exception_in_group():
119-
with pytest.raises(ExceptionGroup) as excinfo:
120-
f()
121-
assert excinfo.group_contains(RuntimeError)
122-
assert not excinfo.group_contains(TypeError)
123-
124100
Execute the test function with “quiet” reporting mode:
125101

126102
.. code-block:: pytest
@@ -133,6 +109,8 @@ Execute the test function with “quiet” reporting mode:
133109

134110
The ``-q/--quiet`` flag keeps the output brief in this and following examples.
135111

112+
See :ref:`assertraises` for specifying more details about the expected exception.
113+
136114
Group multiple tests in a class
137115
--------------------------------------------------------------
138116

doc/en/how-to/assert.rst

Lines changed: 104 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,93 @@ Notes:
145145

146146
.. _`assert-matching-exception-groups`:
147147

148-
Matching exception groups
149-
~~~~~~~~~~~~~~~~~~~~~~~~~
148+
Assertions about expected exception groups
149+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
150+
151+
When expecting a :exc:`BaseExceptionGroup` or :exc:`ExceptionGroup` you can use :class:`pytest.RaisesGroup`:
152+
153+
.. code-block:: python
154+
155+
def test_exception_in_group():
156+
with pytest.RaisesGroup(ValueError):
157+
raise ExceptionGroup("group msg", [ValueError("value msg")])
158+
with pytest.RaisesGroup(ValueError, TypeError):
159+
raise ExceptionGroup("msg", [ValueError("foo"), TypeError("bar")])
160+
161+
162+
It accepts a ``match`` parameter, that checks against the group message, and a ``check`` parameter that takes an arbitrary callable which it passes the group to, and only succeeds if the callable returns ``True``.
163+
164+
.. code-block:: python
165+
166+
def test_raisesgroup_match_and_check():
167+
with pytest.RaisesGroup(BaseException, match="my group msg"):
168+
raise BaseExceptionGroup("my group msg", [KeyboardInterrupt()])
169+
with pytest.RaisesGroup(
170+
Exception, check=lambda eg: isinstance(eg.__cause__, ValueError)
171+
):
172+
raise ExceptionGroup("", [TypeError()]) from ValueError()
173+
174+
It is strict about structure and unwrapped exceptions, unlike :ref:`except* <except_star>`, so you might want to set the ``flatten_subgroups`` and/or ``allow_unwrapped`` parameters.
175+
176+
.. code-block:: python
177+
178+
def test_structure():
179+
with pytest.RaisesGroup(pytest.RaisesGroup(ValueError)):
180+
raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),))
181+
with pytest.RaisesGroup(ValueError, flatten_subgroups=True):
182+
raise ExceptionGroup("1st group", [ExceptionGroup("2nd group", [ValueError()])])
183+
with pytest.RaisesGroup(ValueError, allow_unwrapped=True):
184+
raise ValueError
185+
186+
To specify more details about the contained exception you can use :class:`pytest.RaisesExc`
187+
188+
.. code-block:: python
189+
190+
def test_raises_exc():
191+
with pytest.RaisesGroup(pytest.RaisesExc(ValueError, match="foo")):
192+
raise ExceptionGroup("", (ValueError("foo")))
193+
194+
They both supply a method :meth:`pytest.RaisesGroup.matches` :meth:`pytest.RaisesExc.matches` if you want to do matching outside of using it as a contextmanager. This can be helpful when checking ``.__context__`` or ``.__cause__``.
195+
196+
.. code-block:: python
197+
198+
def test_matches():
199+
exc = ValueError()
200+
exc_group = ExceptionGroup("", [exc])
201+
if RaisesGroup(ValueError).matches(exc_group):
202+
...
203+
# helpful error is available in `.fail_reason` if it fails to match
204+
r = RaisesExc(ValueError)
205+
assert r.matches(e), r.fail_reason
206+
207+
Check the documentation on :class:`pytest.RaisesGroup` and :class:`pytest.RaisesExc` for more details and examples.
208+
209+
``ExceptionInfo.group_contains()``
210+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
211+
212+
.. warning::
213+
214+
This helper makes it easy to check for the presence of specific exceptions, but it is very bad for checking that the group does *not* contain *any other exceptions*. So this will pass:
215+
216+
.. code-block:: python
217+
218+
class EXTREMELYBADERROR(BaseException):
219+
"""This is a very bad error to miss"""
220+
221+
222+
def test_for_value_error():
223+
with pytest.raises(ExceptionGroup) as excinfo:
224+
excs = [ValueError()]
225+
if very_unlucky():
226+
excs.append(EXTREMELYBADERROR())
227+
raise ExceptionGroup("", excs)
228+
# This passes regardless of if there's other exceptions.
229+
assert excinfo.group_contains(ValueError)
230+
# You can't simply list all exceptions you *don't* want to get here.
231+
232+
233+
There is no good way of using :func:`excinfo.group_contains() <pytest.ExceptionInfo.group_contains>` to ensure you're not getting *any* other exceptions than the one you expected.
234+
You should instead use :class:`pytest.RaisesGroup`, see :ref:`assert-matching-exception-groups`.
150235

151236
You can also use the :func:`excinfo.group_contains() <pytest.ExceptionInfo.group_contains>`
152237
method to test for exceptions returned as part of an :class:`ExceptionGroup`:
@@ -194,12 +279,12 @@ exception at a specific level; exceptions contained directly in the top
194279
assert not excinfo.group_contains(RuntimeError, depth=2)
195280
assert not excinfo.group_contains(TypeError, depth=1)
196281
197-
Alternate form (legacy)
198-
~~~~~~~~~~~~~~~~~~~~~~~
282+
Alternate `pytest.raises` form (legacy)
283+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
199284

200-
There is an alternate form where you pass
201-
a function that will be executed, along ``*args`` and ``**kwargs``, and :func:`pytest.raises`
202-
will execute the function with the arguments and assert that the given exception is raised:
285+
There is an alternate form of :func:`pytest.raises` where you pass
286+
a function that will be executed, along with ``*args`` and ``**kwargs``. :func:`pytest.raises`
287+
will then execute the function with those arguments and assert that the given exception is raised:
203288

204289
.. code-block:: python
205290
@@ -244,6 +329,18 @@ This will only "xfail" if the test fails by raising ``IndexError`` or subclasses
244329
* Using :func:`pytest.raises` is likely to be better for cases where you are
245330
testing exceptions your own code is deliberately raising, which is the majority of cases.
246331

332+
You can also use :class:`pytest.RaisesGroup`:
333+
334+
.. code-block:: python
335+
336+
def f():
337+
raise ExceptionGroup("", [IndexError()])
338+
339+
340+
@pytest.mark.xfail(raises=RaisesGroup(IndexError))
341+
def test_f():
342+
f()
343+
247344
248345
.. _`assertwarns`:
249346

doc/en/reference/reference.rst

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,6 +1034,23 @@ PytestPluginManager
10341034
:inherited-members:
10351035
:show-inheritance:
10361036

1037+
RaisesExc
1038+
~~~~~~~~~
1039+
1040+
.. autoclass:: pytest.RaisesExc()
1041+
:members:
1042+
1043+
.. autoattribute:: fail_reason
1044+
1045+
RaisesGroup
1046+
~~~~~~~~~~~
1047+
**Tutorial**: :ref:`assert-matching-exception-groups`
1048+
1049+
.. autoclass:: pytest.RaisesGroup()
1050+
:members:
1051+
1052+
.. autoattribute:: fail_reason
1053+
10371054
TerminalReporter
10381055
~~~~~~~~~~~~~~~~
10391056

src/_pytest/_code/code.py

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,32 @@ def recursionindex(self) -> int | None:
459459
return None
460460

461461

462+
def stringify_exception(
463+
exc: BaseException, include_subexception_msg: bool = True
464+
) -> str:
465+
try:
466+
notes = getattr(exc, "__notes__", [])
467+
except KeyError:
468+
# Workaround for https://github.com/python/cpython/issues/98778 on
469+
# Python <= 3.9, and some 3.10 and 3.11 patch versions.
470+
HTTPError = getattr(sys.modules.get("urllib.error", None), "HTTPError", ())
471+
if sys.version_info < (3, 12) and isinstance(exc, HTTPError):
472+
notes = []
473+
else:
474+
raise
475+
if not include_subexception_msg and isinstance(exc, BaseExceptionGroup):
476+
message = exc.message
477+
else:
478+
message = str(exc)
479+
480+
return "\n".join(
481+
[
482+
message,
483+
*notes,
484+
]
485+
)
486+
487+
462488
E = TypeVar("E", bound=BaseException, covariant=True)
463489

464490

@@ -736,33 +762,14 @@ def getrepr(
736762
)
737763
return fmt.repr_excinfo(self)
738764

739-
def _stringify_exception(self, exc: BaseException) -> str:
740-
try:
741-
notes = getattr(exc, "__notes__", [])
742-
except KeyError:
743-
# Workaround for https://github.com/python/cpython/issues/98778 on
744-
# Python <= 3.9, and some 3.10 and 3.11 patch versions.
745-
HTTPError = getattr(sys.modules.get("urllib.error", None), "HTTPError", ())
746-
if sys.version_info < (3, 12) and isinstance(exc, HTTPError):
747-
notes = []
748-
else:
749-
raise
750-
751-
return "\n".join(
752-
[
753-
str(exc),
754-
*notes,
755-
]
756-
)
757-
758765
def match(self, regexp: str | re.Pattern[str]) -> Literal[True]:
759766
"""Check whether the regular expression `regexp` matches the string
760767
representation of the exception using :func:`python:re.search`.
761768
762769
If it matches `True` is returned, otherwise an `AssertionError` is raised.
763770
"""
764771
__tracebackhide__ = True
765-
value = self._stringify_exception(self.value)
772+
value = stringify_exception(self.value)
766773
msg = f"Regex pattern did not match.\n Regex: {regexp!r}\n Input: {value!r}"
767774
if regexp == value:
768775
msg += "\n Did you mean to `re.escape()` the regex?"
@@ -794,7 +801,7 @@ def _group_contains(
794801
if not isinstance(exc, expected_exception):
795802
continue
796803
if match is not None:
797-
value = self._stringify_exception(exc)
804+
value = stringify_exception(exc)
798805
if not re.search(match, value):
799806
continue
800807
return True
@@ -828,6 +835,13 @@ def group_contains(
828835
the exceptions contained within the topmost exception group).
829836
830837
.. versionadded:: 8.0
838+
839+
.. warning::
840+
This helper makes it easy to check for the presence of specific exceptions,
841+
but it is very bad for checking that the group does *not* contain
842+
*any other exceptions*.
843+
You should instead consider using :class:`pytest.RaisesGroup`
844+
831845
"""
832846
msg = "Captured exception is not an instance of `BaseExceptionGroup`"
833847
assert isinstance(self.value, BaseExceptionGroup), msg

src/_pytest/mark/structures.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from _pytest.deprecated import check_ispytest
2929
from _pytest.deprecated import MARKED_FIXTURE
3030
from _pytest.outcomes import fail
31+
from _pytest.raises_group import AbstractRaises
3132
from _pytest.scope import _ScopeName
3233
from _pytest.warning_types import PytestUnknownMarkWarning
3334

@@ -473,7 +474,10 @@ def __call__(
473474
*conditions: str | bool,
474475
reason: str = ...,
475476
run: bool = ...,
476-
raises: None | type[BaseException] | tuple[type[BaseException], ...] = ...,
477+
raises: None
478+
| type[BaseException]
479+
| tuple[type[BaseException], ...]
480+
| AbstractRaises[BaseException] = ...,
477481
strict: bool = ...,
478482
) -> MarkDecorator: ...
479483

src/_pytest/outcomes.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ def __init__(
7777
super().__init__(msg)
7878

7979

80-
# Elaborate hack to work around https://github.com/python/mypy/issues/2087.
81-
# Ideally would just be `exit.Exception = Exit` etc.
80+
# We need a callable protocol to add attributes, for discussion see
81+
# https://github.com/python/mypy/issues/2087.
8282

8383
_F = TypeVar("_F", bound=Callable[..., object])
8484
_ET = TypeVar("_ET", bound=type[BaseException])

0 commit comments

Comments
 (0)