Skip to content

Commit 8b7f94f

Browse files
authored
Merge pull request #11424 from lanzz/exceptioninfo-groupcontains
2 parents 6c2feb7 + 5ace48c commit 8b7f94f

File tree

6 files changed

+235
-7
lines changed

6 files changed

+235
-7
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ Michal Wajszczuk
266266
Michał Zięba
267267
Mickey Pashov
268268
Mihai Capotă
269+
Mihail Milushev
269270
Mike Hoyle (hoylemd)
270271
Mike Lundy
271272
Milan Lesnek

changelog/10441.feature.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Added :func:`ExceptionInfo.group_contains() <pytest.ExceptionInfo.group_contains>`, an assertion
2+
helper that tests if an `ExceptionGroup` contains a matching exception.

doc/en/getting-started.rst

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,30 @@ 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 ``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+
100124
Execute the test function with “quiet” reporting mode:
101125

102126
.. code-block:: pytest

doc/en/how-to/assert.rst

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,56 @@ that a regular expression matches on the string representation of an exception
115115
with pytest.raises(ValueError, match=r".* 123 .*"):
116116
myfunc()
117117
118-
The regexp parameter of the ``match`` method is matched with the ``re.search``
118+
The regexp parameter of the ``match`` parameter is matched with the ``re.search``
119119
function, so in the above example ``match='123'`` would have worked as
120120
well.
121121

122+
You can also use the :func:`excinfo.group_contains() <pytest.ExceptionInfo.group_contains>`
123+
method to test for exceptions returned as part of an ``ExceptionGroup``:
124+
125+
.. code-block:: python
126+
127+
def test_exception_in_group():
128+
with pytest.raises(RuntimeError) as excinfo:
129+
raise ExceptionGroup(
130+
"Group message",
131+
[
132+
RuntimeError("Exception 123 raised"),
133+
],
134+
)
135+
assert excinfo.group_contains(RuntimeError, match=r".* 123 .*")
136+
assert not excinfo.group_contains(TypeError)
137+
138+
The optional ``match`` keyword parameter works the same way as for
139+
:func:`pytest.raises`.
140+
141+
By default ``group_contains()`` will recursively search for a matching
142+
exception at any level of nested ``ExceptionGroup`` instances. You can
143+
specify a ``depth`` keyword parameter if you only want to match an
144+
exception at a specific level; exceptions contained directly in the top
145+
``ExceptionGroup`` would match ``depth=1``.
146+
147+
.. code-block:: python
148+
149+
def test_exception_in_group_at_given_depth():
150+
with pytest.raises(RuntimeError) as excinfo:
151+
raise ExceptionGroup(
152+
"Group message",
153+
[
154+
RuntimeError(),
155+
ExceptionGroup(
156+
"Nested group",
157+
[
158+
TypeError(),
159+
],
160+
),
161+
],
162+
)
163+
assert excinfo.group_contains(RuntimeError, depth=1)
164+
assert excinfo.group_contains(TypeError, depth=2)
165+
assert not excinfo.group_contains(RuntimeError, depth=2)
166+
assert not excinfo.group_contains(TypeError, depth=1)
167+
122168
There's an alternate form of the :func:`pytest.raises` function where you pass
123169
a function that will be executed with the given ``*args`` and ``**kwargs`` and
124170
assert that the given exception is raised:

src/_pytest/_code/code.py

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -697,26 +697,92 @@ def getrepr(
697697
)
698698
return fmt.repr_excinfo(self)
699699

700+
def _stringify_exception(self, exc: BaseException) -> str:
701+
return "\n".join(
702+
[
703+
str(exc),
704+
*getattr(exc, "__notes__", []),
705+
]
706+
)
707+
700708
def match(self, regexp: Union[str, Pattern[str]]) -> "Literal[True]":
701709
"""Check whether the regular expression `regexp` matches the string
702710
representation of the exception using :func:`python:re.search`.
703711
704712
If it matches `True` is returned, otherwise an `AssertionError` is raised.
705713
"""
706714
__tracebackhide__ = True
707-
value = "\n".join(
708-
[
709-
str(self.value),
710-
*getattr(self.value, "__notes__", []),
711-
]
712-
)
715+
value = self._stringify_exception(self.value)
713716
msg = f"Regex pattern did not match.\n Regex: {regexp!r}\n Input: {value!r}"
714717
if regexp == value:
715718
msg += "\n Did you mean to `re.escape()` the regex?"
716719
assert re.search(regexp, value), msg
717720
# Return True to allow for "assert excinfo.match()".
718721
return True
719722

