Merge pull request #11424 from lanzz/exceptioninfo-groupcontains

This commit is contained in:
Zac Hatfield-Dodds 2023-09-17 16:51:57 -07:00 committed by GitHub
commit 8b7f94f145
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 235 additions and 7 deletions

View File

@ -266,6 +266,7 @@ Michal Wajszczuk
Michał Zięba Michał Zięba
Mickey Pashov Mickey Pashov
Mihai Capotă Mihai Capotă
Mihail Milushev
Mike Hoyle (hoylemd) Mike Hoyle (hoylemd)
Mike Lundy Mike Lundy
Milan Lesnek Milan Lesnek

View File

@ -0,0 +1,2 @@
Added :func:`ExceptionInfo.group_contains() <pytest.ExceptionInfo.group_contains>`, an assertion
helper that tests if an `ExceptionGroup` contains a matching exception.

View File

@ -97,6 +97,30 @@ Use the :ref:`raises <assertraises>` helper to assert that some code raises an e
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
f() f()
You can also use the context provided by :ref:`raises <assertraises>` to
assert that an expected exception is part of a raised ``ExceptionGroup``:
.. code-block:: python
# content of test_exceptiongroup.py
import pytest
def f():
raise ExceptionGroup(
"Group message",
[
RuntimeError(),
],
)
def test_exception_in_group():
with pytest.raises(ExceptionGroup) as excinfo:
f()
assert excinfo.group_contains(RuntimeError)
assert not excinfo.group_contains(TypeError)
Execute the test function with “quiet” reporting mode: Execute the test function with “quiet” reporting mode:
.. code-block:: pytest .. code-block:: pytest

View File

@ -115,10 +115,56 @@ that a regular expression matches on the string representation of an exception
with pytest.raises(ValueError, match=r".* 123 .*"): with pytest.raises(ValueError, match=r".* 123 .*"):
myfunc() myfunc()
The regexp parameter of the ``match`` method is matched with the ``re.search`` The regexp parameter of the ``match`` parameter is matched with the ``re.search``
function, so in the above example ``match='123'`` would have worked as function, so in the above example ``match='123'`` would have worked as
well. well.
You can also use the :func:`excinfo.group_contains() <pytest.ExceptionInfo.group_contains>`
method to test for exceptions returned as part of an ``ExceptionGroup``:
.. code-block:: python
def test_exception_in_group():
with pytest.raises(RuntimeError) as excinfo:
raise ExceptionGroup(
"Group message",
[
RuntimeError("Exception 123 raised"),
],
)
assert excinfo.group_contains(RuntimeError, match=r".* 123 .*")
assert not excinfo.group_contains(TypeError)
The optional ``match`` keyword parameter works the same way as for
:func:`pytest.raises`.
By default ``group_contains()`` will recursively search for a matching
exception at any level of nested ``ExceptionGroup`` instances. You can
specify a ``depth`` keyword parameter if you only want to match an
exception at a specific level; exceptions contained directly in the top
``ExceptionGroup`` would match ``depth=1``.
.. code-block:: python
def test_exception_in_group_at_given_depth():
with pytest.raises(RuntimeError) as excinfo:
raise ExceptionGroup(
"Group message",
[
RuntimeError(),
ExceptionGroup(
"Nested group",
[
TypeError(),
],
),
],
)
assert excinfo.group_contains(RuntimeError, depth=1)
assert excinfo.group_contains(TypeError, depth=2)
assert not excinfo.group_contains(RuntimeError, depth=2)
assert not excinfo.group_contains(TypeError, depth=1)
There's an alternate form of the :func:`pytest.raises` function where you pass There's an alternate form of the :func:`pytest.raises` function where you pass
a function that will be executed with the given ``*args`` and ``**kwargs`` and a function that will be executed with the given ``*args`` and ``**kwargs`` and
assert that the given exception is raised: assert that the given exception is raised:

View File

@ -697,6 +697,14 @@ class ExceptionInfo(Generic[E]):
) )
return fmt.repr_excinfo(self) return fmt.repr_excinfo(self)
def _stringify_exception(self, exc: BaseException) -> str:
return "\n".join(
[
str(exc),
*getattr(exc, "__notes__", []),
]
)
def match(self, regexp: Union[str, Pattern[str]]) -> "Literal[True]": def match(self, regexp: Union[str, Pattern[str]]) -> "Literal[True]":
"""Check whether the regular expression `regexp` matches the string """Check whether the regular expression `regexp` matches the string
representation of the exception using :func:`python:re.search`. representation of the exception using :func:`python:re.search`.
@ -704,12 +712,7 @@ class ExceptionInfo(Generic[E]):
If it matches `True` is returned, otherwise an `AssertionError` is raised. If it matches `True` is returned, otherwise an `AssertionError` is raised.
""" """
__tracebackhide__ = True __tracebackhide__ = True
value = "\n".join( value = self._stringify_exception(self.value)
[
str(self.value),
*getattr(self.value, "__notes__", []),
]
)
msg = f"Regex pattern did not match.\n Regex: {regexp!r}\n Input: {value!r}" msg = f"Regex pattern did not match.\n Regex: {regexp!r}\n Input: {value!r}"
if regexp == value: if regexp == value:
msg += "\n Did you mean to `re.escape()` the regex?" msg += "\n Did you mean to `re.escape()` the regex?"
@ -717,6 +720,69 @@ class ExceptionInfo(Generic[E]):
# Return True to allow for "assert excinfo.match()". # Return True to allow for "assert excinfo.match()".
return True return True
def _group_contains(
self,
exc_group: BaseExceptionGroup[BaseException],
expected_exception: Union[Type[BaseException], Tuple[Type[BaseException], ...]],
match: Union[str, Pattern[str], None],
target_depth: Optional[int] = None,
current_depth: int = 1,
) -> bool:
"""Return `True` if a `BaseExceptionGroup` contains a matching exception."""
if (target_depth is not None) and (current_depth > target_depth):
# already descended past the target depth
return False
for exc in exc_group.exceptions:
if isinstance(exc, BaseExceptionGroup):
if self._group_contains(
exc, expected_exception, match, target_depth, current_depth + 1
):
return True
if (target_depth is not None) and (current_depth != target_depth):
# not at the target depth, no match
continue
if not isinstance(exc, expected_exception):
continue
if match is not None:
value = self._stringify_exception(exc)
if not re.search(match, value):
continue
return True
return False
def group_contains(
self,
expected_exception: Union[Type[BaseException], Tuple[Type[BaseException], ...]],
*,
match: Union[str, Pattern[str], None] = None,
depth: Optional[int] = None,
) -> bool:
"""Check whether a captured exception group contains a matching exception.
:param Type[BaseException] | Tuple[Type[BaseException]] expected_exception:
The expected exception type, or a tuple if one of multiple possible
exception types are expected.
:param str | Pattern[str] | None match:
If specified, a string containing a regular expression,
or a regular expression object, that is tested against the string
representation of the exception and its `PEP-678 <https://peps.python.org/pep-0678/>` `__notes__`
using :func:`re.search`.
To match a literal string that may contain :ref:`special characters
<re-syntax>`, the pattern can first be escaped with :func:`re.escape`.
:param Optional[int] depth:
If `None`, will search for a matching exception at any nesting depth.
If >= 1, will only match an exception if it's at the specified depth (depth = 1 being
the exceptions contained within the topmost exception group).
"""
msg = "Captured exception is not an instance of `BaseExceptionGroup`"
assert isinstance(self.value, BaseExceptionGroup), msg
msg = "`depth` must be >= 1 if specified"
assert (depth is None) or (depth >= 1), msg
return self._group_contains(self.value, expected_exception, match, depth)
@dataclasses.dataclass @dataclasses.dataclass
class FormattedExcinfo: class FormattedExcinfo:

View File