723+
def _group_contains(
724+
self,
725+
exc_group: BaseExceptionGroup[BaseException],
726+
expected_exception: Union[Type[BaseException], Tuple[Type[BaseException], ...]],
727+
match: Union[str, Pattern[str], None],
728+
target_depth: Optional[int] = None,
729+
current_depth: int = 1,
730+
) -> bool:
731+
"""Return `True` if a `BaseExceptionGroup` contains a matching exception."""
732+
if (target_depth is not None) and (current_depth > target_depth):
733+
# already descended past the target depth
734+
return False
735+
for exc in exc_group.exceptions:
736+
if isinstance(exc, BaseExceptionGroup):
737+
if self._group_contains(
738+
exc, expected_exception, match, target_depth, current_depth + 1
739+
):
740+
return True
741+
if (target_depth is not None) and (current_depth != target_depth):
742+
# not at the target depth, no match
743+
continue
744+
if not isinstance(exc, expected_exception):
745+
continue
746+
if match is not None:
747+
value = self._stringify_exception(exc)
748+
if not re.search(match, value):
749+
continue
750+
return True
751+
return False
752+
753+
def group_contains(
754+
self,
755+
expected_exception: Union[Type[BaseException], Tuple[Type[BaseException], ...]],
756+
*,
757+
match: Union[str, Pattern[str], None] = None,
758+
depth: Optional[int] = None,
759+
) -> bool:
760+
"""Check whether a captured exception group contains a matching exception.
761+
762+
:param Type[BaseException] | Tuple[Type[BaseException]] expected_exception:
763+
The expected exception type, or a tuple if one of multiple possible
764+
exception types are expected.
765+
766+
:param str | Pattern[str] | None match:
767+
If specified, a string containing a regular expression,
768+
or a regular expression object, that is tested against the string
769+
representation of the exception and its `PEP-678 <https://peps.python.org/pep-0678/>` `__notes__`
770+
using :func:`re.search`.
771+
772+
To match a literal string that may contain :ref:`special characters
773+
<re-syntax>`, the pattern can first be escaped with :func:`re.escape`.
774+
775+
:param Optional[int] depth:
776+
If `None`, will search for a matching exception at any nesting depth.
777+
If >= 1, will only match an exception if it's at the specified depth (depth = 1 being
778+
the exceptions contained within the topmost exception group).
779+
"""
780+
msg = "Captured exception is not an instance of `BaseExceptionGroup`"
781+
assert isinstance(self.value, BaseExceptionGroup), msg
782+
msg = "`depth` must be >= 1 if specified"
783+
assert (depth is None) or (depth >= 1), msg
784+
return self._group_contains(self.value, expected_exception, match, depth)
785+
720786

721787
@dataclasses.dataclass
722788
class FormattedExcinfo:

testing/code/test_excinfo.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
2727
if TYPE_CHECKING:
2828
from _pytest._code.code import _TracebackStyle
2929

30+
if sys.version_info[:2] < (3, 11):
31+
from exceptiongroup import ExceptionGroup
32+
3033

3134
@pytest.fixture
3235
def limited_recursion_depth():
@@ -444,6 +447,92 @@ def test_division_zero():
444447
result.stdout.re_match_lines([r".*__tracebackhide__ = True.*", *match])
445448

446449