@ -27,6 +27,9 @@ from _pytest.pytester import Pytester
if TYPE_CHECKING: if TYPE_CHECKING:
from _pytest._code.code import _TracebackStyle from _pytest._code.code import _TracebackStyle
if sys.version_info[:2] < (3, 11):
from exceptiongroup import ExceptionGroup
@pytest.fixture @pytest.fixture
def limited_recursion_depth(): def limited_recursion_depth():
@ -444,6 +447,92 @@ def test_match_raises_error(pytester: Pytester) -> None:
result.stdout.re_match_lines([r".*__tracebackhide__ = True.*", *match]) result.stdout.re_match_lines([r".*__tracebackhide__ = True.*", *match])
class TestGroupContains:
def test_contains_exception_type(self) -> None:
exc_group = ExceptionGroup("", [RuntimeError()])
with pytest.raises(ExceptionGroup) as exc_info:
raise exc_group
assert exc_info.group_contains(RuntimeError)
def test_doesnt_contain_exception_type(self) -> None:
exc_group = ExceptionGroup("", [ValueError()])
with pytest.raises(ExceptionGroup) as exc_info:
raise exc_group
assert not exc_info.group_contains(RuntimeError)
def test_contains_exception_match(self) -> None:
exc_group = ExceptionGroup("", [RuntimeError("exception message")])
with pytest.raises(ExceptionGroup) as exc_info:
raise exc_group
assert exc_info.group_contains(RuntimeError, match=r"^exception message$")
def test_doesnt_contain_exception_match(self) -> None:
exc_group = ExceptionGroup("", [RuntimeError("message that will not match")])
with pytest.raises(ExceptionGroup) as exc_info:
raise exc_group
assert not exc_info.group_contains(RuntimeError, match=r"^exception message$")
def test_contains_exception_type_unlimited_depth(self) -> None:
exc_group = ExceptionGroup("", [ExceptionGroup("", [RuntimeError()])])
with pytest.raises(ExceptionGroup) as exc_info:
raise exc_group
assert exc_info.group_contains(RuntimeError)
def test_contains_exception_type_at_depth_1(self) -> None:
exc_group = ExceptionGroup("", [RuntimeError()])
with pytest.raises(ExceptionGroup) as exc_info:
raise exc_group
assert exc_info.group_contains(RuntimeError, depth=1)
def test_doesnt_contain_exception_type_past_depth(self) -> None:
exc_group = ExceptionGroup("", [ExceptionGroup("", [RuntimeError()])])
with pytest.raises(ExceptionGroup) as exc_info:
raise exc_group
assert not exc_info.group_contains(RuntimeError, depth=1)
def test_contains_exception_type_specific_depth(self) -> None:
exc_group = ExceptionGroup("", [ExceptionGroup("", [RuntimeError()])])
with pytest.raises(ExceptionGroup) as exc_info:
raise exc_group
assert exc_info.group_contains(RuntimeError, depth=2)
def test_contains_exception_match_unlimited_depth(self) -> None:
exc_group = ExceptionGroup(
"", [ExceptionGroup("", [RuntimeError("exception message")])]
)
with pytest.raises(ExceptionGroup) as exc_info:
raise exc_group
assert exc_info.group_contains(RuntimeError, match=r"^exception message$")
def test_contains_exception_match_at_depth_1(self) -> None:
exc_group = ExceptionGroup("", [RuntimeError("exception message")])
with pytest.raises(ExceptionGroup) as exc_info:
raise exc_group
assert exc_info.group_contains(
RuntimeError, match=r"^exception message$", depth=1
)
def test_doesnt_contain_exception_match_past_depth(self) -> None:
exc_group = ExceptionGroup(
"", [ExceptionGroup("", [RuntimeError("exception message")])]
)
with pytest.raises(ExceptionGroup) as exc_info:
raise exc_group
assert not exc_info.group_contains(
RuntimeError, match=r"^exception message$", depth=1
)
def test_contains_exception_match_specific_depth(self) -> None:
exc_group = ExceptionGroup(
"", [ExceptionGroup("", [RuntimeError("exception message")])]
)
with pytest.raises(ExceptionGroup) as exc_info:
raise exc_group
assert exc_info.group_contains(
RuntimeError, match=r"^exception message$", depth=2
)
class TestFormattedExcinfo: class TestFormattedExcinfo:
@pytest.fixture @pytest.fixture
def importasmod(self, tmp_path: Path, _sys_snapshot): def importasmod(self, tmp_path: Path, _sys_snapshot):