450+
class TestGroupContains:
451+
def test_contains_exception_type(self) -> None:
452+
exc_group = ExceptionGroup("", [RuntimeError()])
453+
with pytest.raises(ExceptionGroup) as exc_info:
454+
raise exc_group
455+
assert exc_info.group_contains(RuntimeError)
456+
457+
def test_doesnt_contain_exception_type(self) -> None:
458+
exc_group = ExceptionGroup("", [ValueError()])
459+
with pytest.raises(ExceptionGroup) as exc_info:
460+
raise exc_group
461+
assert not exc_info.group_contains(RuntimeError)
462+
463+
def test_contains_exception_match(self) -> None:
464+
exc_group = ExceptionGroup("", [RuntimeError("exception message")])
465+
with pytest.raises(ExceptionGroup) as exc_info:
466+
raise exc_group
467+
assert exc_info.group_contains(RuntimeError, match=r"^exception message$")
468+
469+
def test_doesnt_contain_exception_match(self) -> None:
470+
exc_group = ExceptionGroup("", [RuntimeError("message that will not match")])
471+
with pytest.raises(ExceptionGroup) as exc_info:
472+
raise exc_group
473+
assert not exc_info.group_contains(RuntimeError, match=r"^exception message$")
474+
475+
def test_contains_exception_type_unlimited_depth(self) -> None:
476+
exc_group = ExceptionGroup("", [ExceptionGroup("", [RuntimeError()])])
477+
with pytest.raises(ExceptionGroup) as exc_info:
478+
raise exc_group
479+
assert exc_info.group_contains(RuntimeError)
480+
481+
def test_contains_exception_type_at_depth_1(self) -> None:
482+
exc_group = ExceptionGroup("", [RuntimeError()])
483+
with pytest.raises(ExceptionGroup) as exc_info:
484+
raise exc_group
485+
assert exc_info.group_contains(RuntimeError, depth=1)
486+
487+
def test_doesnt_contain_exception_type_past_depth(self) -> None:
488+
exc_group = ExceptionGroup("", [ExceptionGroup("", [RuntimeError()])])
489+
with pytest.raises(ExceptionGroup) as exc_info:
490+
raise exc_group
491+
assert not exc_info.group_contains(RuntimeError, depth=1)
492+
493+
def test_contains_exception_type_specific_depth(self) -> None:
494+
exc_group = ExceptionGroup("", [ExceptionGroup("", [RuntimeError()])])
495+
with pytest.raises(ExceptionGroup) as exc_info:
496+
raise exc_group
497+
assert exc_info.group_contains(RuntimeError, depth=2)
498+
499+
def test_contains_exception_match_unlimited_depth(self) -> None:
500+
exc_group = ExceptionGroup(
501+
"", [ExceptionGroup("", [RuntimeError("exception message")])]
502+
)
503+
with pytest.raises(ExceptionGroup) as exc_info:
504+
raise exc_group
505+
assert exc_info.group_contains(RuntimeError, match=r"^exception message$")
506+
507+
def test_contains_exception_match_at_depth_1(self) -> None:
508+
exc_group = ExceptionGroup("", [RuntimeError("exception message")])
509+
with pytest.raises(ExceptionGroup) as exc_info:
510+
raise exc_group
511+
assert exc_info.group_contains(
512+
RuntimeError, match=r"^exception message$", depth=1
513+
)
514+
515+
def test_doesnt_contain_exception_match_past_depth(self) -> None:
516+
exc_group = ExceptionGroup(
517+
"", [ExceptionGroup("", [RuntimeError("exception message")])]
518+
)
519+
with pytest.raises(ExceptionGroup) as exc_info:
520+
raise exc_group
521+
assert not exc_info.group_contains(
522+
RuntimeError, match=r"^exception message$", depth=1
523+
)
524+
525+
def test_contains_exception_match_specific_depth(self) -> None:
526+
exc_group = ExceptionGroup(
527+
"", [ExceptionGroup("", [RuntimeError("exception message")])]
528+
)
529+
with pytest.raises(ExceptionGroup) as exc_info:
530+
raise exc_group
531+
assert exc_info.group_contains(
532+
RuntimeError, match=r"^exception message$", depth=2
533+
)
534+
535+
447536
class TestFormattedExcinfo:
448537
@pytest.fixture
449538
def importasmod(self, tmp_path: Path, _sys_snapshot):

0 commit comments

Comments
 (0)