From c93130c1b9048d66551070d28aabe631c0f10b8e Mon Sep 17 00:00:00 2001 From: jakkdl Date: Tue, 4 Feb 2025 13:30:01 +0100 Subject: [PATCH 1/5] add RaisesGroup & Matcher --- changelog/11671.feature.rst | 1 + src/_pytest/_raises_group.py | 1003 ++++++++++++++++++++++++++++ src/pytest/__init__.py | 6 + testing/python/raises_group.py | 1137 ++++++++++++++++++++++++++++++++ testing/typing_raises_group.py | 234 +++++++ 5 files changed, 2381 insertions(+) create mode 100644 changelog/11671.feature.rst create mode 100644 src/_pytest/_raises_group.py create mode 100644 testing/python/raises_group.py create mode 100644 testing/typing_raises_group.py diff --git a/changelog/11671.feature.rst b/changelog/11671.feature.rst new file mode 100644 index 00000000000..9e401112ad0 --- /dev/null +++ b/changelog/11671.feature.rst @@ -0,0 +1 @@ +Added `RaisesGroup` (also available as `raises_group`) and `Matcher`, as an equivalent to `raises` for expecting `ExceptionGroup`. It includes the ability to specity multiple different expected exceptions, the structure of nested exception groups, and/or closely emulating `except_star`. diff --git a/src/_pytest/_raises_group.py b/src/_pytest/_raises_group.py new file mode 100644 index 00000000000..68303c4a3fe --- /dev/null +++ b/src/_pytest/_raises_group.py @@ -0,0 +1,1003 @@ +from __future__ import annotations + +from abc import ABC +from abc import abstractmethod +import re +from re import Pattern +import sys +from textwrap import indent +from typing import cast +from typing import final +from typing import Generic +from typing import Literal +from typing import overload +from typing import TYPE_CHECKING + +from _pytest._code import ExceptionInfo +from _pytest.outcomes import fail + + +if TYPE_CHECKING: + from collections.abc import Callable + from collections.abc import Sequence + + # for some reason Sphinx does not play well with 'from types import TracebackType' + import types + + from typing_extensions import TypeGuard + from typing_extensions import TypeVar + + # this conditional definition is because we want to allow a TypeVar default + MatchE = TypeVar( + "MatchE", + bound=BaseException, + default=BaseException, + covariant=True, + ) +else: + from typing import TypeVar + + MatchE = TypeVar("MatchE", bound=BaseException, covariant=True) + +# RaisesGroup doesn't work with a default. +BaseExcT_co = TypeVar("BaseExcT_co", bound=BaseException, covariant=True) +BaseExcT_1 = TypeVar("BaseExcT_1", bound=BaseException) +BaseExcT_2 = TypeVar("BaseExcT_2", bound=BaseException) +ExcT_1 = TypeVar("ExcT_1", bound=Exception) +ExcT_2 = TypeVar("ExcT_2", bound=Exception) + +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + from exceptiongroup import ExceptionGroup + + +# this differs slightly from pytest.ExceptionInfo._stringify_exception +# as we don't want '(1 sub-exception)' when matching group strings +def _stringify_exception(exc: BaseException) -> str: + return "\n".join( + [ + exc.message if isinstance(exc, BaseExceptionGroup) else str(exc), + *getattr(exc, "__notes__", []), + ], + ) + + +# String patterns default to including the unicode flag. +_REGEX_NO_FLAGS = re.compile(r"").flags + + +def _match_pattern(match: Pattern[str]) -> str | Pattern[str]: + """Helper function to remove redundant `re.compile` calls when printing regex""" + return match.pattern if match.flags == _REGEX_NO_FLAGS else match + + +def repr_callable(fun: Callable[[BaseExcT_1], bool]) -> str: + """Get the repr of a ``check`` parameter. + + Split out so it can be monkeypatched (e.g. by hypothesis) + """ + return repr(fun) + + +def _exception_type_name(e: type[BaseException]) -> str: + return repr(e.__name__) + + +def _check_raw_type( + expected_type: type[BaseException] | None, + exception: BaseException, +) -> str | None: + if expected_type is None: + return None + + if not isinstance( + exception, + expected_type, + ): + actual_type_str = _exception_type_name(type(exception)) + expected_type_str = _exception_type_name(expected_type) + if isinstance(exception, BaseExceptionGroup) and not issubclass( + expected_type, BaseExceptionGroup + ): + return f"Unexpected nested {actual_type_str}, expected {expected_type_str}" + return f"{actual_type_str} is not of type {expected_type_str}" + return None + + +class AbstractMatcher(ABC, Generic[BaseExcT_co]): + """ABC with common functionality shared between Matcher and RaisesGroup""" + + def __init__( + self, + match: str | Pattern[str] | None, + check: Callable[[BaseExcT_co], bool] | None, + ) -> None: + if isinstance(match, str): + self.match: Pattern[str] | None = re.compile(match) + else: + self.match = match + self.check = check + self._fail_reason: str | None = None + + # used to suppress repeated printing of `repr(self.check)` + self._nested: bool = False + + @property + def fail_reason(self) -> str | None: + """Set after a call to `matches` to give a human-readable reason for why the match failed. + When used as a context manager the string will be given as the text of an + `Failed`""" + return self._fail_reason + + def _check_check( + self: AbstractMatcher[BaseExcT_1], + exception: BaseExcT_1, + ) -> bool: + if self.check is None: + return True + + if self.check(exception): + return True + + check_repr = "" if self._nested else " " + repr_callable(self.check) + self._fail_reason = f"check{check_repr} did not return True" + return False + + def _check_match(self, e: BaseException) -> bool: + if self.match is None or re.search( + self.match, + stringified_exception := _stringify_exception(e), + ): + return True + + maybe_specify_type = ( + f" of {_exception_type_name(type(e))}" + if isinstance(e, BaseExceptionGroup) + else "" + ) + self._fail_reason = ( + f"Regex pattern {_match_pattern(self.match)!r}" + f" did not match {stringified_exception!r}{maybe_specify_type}" + ) + if _match_pattern(self.match) == stringified_exception: + self._fail_reason += "\n Did you mean to `re.escape()` the regex?" + return False + + @abstractmethod + def matches( + self: AbstractMatcher[BaseExcT_1], exc_val: BaseException + ) -> TypeGuard[BaseExcT_1]: + """Check if an exception matches the requirements of this AbstractMatcher. + If it fails, `AbstractMatcher.fail_reason` should be set. + """ + + +@final +class Matcher(AbstractMatcher[MatchE]): + """Helper class to be used together with RaisesGroups when you want to specify requirements on sub-exceptions. + Only specifying the type is redundant, and it's also unnecessary when the type is a + nested `RaisesGroup` since it supports the same arguments. + The type is checked with `isinstance`, and does not need to be an exact match. + If that is wanted you can use the ``check`` parameter. + :meth:`Matcher.matches` can also be used standalone to check individual exceptions. + + Examples:: + + with RaisesGroups(Matcher(ValueError, match="string")) + ... + with RaisesGroups(Matcher(check=lambda x: x.args == (3, "hello"))): + ... + with RaisesGroups(Matcher(check=lambda x: type(x) is ValueError)): + ... + + Tip: if you install ``hypothesis`` and import it in ``conftest.py`` you will get + readable ``repr``s of ``check`` callables in the output. + """ + + # Trio bundled hypothesis monkeypatching, we will probably instead assume that + # hypothesis will handle that in their pytest plugin by the time this is released. + # Alternatively we could add a version of get_pretty_function_description ourselves + # https://github.com/HypothesisWorks/hypothesis/blob/8ced2f59f5c7bea3344e35d2d53e1f8f8eb9fcd8/hypothesis-python/src/hypothesis/internal/reflection.py#L439 + + # At least one of the three parameters must be passed. + @overload + def __init__( + self: Matcher[MatchE], + exception_type: type[MatchE], + match: str | Pattern[str] = ..., + check: Callable[[MatchE], bool] = ..., + ) -> None: ... + + @overload + def __init__( + self: Matcher[BaseException], # Give E a value. + *, + match: str | Pattern[str], + # If exception_type is not provided, check() must do any typechecks itself. + check: Callable[[BaseException], bool] = ..., + ) -> None: ... + + @overload + def __init__(self, *, check: Callable[[BaseException], bool]) -> None: ... + + def __init__( + self, + exception_type: type[MatchE] | None = None, + match: str | Pattern[str] | None = None, + check: Callable[[MatchE], bool] | None = None, + ): + super().__init__(match, check) + if exception_type is None and match is None and check is None: + raise ValueError("You must specify at least one parameter to match on.") + if exception_type is not None and not issubclass(exception_type, BaseException): + raise TypeError( + f"exception_type {exception_type} must be a subclass of BaseException", + ) + self.exception_type = exception_type + + def matches( + self, + exception: BaseException, + ) -> TypeGuard[MatchE]: + """Check if an exception matches the requirements of this Matcher. + If it fails, `Matcher.fail_reason` will be set. + + Examples:: + + assert Matcher(ValueError).matches(my_exception): + # is equivalent to + assert isinstance(my_exception, ValueError) + + # this can be useful when checking e.g. the ``__cause__`` of an exception. + with pytest.raises(ValueError) as excinfo: + ... + assert Matcher(SyntaxError, match="foo").matches(excinfo.value.__cause__) + # above line is equivalent to + assert isinstance(excinfo.value.__cause__, SyntaxError) + assert re.search("foo", str(excinfo.value.__cause__) + + """ + if not self._check_type(exception): + return False + + if not self._check_match(exception): + return False + + return self._check_check(exception) + + def __repr__(self) -> str: + parameters = [] + if self.exception_type is not None: + parameters.append(self.exception_type.__name__) + if self.match is not None: + # If no flags were specified, discard the redundant re.compile() here. + parameters.append( + f"match={_match_pattern(self.match)!r}", + ) + if self.check is not None: + parameters.append(f"check={repr_callable(self.check)}") + return f"Matcher({', '.join(parameters)})" + + def _check_type(self, exception: BaseException) -> TypeGuard[MatchE]: + self._fail_reason = _check_raw_type(self.exception_type, exception) + return self._fail_reason is None + + +@final +class RaisesGroup(AbstractMatcher[BaseExceptionGroup[BaseExcT_co]]): + """Contextmanager for checking for an expected `ExceptionGroup`. + This works similar to ``pytest.raises``, but allows for specifying the structure of an `ExceptionGroup`. + `ExceptionInfo.group_contains` also tries to handle exception groups, + but it is very bad at checking that you *didn't* get exceptions you didn't expect. + + + The catching behaviour differs from :ref:`except* ` in multiple + different ways, being much stricter by default. + By using ``allow_unwrapped=True`` and ``flatten_subgroups=True`` you can match + ``except*`` fully when expecting a single exception. + + #. All specified exceptions must be present, *and no others*. + + * If you expect a variable number of exceptions you need to use ``pytest.raises(ExceptionGroup)`` and manually + check the contained exceptions. Consider making use of :func:`Matcher.matches`. + + #. It will only catch exceptions wrapped in an exceptiongroup by default. + + * With ``allow_unwrapped=True`` you can specify a single expected exception or `Matcher` and it will match + the exception even if it is not inside an `ExceptionGroup`. + If you expect one of several different exception types you need to use a `Matcher` object. + + #. By default it cares about the full structure with nested `ExceptionGroup`'s. You can specify nested + `ExceptionGroup`'s by passing `RaisesGroup` objects as expected exceptions. + + * With ``flatten_subgroups=True`` it will "flatten" the raised `ExceptionGroup`, + extracting all exceptions inside any nested :class:`ExceptionGroup`, before matching. + + It does not care about the order of the exceptions, so + ``RaisesGroups(ValueError, TypeError)`` + is equivalent to + ``RaisesGroups(TypeError, ValueError)``. + + Examples:: + + with RaisesGroups(ValueError): + raise ExceptionGroup("", (ValueError(),)) + with RaisesGroups( + ValueError, ValueError, Matcher(TypeError, match="expected int") + ): + ... + with RaisesGroups( + KeyboardInterrupt, + match="hello", + check=lambda x: type(x) is BaseExceptionGroup, + ): + ... + with RaisesGroups(RaisesGroups(ValueError)): + raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)) + + # flatten_subgroups + with RaisesGroups(ValueError, flatten_subgroups=True): + raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)) + + # allow_unwrapped + with RaisesGroups(ValueError, allow_unwrapped=True): + raise ValueError + + + `RaisesGroup.matches` can also be used directly to check a standalone exception group. + + + The matching algorithm is greedy, which means cases such as this may fail:: + + with RaisesGroups(ValueError, Matcher(ValueError, match="hello")): + raise ExceptionGroup("", (ValueError("hello"), ValueError("goodbye"))) + + even though it generally does not care about the order of the exceptions in the group. + To avoid the above you should specify the first ValueError with a Matcher as well. + + Tip: if you install ``hypothesis`` and import it in ``conftest.py`` you will get + readable ``repr``s of ``check`` callables in the output. + """ + + # allow_unwrapped=True requires: singular exception, exception not being + # RaisesGroup instance, match is None, check is None + @overload + def __init__( + self, + exception: type[BaseExcT_co] | Matcher[BaseExcT_co], + *, + allow_unwrapped: Literal[True], + flatten_subgroups: bool = False, + ) -> None: ... + + # flatten_subgroups = True also requires no nested RaisesGroup + @overload + def __init__( + self, + exception: type[BaseExcT_co] | Matcher[BaseExcT_co], + *other_exceptions: type[BaseExcT_co] | Matcher[BaseExcT_co], + flatten_subgroups: Literal[True], + match: str | Pattern[str] | None = None, + check: Callable[[BaseExceptionGroup[BaseExcT_co]], bool] | None = None, + ) -> None: ... + + # simplify the typevars if possible (the following 3 are equivalent but go simpler->complicated) + # ... the first handles RaisesGroup[ValueError], the second RaisesGroup[ExceptionGroup[ValueError]], + # the third RaisesGroup[ValueError | ExceptionGroup[ValueError]]. + # ... otherwise, we will get results like RaisesGroup[ValueError | ExceptionGroup[Never]] (I think) + # (technically correct but misleading) + @overload + def __init__( + self: RaisesGroup[ExcT_1], + exception: type[ExcT_1] | Matcher[ExcT_1], + *other_exceptions: type[ExcT_1] | Matcher[ExcT_1], + match: str | Pattern[str] | None = None, + check: Callable[[ExceptionGroup[ExcT_1]], bool] | None = None, + ) -> None: ... + + @overload + def __init__( + self: RaisesGroup[ExceptionGroup[ExcT_2]], + exception: RaisesGroup[ExcT_2], + *other_exceptions: RaisesGroup[ExcT_2], + match: str | Pattern[str] | None = None, + check: Callable[[ExceptionGroup[ExceptionGroup[ExcT_2]]], bool] | None = None, + ) -> None: ... + + @overload + def __init__( + self: RaisesGroup[ExcT_1 | ExceptionGroup[ExcT_2]], + exception: type[ExcT_1] | Matcher[ExcT_1] | RaisesGroup[ExcT_2], + *other_exceptions: type[ExcT_1] | Matcher[ExcT_1] | RaisesGroup[ExcT_2], + match: str | Pattern[str] | None = None, + check: ( + Callable[[ExceptionGroup[ExcT_1 | ExceptionGroup[ExcT_2]]], bool] | None + ) = None, + ) -> None: ... + + # same as the above 3 but handling BaseException + @overload + def __init__( + self: RaisesGroup[BaseExcT_1], + exception: type[BaseExcT_1] | Matcher[BaseExcT_1], + *other_exceptions: type[BaseExcT_1] | Matcher[BaseExcT_1], + match: str | Pattern[str] | None = None, + check: Callable[[BaseExceptionGroup[BaseExcT_1]], bool] | None = None, + ) -> None: ... + + @overload + def __init__( + self: RaisesGroup[BaseExceptionGroup[BaseExcT_2]], + exception: RaisesGroup[BaseExcT_2], + *other_exceptions: RaisesGroup[BaseExcT_2], + match: str | Pattern[str] | None = None, + check: ( + Callable[[BaseExceptionGroup[BaseExceptionGroup[BaseExcT_2]]], bool] | None + ) = None, + ) -> None: ... + + @overload + def __init__( + self: RaisesGroup[BaseExcT_1 | BaseExceptionGroup[BaseExcT_2]], + exception: type[BaseExcT_1] | Matcher[BaseExcT_1] | RaisesGroup[BaseExcT_2], + *other_exceptions: type[BaseExcT_1] + | Matcher[BaseExcT_1] + | RaisesGroup[BaseExcT_2], + match: str | Pattern[str] | None = None, + check: ( + Callable[ + [BaseExceptionGroup[BaseExcT_1 | BaseExceptionGroup[BaseExcT_2]]], + bool, + ] + | None + ) = None, + ) -> None: ... + + def __init__( + self: RaisesGroup[ExcT_1 | BaseExcT_1 | BaseExceptionGroup[BaseExcT_2]], + exception: type[BaseExcT_1] | Matcher[BaseExcT_1] | RaisesGroup[BaseExcT_2], + *other_exceptions: type[BaseExcT_1] + | Matcher[BaseExcT_1] + | RaisesGroup[BaseExcT_2], + allow_unwrapped: bool = False, + flatten_subgroups: bool = False, + match: str | Pattern[str] | None = None, + check: ( + Callable[[BaseExceptionGroup[BaseExcT_1]], bool] + | Callable[[ExceptionGroup[ExcT_1]], bool] + | None + ) = None, + ): + # The type hint on the `self` and `check` parameters uses different formats + # that are *very* hard to reconcile while adhering to the overloads, so we cast + # it to avoid an error when passing it to super().__init__ + check = cast( + "Callable[[" + "BaseExceptionGroup[ExcT_1|BaseExcT_1|BaseExceptionGroup[BaseExcT_2]]" + "], bool]", + check, + ) + super().__init__(match, check) + self.expected_exceptions: tuple[ + type[BaseExcT_co] | Matcher[BaseExcT_co] | RaisesGroup[BaseException], ... + ] = ( + exception, + *other_exceptions, + ) + self.allow_unwrapped = allow_unwrapped + self.flatten_subgroups: bool = flatten_subgroups + self.is_baseexceptiongroup = False + + if allow_unwrapped and other_exceptions: + raise ValueError( + "You cannot specify multiple exceptions with `allow_unwrapped=True.`" + " If you want to match one of multiple possible exceptions you should" + " use a `Matcher`." + " E.g. `Matcher(check=lambda e: isinstance(e, (...)))`", + ) + if allow_unwrapped and isinstance(exception, RaisesGroup): + raise ValueError( + "`allow_unwrapped=True` has no effect when expecting a `RaisesGroup`." + " You might want it in the expected `RaisesGroup`, or" + " `flatten_subgroups=True` if you don't care about the structure.", + ) + if allow_unwrapped and (match is not None or check is not None): + raise ValueError( + "`allow_unwrapped=True` bypasses the `match` and `check` parameters" + " if the exception is unwrapped. If you intended to match/check the" + " exception you should use a `Matcher` object. If you want to match/check" + " the exceptiongroup when the exception *is* wrapped you need to" + " do e.g. `if isinstance(exc.value, ExceptionGroup):" + " assert RaisesGroup(...).matches(exc.value)` afterwards.", + ) + + # verify `expected_exceptions` and set `self.is_baseexceptiongroup` + for exc in self.expected_exceptions: + if isinstance(exc, RaisesGroup): + if self.flatten_subgroups: + raise ValueError( + "You cannot specify a nested structure inside a RaisesGroup with" + " `flatten_subgroups=True`. The parameter will flatten subgroups" + " in the raised exceptiongroup before matching, which would never" + " match a nested structure.", + ) + self.is_baseexceptiongroup |= exc.is_baseexceptiongroup + exc._nested = True + elif isinstance(exc, Matcher): + if exc.exception_type is not None: + # Matcher __init__ assures it's a subclass of BaseException + self.is_baseexceptiongroup |= not issubclass( + exc.exception_type, + Exception, + ) + exc._nested = True + elif isinstance(exc, type) and issubclass(exc, BaseException): + self.is_baseexceptiongroup |= not issubclass(exc, Exception) + else: + raise TypeError( + f'Invalid argument "{exc!r}" must be exception type, Matcher, or' + " RaisesGroup.", + ) + + @overload + def __enter__( + self: RaisesGroup[ExcT_1], + ) -> ExceptionInfo[ExceptionGroup[ExcT_1]]: ... + @overload + def __enter__( + self: RaisesGroup[BaseExcT_1], + ) -> ExceptionInfo[BaseExceptionGroup[BaseExcT_1]]: ... + + def __enter__(self) -> ExceptionInfo[BaseExceptionGroup[BaseException]]: + self.excinfo: ExceptionInfo[BaseExceptionGroup[BaseExcT_co]] = ( + ExceptionInfo.for_later() + ) + return self.excinfo + + def __repr__(self) -> str: + reqs = [ + e.__name__ if isinstance(e, type) else repr(e) + for e in self.expected_exceptions + ] + if self.allow_unwrapped: + reqs.append(f"allow_unwrapped={self.allow_unwrapped}") + if self.flatten_subgroups: + reqs.append(f"flatten_subgroups={self.flatten_subgroups}") + if self.match is not None: + # If no flags were specified, discard the redundant re.compile() here. + reqs.append(f"match={_match_pattern(self.match)!r}") + if self.check is not None: + reqs.append(f"check={repr_callable(self.check)}") + return f"RaisesGroup({', '.join(reqs)})" + + def _unroll_exceptions( + self, + exceptions: Sequence[BaseException], + ) -> Sequence[BaseException]: + """Used if `flatten_subgroups=True`.""" + res: list[BaseException] = [] + for exc in exceptions: + if isinstance(exc, BaseExceptionGroup): + res.extend(self._unroll_exceptions(exc.exceptions)) + + else: + res.append(exc) + return res + + @overload + def matches( + self: RaisesGroup[ExcT_1], + exc_val: BaseException | None, + ) -> TypeGuard[ExceptionGroup[ExcT_1]]: ... + @overload + def matches( + self: RaisesGroup[BaseExcT_1], + exc_val: BaseException | None, + ) -> TypeGuard[BaseExceptionGroup[BaseExcT_1]]: ... + + def matches( + self, + exc_val: BaseException | None, + ) -> TypeGuard[BaseExceptionGroup[BaseExcT_co]]: + """Check if an exception matches the requirements of this RaisesGroup. + If it fails, `RaisesGroup.fail_reason` will be set. + + Example:: + + with pytest.raises(TypeError) as excinfo: + ... + assert RaisesGroups(ValueError).matches(excinfo.value.__cause__) + # the above line is equivalent to + myexc = excinfo.value.__cause + assert isinstance(myexc, BaseExceptionGroup) + assert len(myexc.exceptions) == 1 + assert isinstance(myexc.exceptions[0], ValueError) + """ + self._fail_reason = None + if exc_val is None: + self._fail_reason = "exception is None" + return False + if not isinstance(exc_val, BaseExceptionGroup): + # we opt to only print type of the exception here, as the repr would + # likely be quite long + not_group_msg = f"{type(exc_val).__name__!r} is not an exception group" + if len(self.expected_exceptions) > 1: + self._fail_reason = not_group_msg + return False + # if we have 1 expected exception, check if it would work even if + # allow_unwrapped is not set + res = self._check_expected(self.expected_exceptions[0], exc_val) + if res is None and self.allow_unwrapped: + return True + + if res is None: + self._fail_reason = ( + f"{not_group_msg}, but would match with `allow_unwrapped=True`" + ) + elif self.allow_unwrapped: + self._fail_reason = res + else: + self._fail_reason = not_group_msg + return False + + actual_exceptions: Sequence[BaseException] = exc_val.exceptions + if self.flatten_subgroups: + actual_exceptions = self._unroll_exceptions(actual_exceptions) + + if not self._check_match(exc_val): + self._fail_reason = cast(str, self._fail_reason) + old_reason = self._fail_reason + if ( + len(actual_exceptions) == len(self.expected_exceptions) == 1 + and isinstance(expected := self.expected_exceptions[0], type) + and isinstance(actual := actual_exceptions[0], expected) + and self._check_match(actual) + ): + assert self.match is not None, "can't be None if _check_match failed" + assert self._fail_reason is old_reason is not None + self._fail_reason += ( + f", but matched the expected {self._repr_expected(expected)}." + f" You might want RaisesGroup(Matcher({expected.__name__}, match={_match_pattern(self.match)!r}))" + ) + else: + self._fail_reason = old_reason + return False + + # do the full check on expected exceptions + if not self._check_exceptions( + exc_val, + actual_exceptions, + ): + self._fail_reason = cast(str, self._fail_reason) + assert self._fail_reason is not None + old_reason = self._fail_reason + # if we're not expecting a nested structure, and there is one, do a second + # pass where we try flattening it + if ( + not self.flatten_subgroups + and not any( + isinstance(e, RaisesGroup) for e in self.expected_exceptions + ) + and any(isinstance(e, BaseExceptionGroup) for e in actual_exceptions) + and self._check_exceptions( + exc_val, + self._unroll_exceptions(exc_val.exceptions), + ) + ): + # only indent if it's a single-line reason. In a multi-line there's already + # indented lines that this does not belong to. + indent = " " if "\n" not in self._fail_reason else "" + self._fail_reason = ( + old_reason + + f"\n{indent}Did you mean to use `flatten_subgroups=True`?" + ) + else: + self._fail_reason = old_reason + return False + + # Only run `self.check` once we know `exc_val` is of the correct type. + if not self._check_check(exc_val): + reason = cast(str, self._fail_reason) + f" on the {type(exc_val).__name__}" + if ( + len(actual_exceptions) == len(self.expected_exceptions) == 1 + and isinstance(expected := self.expected_exceptions[0], type) + # we explicitly break typing here :) + and self._check_check(actual_exceptions[0]) # type: ignore[arg-type] + ): + self._fail_reason = reason + ( + f", but did return True for the expected {self._repr_expected(expected)}." + f" You might want RaisesGroup(Matcher({expected.__name__}, check=<...>))" + ) + else: + self._fail_reason = reason + return False + + return True + + @staticmethod + def _check_expected( + expected_type: ( + type[BaseException] | Matcher[BaseException] | RaisesGroup[BaseException] + ), + exception: BaseException, + ) -> str | None: + """Helper method for `RaisesGroup.matches` and `RaisesGroup._check_exceptions` + to check one of potentially several expected exceptions.""" + if isinstance(expected_type, type): + return _check_raw_type(expected_type, exception) + res = expected_type.matches(exception) + if res: + return None + assert expected_type.fail_reason is not None + if expected_type.fail_reason.startswith("\n"): + return f"\n{expected_type!r}: {indent(expected_type.fail_reason, ' ')}" + return f"{expected_type!r}: {expected_type.fail_reason}" + + @staticmethod + def _repr_expected(e: type[BaseException] | AbstractMatcher[BaseException]) -> str: + """Get the repr of an expected type/Matcher/RaisesGroup, but we only want + the name if it's a type""" + if isinstance(e, type): + return _exception_type_name(e) + return repr(e) + + @overload + def _check_exceptions( + self: RaisesGroup[ExcT_1], + _exc_val: Exception, + actual_exceptions: Sequence[Exception], + ) -> TypeGuard[ExceptionGroup[ExcT_1]]: ... + @overload + def _check_exceptions( + self: RaisesGroup[BaseExcT_1], + _exc_val: BaseException, + actual_exceptions: Sequence[BaseException], + ) -> TypeGuard[BaseExceptionGroup[BaseExcT_1]]: ... + + def _check_exceptions( + self, + _exc_val: BaseException, + actual_exceptions: Sequence[BaseException], + ) -> TypeGuard[BaseExceptionGroup[BaseExcT_co]]: + """Helper method for RaisesGroup.matches that attempts to pair up expected and actual exceptions""" + # full table with all results + results = ResultHolder(self.expected_exceptions, actual_exceptions) + + # (indexes of) raised exceptions that haven't (yet) found an expected + remaining_actual = list(range(len(actual_exceptions))) + # (indexes of) expected exceptions that haven't found a matching raised + failed_expected: list[int] = [] + # successful greedy matches + matches: dict[int, int] = {} + + # loop over expected exceptions first to get a more predictable result + for i_exp, expected in enumerate(self.expected_exceptions): + for i_rem in remaining_actual: + res = self._check_expected(expected, actual_exceptions[i_rem]) + results.set_result(i_exp, i_rem, res) + if res is None: + remaining_actual.remove(i_rem) + matches[i_exp] = i_rem + break + else: + failed_expected.append(i_exp) + + # All exceptions matched up successfully + if not remaining_actual and not failed_expected: + return True + + # in case of a single expected and single raised we simplify the output + if 1 == len(actual_exceptions) == len(self.expected_exceptions): + assert not matches + self._fail_reason = res + return False + + # The test case is failing, so we can do a slow and exhaustive check to find + # duplicate matches etc that will be helpful in debugging + for i_exp, expected in enumerate(self.expected_exceptions): + for i_actual, actual in enumerate(actual_exceptions): + if results.has_result(i_exp, i_actual): + continue + results.set_result( + i_exp, i_actual, self._check_expected(expected, actual) + ) + + successful_str = ( + f"{len(matches)} matched exception{'s' if len(matches) > 1 else ''}. " + if matches + else "" + ) + + # all expected were found + if not failed_expected and results.no_match_for_actual(remaining_actual): + self._fail_reason = ( + f"{successful_str}Unexpected exception(s):" + f" {[actual_exceptions[i] for i in remaining_actual]!r}" + ) + return False + # all raised exceptions were expected + if not remaining_actual and results.no_match_for_expected(failed_expected): + no_match_for_str = ", ".join( + self._repr_expected(self.expected_exceptions[i]) + for i in failed_expected + ) + self._fail_reason = f"{successful_str}Too few exceptions raised, found no match for: [{no_match_for_str}]" + return False + + # if there's only one remaining and one failed, and the unmatched didn't match anything else, + # we elect to only print why the remaining and the failed didn't match. + if ( + 1 == len(remaining_actual) == len(failed_expected) + and results.no_match_for_actual(remaining_actual) + and results.no_match_for_expected(failed_expected) + ): + self._fail_reason = f"{successful_str}{results.get_result(failed_expected[0], remaining_actual[0])}" + return False + + # there's both expected and raised exceptions without matches + s = "" + if matches: + s += f"\n{successful_str}" + indent_1 = " " * 2 + indent_2 = " " * 4 + + if not remaining_actual: + s += "\nToo few exceptions raised!" + elif not failed_expected: + s += "\nUnexpected exception(s)!" + + if failed_expected: + s += "\nThe following expected exceptions did not find a match:" + rev_matches = {v: k for k, v in matches.items()} + for i_failed in failed_expected: + s += ( + f"\n{indent_1}{self._repr_expected(self.expected_exceptions[i_failed])}" + ) + for i_actual, actual in enumerate(actual_exceptions): + if results.get_result(i_exp, i_actual) is None: + # we print full repr of match target + s += ( + f"\n{indent_2}It matches {actual!r} which was paired with " + + self._repr_expected( + self.expected_exceptions[rev_matches[i_actual]] + ) + ) + + if remaining_actual: + s += "\nThe following raised exceptions did not find a match" + for i_actual in remaining_actual: + s += f"\n{indent_1}{actual_exceptions[i_actual]!r}:" + for i_exp, expected in enumerate(self.expected_exceptions): + res = results.get_result(i_exp, i_actual) + if i_exp in failed_expected: + assert res is not None + if res[0] != "\n": + s += "\n" + s += indent(res, indent_2) + if res is None: + # we print full repr of match target + s += ( + f"\n{indent_2}It matches {self._repr_expected(expected)} " + f"which was paired with {actual_exceptions[matches[i_exp]]!r}" + ) + + if len(self.expected_exceptions) == len(actual_exceptions) and possible_match( + results + ): + s += ( + "\nThere exist a possible match when attempting an exhaustive check," + " but RaisesGroup uses a greedy algorithm. " + "Please make your expected exceptions more stringent with `Matcher` etc" + " so the greedy algorithm can function." + ) + self._fail_reason = s + return False + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> bool: + __tracebackhide__ = True + if exc_type is None: + fail(f"DID NOT RAISE any exception, expected {self.expected_type()}") + + assert self.excinfo is not None, ( + "Internal error - should have been constructed in __enter__" + ) + + group_str = ( + "(group)" + if self.allow_unwrapped and not issubclass(exc_type, BaseExceptionGroup) + else "group" + ) + + if not self.matches(exc_val): + fail(f"Raised exception {group_str} did not match: {self._fail_reason}") + + # Cast to narrow the exception type now that it's verified.... + # even though the TypeGuard in self.matches should be narrowing + exc_info = cast( + "tuple[type[BaseExceptionGroup[BaseExcT_co]], BaseExceptionGroup[BaseExcT_co], types.TracebackType]", + (exc_type, exc_val, exc_tb), + ) + self.excinfo.fill_unfilled(exc_info) + return True + + def expected_type(self) -> str: + subexcs = [] + for e in self.expected_exceptions: + if isinstance(e, Matcher): + subexcs.append(str(e)) + elif isinstance(e, RaisesGroup): + subexcs.append(e.expected_type()) + elif isinstance(e, type): + subexcs.append(e.__name__) + else: # pragma: no cover + raise AssertionError("unknown type") + group_type = "Base" if self.is_baseexceptiongroup else "" + return f"{group_type}ExceptionGroup({', '.join(subexcs)})" + + +@final +class NotChecked: + """Singleton for unchecked values in ResultHolder""" + + +class ResultHolder: + """Container for results of checking exceptions. + Used in RaisesGroup._check_exceptions and possible_match. + """ + + def __init__( + self, + expected_exceptions: tuple[ + type[BaseException] | AbstractMatcher[BaseException], ... + ], + actual_exceptions: Sequence[BaseException], + ) -> None: + self.results: list[list[str | type[NotChecked] | None]] = [ + [NotChecked for _ in expected_exceptions] for _ in actual_exceptions + ] + + def set_result(self, expected: int, actual: int, result: str | None) -> None: + self.results[actual][expected] = result + + def get_result(self, expected: int, actual: int) -> str | None: + res = self.results[actual][expected] + assert res is not NotChecked + # mypy doesn't support identity checking against anything but None + return res # type: ignore[return-value] + + def has_result(self, expected: int, actual: int) -> bool: + return self.results[actual][expected] is not NotChecked + + def no_match_for_expected(self, expected: list[int]) -> bool: + for i in expected: + for actual_results in self.results: + assert actual_results[i] is not NotChecked + if actual_results[i] is None: + return False + return True + + def no_match_for_actual(self, actual: list[int]) -> bool: + for i in actual: + for res in self.results[i]: + assert res is not NotChecked + if res is None: + return False + return True + + +def possible_match(results: ResultHolder, used: set[int] | None = None) -> bool: + if used is None: + used = set() + curr_row = len(used) + if curr_row == len(results.results): + return True + + for i, val in enumerate(results.results[curr_row]): + if val is None and i not in used and possible_match(results, used | {i}): + return True + return False diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index 70096d6593e..ca2c66fee03 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -6,6 +6,9 @@ from _pytest import __version__ from _pytest import version_tuple from _pytest._code import ExceptionInfo +from _pytest._raises_group import Matcher +from _pytest._raises_group import RaisesGroup +from _pytest._raises_group import RaisesGroup as raises_group from _pytest.assertion import register_assert_rewrite from _pytest.cacheprovider import Cache from _pytest.capture import CaptureFixture @@ -113,6 +116,7 @@ "Mark", "MarkDecorator", "MarkGenerator", + "Matcher", "Metafunc", "Module", "MonkeyPatch", @@ -133,6 +137,7 @@ "PytestUnraisableExceptionWarning", "PytestWarning", "Pytester", + "RaisesGroup", "RecordedHookCall", "RunResult", "Session", @@ -162,6 +167,7 @@ "mark", "param", "raises", + "raises_group", "register_assert_rewrite", "set_trace", "skip", diff --git a/testing/python/raises_group.py b/testing/python/raises_group.py new file mode 100644 index 00000000000..c10398d0b9e --- /dev/null +++ b/testing/python/raises_group.py @@ -0,0 +1,1137 @@ +from __future__ import annotations + +# several expected multi-line strings contain long lines. We don't wanna break them up +# as that makes it confusing to see where the line breaks are. +# ruff: noqa: E501 +import re +import sys +from typing import TYPE_CHECKING + +from _pytest._raises_group import Matcher +from _pytest._raises_group import RaisesGroup +from _pytest._raises_group import repr_callable +from _pytest.outcomes import Failed +import pytest + + +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + from exceptiongroup import ExceptionGroup + +if TYPE_CHECKING: + from _pytest.python_api import RaisesContext + + +def wrap_escape(s: str) -> str: + return "^" + re.escape(s) + "$" + + +def fails_raises_group(msg: str, add_prefix: bool = True) -> RaisesContext[Failed]: + assert msg[-1] != "\n", ( + "developer error, expected string should not end with newline" + ) + prefix = "Raised exception group did not match: " if add_prefix else "" + return pytest.raises(Failed, match=wrap_escape(prefix + msg)) + + +def test_raises_group() -> None: + with pytest.raises( + TypeError, + match=wrap_escape( + f'Invalid argument "{ValueError()!r}" must be exception type, Matcher, or RaisesGroup.', + ), + ): + RaisesGroup(ValueError()) # type: ignore[call-overload] + with RaisesGroup(ValueError): + raise ExceptionGroup("foo", (ValueError(),)) + + with ( + fails_raises_group("'SyntaxError' is not of type 'ValueError'"), + RaisesGroup(ValueError), + ): + raise ExceptionGroup("foo", (SyntaxError(),)) + + # multiple exceptions + with RaisesGroup(ValueError, SyntaxError): + raise ExceptionGroup("foo", (ValueError(), SyntaxError())) + + # order doesn't matter + with RaisesGroup(SyntaxError, ValueError): + raise ExceptionGroup("foo", (ValueError(), SyntaxError())) + + # nested exceptions + with RaisesGroup(RaisesGroup(ValueError)): + raise ExceptionGroup("foo", (ExceptionGroup("bar", (ValueError(),)),)) + + with RaisesGroup( + SyntaxError, + RaisesGroup(ValueError), + RaisesGroup(RuntimeError), + ): + raise ExceptionGroup( + "foo", + ( + SyntaxError(), + ExceptionGroup("bar", (ValueError(),)), + ExceptionGroup("", (RuntimeError(),)), + ), + ) + + +def test_incorrect_number_exceptions() -> None: + # We previously gave an error saying the number of exceptions was wrong, + # but we now instead indicate excess/missing exceptions + with ( + fails_raises_group( + "1 matched exception. Unexpected exception(s): [RuntimeError()]" + ), + RaisesGroup(ValueError), + ): + raise ExceptionGroup("", (RuntimeError(), ValueError())) + + # will error if there's missing exceptions + with ( + fails_raises_group( + "1 matched exception. Too few exceptions raised, found no match for: ['SyntaxError']" + ), + RaisesGroup(ValueError, SyntaxError), + ): + raise ExceptionGroup("", (ValueError(),)) + + with ( + fails_raises_group( + "\n" + "1 matched exception. \n" + "Too few exceptions raised!\n" + "The following expected exceptions did not find a match:\n" + " 'ValueError'\n" + " It matches ValueError() which was paired with 'ValueError'" + ), + RaisesGroup(ValueError, ValueError), + ): + raise ExceptionGroup("", (ValueError(),)) + + with ( + fails_raises_group( + "\n" + "1 matched exception. \n" + "Unexpected exception(s)!\n" + "The following raised exceptions did not find a match\n" + " ValueError():\n" + " It matches 'ValueError' which was paired with ValueError()" + ), + RaisesGroup(ValueError), + ): + raise ExceptionGroup("", (ValueError(), ValueError())) + + with ( + fails_raises_group( + "\n" + "1 matched exception. \n" + "The following expected exceptions did not find a match:\n" + " 'ValueError'\n" + " It matches ValueError() which was paired with 'ValueError'\n" + "The following raised exceptions did not find a match\n" + " SyntaxError():\n" + " 'SyntaxError' is not of type 'ValueError'" + ), + RaisesGroup(ValueError, ValueError), + ): + raise ExceptionGroup("", [ValueError(), SyntaxError()]) + + +def test_flatten_subgroups() -> None: + # loose semantics, as with expect* + with RaisesGroup(ValueError, flatten_subgroups=True): + raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)) + + with RaisesGroup(ValueError, TypeError, flatten_subgroups=True): + raise ExceptionGroup("", (ExceptionGroup("", (ValueError(), TypeError())),)) + with RaisesGroup(ValueError, TypeError, flatten_subgroups=True): + raise ExceptionGroup("", [ExceptionGroup("", [ValueError()]), TypeError()]) + + # mixed loose is possible if you want it to be at least N deep + with RaisesGroup(RaisesGroup(ValueError, flatten_subgroups=True)): + raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)) + with RaisesGroup(RaisesGroup(ValueError, flatten_subgroups=True)): + raise ExceptionGroup( + "", + (ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)),), + ) + + # but not the other way around + with pytest.raises( + ValueError, + match=r"^You cannot specify a nested structure inside a RaisesGroup with", + ): + RaisesGroup(RaisesGroup(ValueError), flatten_subgroups=True) # type: ignore[call-overload] + + # flatten_subgroups is not sufficient to catch fully unwrapped + with ( + fails_raises_group( + "'ValueError' is not an exception group, but would match with `allow_unwrapped=True`" + ), + RaisesGroup(ValueError, flatten_subgroups=True), + ): + raise ValueError + with ( + fails_raises_group( + "RaisesGroup(ValueError, flatten_subgroups=True): 'ValueError' is not an exception group, but would match with `allow_unwrapped=True`" + ), + RaisesGroup(RaisesGroup(ValueError, flatten_subgroups=True)), + ): + raise ExceptionGroup("", (ValueError(),)) + + # helpful suggestion if flatten_subgroups would make it pass + with ( + fails_raises_group( + "Raised exception group did not match: \n" + "The following expected exceptions did not find a match:\n" + " 'ValueError'\n" + " 'TypeError'\n" + "The following raised exceptions did not find a match\n" + " ExceptionGroup('', [ValueError(), TypeError()]):\n" + " Unexpected nested 'ExceptionGroup', expected 'ValueError'\n" + " Unexpected nested 'ExceptionGroup', expected 'TypeError'\n" + "Did you mean to use `flatten_subgroups=True`?", + add_prefix=False, + ), + RaisesGroup(ValueError, TypeError), + ): + raise ExceptionGroup("", [ExceptionGroup("", [ValueError(), TypeError()])]) + # but doesn't consider check (otherwise we'd break typing guarantees) + with ( + fails_raises_group( + "Raised exception group did not match: \n" + "The following expected exceptions did not find a match:\n" + " 'ValueError'\n" + " 'TypeError'\n" + "The following raised exceptions did not find a match\n" + " ExceptionGroup('', [ValueError(), TypeError()]):\n" + " Unexpected nested 'ExceptionGroup', expected 'ValueError'\n" + " Unexpected nested 'ExceptionGroup', expected 'TypeError'\n" + "Did you mean to use `flatten_subgroups=True`?", + add_prefix=False, + ), + RaisesGroup( + ValueError, + TypeError, + check=lambda eg: len(eg.exceptions) == 1, + ), + ): + raise ExceptionGroup("", [ExceptionGroup("", [ValueError(), TypeError()])]) + # correct number of exceptions, and flatten_subgroups would make it pass + # This now doesn't print a repr of the caught exception at all, but that can be found in the traceback + with ( + fails_raises_group( + "Raised exception group did not match: Unexpected nested 'ExceptionGroup', expected 'ValueError'\n" + " Did you mean to use `flatten_subgroups=True`?", + add_prefix=False, + ), + RaisesGroup(ValueError), + ): + raise ExceptionGroup("", [ExceptionGroup("", [ValueError()])]) + # correct number of exceptions, but flatten_subgroups wouldn't help, so we don't suggest it + with ( + fails_raises_group("Unexpected nested 'ExceptionGroup', expected 'ValueError'"), + RaisesGroup(ValueError), + ): + raise ExceptionGroup("", [ExceptionGroup("", [TypeError()])]) + + # flatten_subgroups can be suggested if nested. This will implicitly ask the user to + # do `RaisesGroup(RaisesGroup(ValueError, flatten_subgroups=True))` which is unlikely + # to be what they actually want - but I don't think it's worth trying to special-case + with ( + fails_raises_group( + "RaisesGroup(ValueError): Unexpected nested 'ExceptionGroup', expected 'ValueError'\n" + " Did you mean to use `flatten_subgroups=True`?", + ), + RaisesGroup(RaisesGroup(ValueError)), + ): + raise ExceptionGroup( + "", + [ExceptionGroup("", [ExceptionGroup("", [ValueError()])])], + ) + + # Don't mention "unexpected nested" if expecting an ExceptionGroup. + # Although it should perhaps be an error to specify `RaisesGroup(ExceptionGroup)` in + # favor of doing `RaisesGroup(RaisesGroup(...))`. + with ( + fails_raises_group("'BaseExceptionGroup' is not of type 'ExceptionGroup'"), + RaisesGroup(ExceptionGroup), + ): + raise BaseExceptionGroup("", [BaseExceptionGroup("", [KeyboardInterrupt()])]) + + +def test_catch_unwrapped_exceptions() -> None: + # Catches lone exceptions with strict=False + # just as except* would + with RaisesGroup(ValueError, allow_unwrapped=True): + raise ValueError + + # expecting multiple unwrapped exceptions is not possible + with pytest.raises( + ValueError, + match=r"^You cannot specify multiple exceptions with", + ): + RaisesGroup(SyntaxError, ValueError, allow_unwrapped=True) # type: ignore[call-overload] + # if users want one of several exception types they need to use a Matcher + # (which the error message suggests) + with RaisesGroup( + Matcher(check=lambda e: isinstance(e, (SyntaxError, ValueError))), + allow_unwrapped=True, + ): + raise ValueError + + # Unwrapped nested `RaisesGroup` is likely a user error, so we raise an error. + with pytest.raises(ValueError, match="has no effect when expecting"): + RaisesGroup(RaisesGroup(ValueError), allow_unwrapped=True) # type: ignore[call-overload] + + # But it *can* be used to check for nesting level +- 1 if they move it to + # the nested RaisesGroup. Users should probably use `Matcher`s instead though. + with RaisesGroup(RaisesGroup(ValueError, allow_unwrapped=True)): + raise ExceptionGroup("", [ExceptionGroup("", [ValueError()])]) + with RaisesGroup(RaisesGroup(ValueError, allow_unwrapped=True)): + raise ExceptionGroup("", [ValueError()]) + + # with allow_unwrapped=False (default) it will not be caught + with ( + fails_raises_group( + "'ValueError' is not an exception group, but would match with `allow_unwrapped=True`" + ), + RaisesGroup(ValueError), + ): + raise ValueError("value error text") + + # allow_unwrapped on its own won't match against nested groups + with ( + fails_raises_group( + "Unexpected nested 'ExceptionGroup', expected 'ValueError'\n" + " Did you mean to use `flatten_subgroups=True`?", + ), + RaisesGroup(ValueError, allow_unwrapped=True), + ): + raise ExceptionGroup("foo", [ExceptionGroup("bar", [ValueError()])]) + + # you need both allow_unwrapped and flatten_subgroups to fully emulate except* + with RaisesGroup(ValueError, allow_unwrapped=True, flatten_subgroups=True): + raise ExceptionGroup("", [ExceptionGroup("", [ValueError()])]) + + # code coverage + with ( + fails_raises_group( + "Raised exception (group) did not match: 'TypeError' is not of type 'ValueError'", + add_prefix=False, + ), + RaisesGroup(ValueError, allow_unwrapped=True), + ): + raise TypeError("this text doesn't show up in the error message") + with ( + fails_raises_group( + "Raised exception (group) did not match: Matcher(ValueError): 'TypeError' is not of type 'ValueError'", + add_prefix=False, + ), + RaisesGroup(Matcher(ValueError), allow_unwrapped=True), + ): + raise TypeError + + # check we don't suggest unwrapping with nested RaisesGroup + with ( + fails_raises_group("'ValueError' is not an exception group"), + RaisesGroup(RaisesGroup(ValueError)), + ): + raise ValueError + + +def test_match() -> None: + # supports match string + with RaisesGroup(ValueError, match="bar"): + raise ExceptionGroup("bar", (ValueError(),)) + + # now also works with ^$ + with RaisesGroup(ValueError, match="^bar$"): + raise ExceptionGroup("bar", (ValueError(),)) + + # it also includes notes + with RaisesGroup(ValueError, match="my note"): + e = ExceptionGroup("bar", (ValueError(),)) + e.add_note("my note") + raise e + + # and technically you can match it all with ^$ + # but you're probably better off using a Matcher at that point + with RaisesGroup(ValueError, match="^bar\nmy note$"): + e = ExceptionGroup("bar", (ValueError(),)) + e.add_note("my note") + raise e + + with ( + fails_raises_group( + "Regex pattern 'foo' did not match 'bar' of 'ExceptionGroup'" + ), + RaisesGroup(ValueError, match="foo"), + ): + raise ExceptionGroup("bar", (ValueError(),)) + + # Suggest a fix for easy pitfall of adding match to the RaisesGroup instead of + # using a Matcher. + # This requires a single expected & raised exception, the expected is a type, + # and `isinstance(raised, expected_type)`. + with ( + fails_raises_group( + "Regex pattern 'foo' did not match 'bar' of 'ExceptionGroup', but matched the expected 'ValueError'. You might want RaisesGroup(Matcher(ValueError, match='foo'))" + ), + RaisesGroup(ValueError, match="foo"), + ): + raise ExceptionGroup("bar", [ValueError("foo")]) + + +def test_check() -> None: + exc = ExceptionGroup("", (ValueError(),)) + + def is_exc(e: ExceptionGroup[ValueError]) -> bool: + return e is exc + + is_exc_repr = repr_callable(is_exc) + with RaisesGroup(ValueError, check=is_exc): + raise exc + + with ( + fails_raises_group( + f"check {is_exc_repr} did not return True on the ExceptionGroup" + ), + RaisesGroup(ValueError, check=is_exc), + ): + raise ExceptionGroup("", (ValueError(),)) + + def is_value_error(e: BaseException) -> bool: + return isinstance(e, ValueError) + + # helpful suggestion if the user thinks the check is for the sub-exception + with ( + fails_raises_group( + f"check {is_value_error} did not return True on the ExceptionGroup, but did return True for the expected 'ValueError'. You might want RaisesGroup(Matcher(ValueError, check=<...>))" + ), + RaisesGroup(ValueError, check=is_value_error), + ): + raise ExceptionGroup("", (ValueError(),)) + + +def test_unwrapped_match_check() -> None: + def my_check(e: object) -> bool: # pragma: no cover + return True + + msg = ( + "`allow_unwrapped=True` bypasses the `match` and `check` parameters" + " if the exception is unwrapped. If you intended to match/check the" + " exception you should use a `Matcher` object. If you want to match/check" + " the exceptiongroup when the exception *is* wrapped you need to" + " do e.g. `if isinstance(exc.value, ExceptionGroup):" + " assert RaisesGroup(...).matches(exc.value)` afterwards." + ) + with pytest.raises(ValueError, match=re.escape(msg)): + RaisesGroup(ValueError, allow_unwrapped=True, match="foo") # type: ignore[call-overload] + with pytest.raises(ValueError, match=re.escape(msg)): + RaisesGroup(ValueError, allow_unwrapped=True, check=my_check) # type: ignore[call-overload] + + # Users should instead use a Matcher + rg = RaisesGroup(Matcher(ValueError, match="^foo$"), allow_unwrapped=True) + with rg: + raise ValueError("foo") + with rg: + raise ExceptionGroup("", [ValueError("foo")]) + + # or if they wanted to match/check the group, do a conditional `.matches()` + with RaisesGroup(ValueError, allow_unwrapped=True) as exc: + raise ExceptionGroup("bar", [ValueError("foo")]) + if isinstance(exc.value, ExceptionGroup): # pragma: no branch + assert RaisesGroup(ValueError, match="bar").matches(exc.value) + + +def test_RaisesGroup_matches() -> None: + rg = RaisesGroup(ValueError) + assert not rg.matches(None) + assert not rg.matches(ValueError()) + assert rg.matches(ExceptionGroup("", (ValueError(),))) + + +def test_message() -> None: + def check_message( + message: str, + body: RaisesGroup[BaseException], + ) -> None: + with ( + pytest.raises( + Failed, + match=f"^DID NOT RAISE any exception, expected {re.escape(message)}$", + ), + body, + ): + ... + + # basic + check_message("ExceptionGroup(ValueError)", RaisesGroup(ValueError)) + # multiple exceptions + check_message( + "ExceptionGroup(ValueError, ValueError)", + RaisesGroup(ValueError, ValueError), + ) + # nested + check_message( + "ExceptionGroup(ExceptionGroup(ValueError))", + RaisesGroup(RaisesGroup(ValueError)), + ) + + # Matcher + check_message( + "ExceptionGroup(Matcher(ValueError, match='my_str'))", + RaisesGroup(Matcher(ValueError, "my_str")), + ) + check_message( + "ExceptionGroup(Matcher(match='my_str'))", + RaisesGroup(Matcher(match="my_str")), + ) + + # BaseExceptionGroup + check_message( + "BaseExceptionGroup(KeyboardInterrupt)", + RaisesGroup(KeyboardInterrupt), + ) + # BaseExceptionGroup with type inside Matcher + check_message( + "BaseExceptionGroup(Matcher(KeyboardInterrupt))", + RaisesGroup(Matcher(KeyboardInterrupt)), + ) + # Base-ness transfers to parent containers + check_message( + "BaseExceptionGroup(BaseExceptionGroup(KeyboardInterrupt))", + RaisesGroup(RaisesGroup(KeyboardInterrupt)), + ) + # but not to child containers + check_message( + "BaseExceptionGroup(BaseExceptionGroup(KeyboardInterrupt), ExceptionGroup(ValueError))", + RaisesGroup(RaisesGroup(KeyboardInterrupt), RaisesGroup(ValueError)), + ) + + +def test_assert_message() -> None: + # the message does not need to list all parameters to RaisesGroup, nor all exceptions + # in the exception group, as those are both visible in the traceback. + # first fails to match + with ( + fails_raises_group("'TypeError' is not of type 'ValueError'"), + RaisesGroup(ValueError), + ): + raise ExceptionGroup("a", [TypeError()]) + with ( + fails_raises_group( + "Raised exception group did not match: \n" + "The following expected exceptions did not find a match:\n" + " RaisesGroup(ValueError)\n" + " RaisesGroup(ValueError, match='a')\n" + "The following raised exceptions did not find a match\n" + " ExceptionGroup('', [RuntimeError()]):\n" + " RaisesGroup(ValueError): 'RuntimeError' is not of type 'ValueError'\n" + " RaisesGroup(ValueError, match='a'): Regex pattern 'a' did not match '' of 'ExceptionGroup'\n" + " RuntimeError():\n" + " RaisesGroup(ValueError): 'RuntimeError' is not an exception group\n" + " RaisesGroup(ValueError, match='a'): 'RuntimeError' is not an exception group", + add_prefix=False, # to see the full structure + ), + RaisesGroup(RaisesGroup(ValueError), RaisesGroup(ValueError, match="a")), + ): + raise ExceptionGroup( + "", + [ExceptionGroup("", [RuntimeError()]), RuntimeError()], + ) + + with ( + fails_raises_group( + "Raised exception group did not match: \n" + "2 matched exceptions. \n" + "The following expected exceptions did not find a match:\n" + " RaisesGroup(RuntimeError)\n" + " RaisesGroup(ValueError)\n" + "The following raised exceptions did not find a match\n" + " RuntimeError():\n" + # " 'RuntimeError' is not of type 'ValueError'\n" + # " Matcher(TypeError): 'RuntimeError' is not of type 'TypeError'\n" + " RaisesGroup(RuntimeError): 'RuntimeError' is not an exception group, but would match with `allow_unwrapped=True`\n" + " RaisesGroup(ValueError): 'RuntimeError' is not an exception group\n" + " ValueError('bar'):\n" + " It matches 'ValueError' which was paired with ValueError('foo')\n" + " RaisesGroup(RuntimeError): 'ValueError' is not an exception group\n" + " RaisesGroup(ValueError): 'ValueError' is not an exception group, but would match with `allow_unwrapped=True`", + add_prefix=False, # to see the full structure + ), + RaisesGroup( + ValueError, + Matcher(TypeError), + RaisesGroup(RuntimeError), + RaisesGroup(ValueError), + ), + ): + raise ExceptionGroup( + "a", + [RuntimeError(), TypeError(), ValueError("foo"), ValueError("bar")], + ) + + with ( + fails_raises_group( + "1 matched exception. 'AssertionError' is not of type 'TypeError'" + ), + RaisesGroup(ValueError, TypeError), + ): + raise ExceptionGroup("a", [ValueError(), AssertionError()]) + + with ( + fails_raises_group( + "Matcher(ValueError): 'TypeError' is not of type 'ValueError'" + ), + RaisesGroup(Matcher(ValueError)), + ): + raise ExceptionGroup("a", [TypeError()]) + + # suggest escaping + with ( + fails_raises_group( + "Raised exception group did not match: Regex pattern 'h(ell)o' did not match 'h(ell)o' of 'ExceptionGroup'\n" + " Did you mean to `re.escape()` the regex?", + add_prefix=False, # to see the full structure + ), + RaisesGroup(ValueError, match="h(ell)o"), + ): + raise ExceptionGroup("h(ell)o", [ValueError()]) + with ( + fails_raises_group( + "Matcher(match='h(ell)o'): Regex pattern 'h(ell)o' did not match 'h(ell)o'\n" + " Did you mean to `re.escape()` the regex?", + ), + RaisesGroup(Matcher(match="h(ell)o")), + ): + raise ExceptionGroup("", [ValueError("h(ell)o")]) + + with ( + fails_raises_group( + "Raised exception group did not match: \n" + "The following expected exceptions did not find a match:\n" + " 'ValueError'\n" + " 'ValueError'\n" + " 'ValueError'\n" + " 'ValueError'\n" + "The following raised exceptions did not find a match\n" + " ExceptionGroup('', [ValueError(), TypeError()]):\n" + " Unexpected nested 'ExceptionGroup', expected 'ValueError'\n" + " Unexpected nested 'ExceptionGroup', expected 'ValueError'\n" + " Unexpected nested 'ExceptionGroup', expected 'ValueError'\n" + " Unexpected nested 'ExceptionGroup', expected 'ValueError'", + add_prefix=False, # to see the full structure + ), + RaisesGroup(ValueError, ValueError, ValueError, ValueError), + ): + raise ExceptionGroup("", [ExceptionGroup("", [ValueError(), TypeError()])]) + + +def test_message_indent() -> None: + with ( + fails_raises_group( + "Raised exception group did not match: \n" + "The following expected exceptions did not find a match:\n" + " RaisesGroup(ValueError, ValueError)\n" + " 'ValueError'\n" + "The following raised exceptions did not find a match\n" + " ExceptionGroup('', [TypeError(), RuntimeError()]):\n" + " RaisesGroup(ValueError, ValueError): \n" + " The following expected exceptions did not find a match:\n" + " 'ValueError'\n" + " 'ValueError'\n" + " The following raised exceptions did not find a match\n" + " TypeError():\n" + " 'TypeError' is not of type 'ValueError'\n" + " 'TypeError' is not of type 'ValueError'\n" + " RuntimeError():\n" + " 'RuntimeError' is not of type 'ValueError'\n" + " 'RuntimeError' is not of type 'ValueError'\n" + # TODO: this line is not great, should maybe follow the same format as the other and say + # 'ValueError': Unexpected nested 'ExceptionGroup' (?) + " Unexpected nested 'ExceptionGroup', expected 'ValueError'\n" + " TypeError():\n" + " RaisesGroup(ValueError, ValueError): 'TypeError' is not an exception group\n" + " 'TypeError' is not of type 'ValueError'", + add_prefix=False, + ), + RaisesGroup( + RaisesGroup(ValueError, ValueError), + ValueError, + ), + ): + raise ExceptionGroup( + "", + [ + ExceptionGroup("", [TypeError(), RuntimeError()]), + TypeError(), + ], + ) + with ( + fails_raises_group( + "Raised exception group did not match: \n" + "RaisesGroup(ValueError, ValueError): \n" + " The following expected exceptions did not find a match:\n" + " 'ValueError'\n" + " 'ValueError'\n" + " The following raised exceptions did not find a match\n" + " TypeError():\n" + " 'TypeError' is not of type 'ValueError'\n" + " 'TypeError' is not of type 'ValueError'\n" + " RuntimeError():\n" + " 'RuntimeError' is not of type 'ValueError'\n" + " 'RuntimeError' is not of type 'ValueError'", + add_prefix=False, + ), + RaisesGroup( + RaisesGroup(ValueError, ValueError), + ), + ): + raise ExceptionGroup( + "", + [ + ExceptionGroup("", [TypeError(), RuntimeError()]), + ], + ) + + +def test_suggestion_on_nested_and_brief_error() -> None: + # Make sure "Did you mean" suggestion gets indented iff it follows a single-line error + with ( + fails_raises_group( + "\n" + "The following expected exceptions did not find a match:\n" + " RaisesGroup(ValueError)\n" + " 'ValueError'\n" + "The following raised exceptions did not find a match\n" + " ExceptionGroup('', [ExceptionGroup('', [ValueError()])]):\n" + " RaisesGroup(ValueError): Unexpected nested 'ExceptionGroup', expected 'ValueError'\n" + " Did you mean to use `flatten_subgroups=True`?\n" + " Unexpected nested 'ExceptionGroup', expected 'ValueError'", + ), + RaisesGroup(RaisesGroup(ValueError), ValueError), + ): + raise ExceptionGroup( + "", + [ExceptionGroup("", [ExceptionGroup("", [ValueError()])])], + ) + # if indented here it would look like another raised exception + with ( + fails_raises_group( + "\n" + "The following expected exceptions did not find a match:\n" + " RaisesGroup(ValueError, ValueError)\n" + " 'ValueError'\n" + "The following raised exceptions did not find a match\n" + " ExceptionGroup('', [ValueError(), ExceptionGroup('', [ValueError()])]):\n" + " RaisesGroup(ValueError, ValueError): \n" + " 1 matched exception. \n" + " The following expected exceptions did not find a match:\n" + " 'ValueError'\n" + " It matches ValueError() which was paired with 'ValueError'\n" + " The following raised exceptions did not find a match\n" + " ExceptionGroup('', [ValueError()]):\n" + " Unexpected nested 'ExceptionGroup', expected 'ValueError'\n" + " Did you mean to use `flatten_subgroups=True`?\n" + " Unexpected nested 'ExceptionGroup', expected 'ValueError'" + ), + RaisesGroup(RaisesGroup(ValueError, ValueError), ValueError), + ): + raise ExceptionGroup( + "", + [ExceptionGroup("", [ValueError(), ExceptionGroup("", [ValueError()])])], + ) + + # re.escape always comes after single-line errors + with ( + fails_raises_group( + "\n" + "The following expected exceptions did not find a match:\n" + " RaisesGroup(Exception, match='^hello')\n" + " 'ValueError'\n" + "The following raised exceptions did not find a match\n" + " ExceptionGroup('^hello', [Exception()]):\n" + " RaisesGroup(Exception, match='^hello'): Regex pattern '^hello' did not match '^hello' of 'ExceptionGroup'\n" + " Did you mean to `re.escape()` the regex?\n" + " Unexpected nested 'ExceptionGroup', expected 'ValueError'" + ), + RaisesGroup(RaisesGroup(Exception, match="^hello"), ValueError), + ): + raise ExceptionGroup("", [ExceptionGroup("^hello", [Exception()])]) + + +def test_assert_message_nested() -> None: + # we only get one instance of aaaaaaaaaa... and bbbbbb..., but we do get multiple instances of ccccc... and dddddd.. + # but I think this now only prints the full repr when that is necessary to disambiguate exceptions + with ( + fails_raises_group( + "Raised exception group did not match: \n" + "The following expected exceptions did not find a match:\n" + " RaisesGroup(ValueError)\n" + " RaisesGroup(RaisesGroup(ValueError))\n" + " RaisesGroup(Matcher(TypeError, match='foo'))\n" + " RaisesGroup(TypeError, ValueError)\n" + "The following raised exceptions did not find a match\n" + " TypeError('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'):\n" + " RaisesGroup(ValueError): 'TypeError' is not an exception group\n" + " RaisesGroup(RaisesGroup(ValueError)): 'TypeError' is not an exception group\n" + " RaisesGroup(Matcher(TypeError, match='foo')): 'TypeError' is not an exception group\n" + " RaisesGroup(TypeError, ValueError): 'TypeError' is not an exception group\n" + " ExceptionGroup('Exceptions from Trio nursery', [TypeError('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb')]):\n" + " RaisesGroup(ValueError): 'TypeError' is not of type 'ValueError'\n" + " RaisesGroup(RaisesGroup(ValueError)): RaisesGroup(ValueError): 'TypeError' is not an exception group\n" + " RaisesGroup(Matcher(TypeError, match='foo')): Matcher(TypeError, match='foo'): Regex pattern 'foo' did not match 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'\n" + " RaisesGroup(TypeError, ValueError): 1 matched exception. Too few exceptions raised, found no match for: ['ValueError']\n" + " ExceptionGroup('Exceptions from Trio nursery', [TypeError('cccccccccccccccccccccccccccccc'), TypeError('dddddddddddddddddddddddddddddd')]):\n" + " RaisesGroup(ValueError): \n" + " The following expected exceptions did not find a match:\n" + " 'ValueError'\n" + " The following raised exceptions did not find a match\n" + " TypeError('cccccccccccccccccccccccccccccc'):\n" + " 'TypeError' is not of type 'ValueError'\n" + " TypeError('dddddddddddddddddddddddddddddd'):\n" + " 'TypeError' is not of type 'ValueError'\n" + " RaisesGroup(RaisesGroup(ValueError)): \n" + " The following expected exceptions did not find a match:\n" + " RaisesGroup(ValueError)\n" + " The following raised exceptions did not find a match\n" + " TypeError('cccccccccccccccccccccccccccccc'):\n" + " RaisesGroup(ValueError): 'TypeError' is not an exception group\n" + " TypeError('dddddddddddddddddddddddddddddd'):\n" + " RaisesGroup(ValueError): 'TypeError' is not an exception group\n" + " RaisesGroup(Matcher(TypeError, match='foo')): \n" + " The following expected exceptions did not find a match:\n" + " Matcher(TypeError, match='foo')\n" + " The following raised exceptions did not find a match\n" + " TypeError('cccccccccccccccccccccccccccccc'):\n" + " Matcher(TypeError, match='foo'): Regex pattern 'foo' did not match 'cccccccccccccccccccccccccccccc'\n" + " TypeError('dddddddddddddddddddddddddddddd'):\n" + " Matcher(TypeError, match='foo'): Regex pattern 'foo' did not match 'dddddddddddddddddddddddddddddd'\n" + " RaisesGroup(TypeError, ValueError): \n" + " 1 matched exception. \n" + " The following expected exceptions did not find a match:\n" + " 'ValueError'\n" + " The following raised exceptions did not find a match\n" + " TypeError('dddddddddddddddddddddddddddddd'):\n" + " It matches 'TypeError' which was paired with TypeError('cccccccccccccccccccccccccccccc')\n" + " 'TypeError' is not of type 'ValueError'", + add_prefix=False, # to see the full structure + ), + RaisesGroup( + RaisesGroup(ValueError), + RaisesGroup(RaisesGroup(ValueError)), + RaisesGroup(Matcher(TypeError, match="foo")), + RaisesGroup(TypeError, ValueError), + ), + ): + raise ExceptionGroup( + "", + [ + TypeError("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + ExceptionGroup( + "Exceptions from Trio nursery", + [TypeError("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")], + ), + ExceptionGroup( + "Exceptions from Trio nursery", + [ + TypeError("cccccccccccccccccccccccccccccc"), + TypeError("dddddddddddddddddddddddddddddd"), + ], + ), + ], + ) + + +@pytest.mark.skipif( + "hypothesis" in sys.modules, + reason="hypothesis may have monkeypatched _check_repr", +) +def test_check_no_patched_repr() -> None: + # We make `_check_repr` monkeypatchable to avoid this very ugly and verbose + # repr. The other tests that use `check` make use of `_check_repr` so they'll + # continue passing in case it is patched - but we have this one test that + # demonstrates just how nasty it gets otherwise. + match_str = ( + r"^Raised exception group did not match: \n" + r"The following expected exceptions did not find a match:\n" + r" Matcher\(check=. at .*>\)\n" + r" 'TypeError'\n" + r"The following raised exceptions did not find a match\n" + r" ValueError\('foo'\):\n" + r" Matcher\(check=. at .*>\): check did not return True\n" + r" 'ValueError' is not of type 'TypeError'\n" + r" ValueError\('bar'\):\n" + r" Matcher\(check=. at .*>\): check did not return True\n" + r" 'ValueError' is not of type 'TypeError'$" + ) + with ( + pytest.raises(Failed, match=match_str), + RaisesGroup(Matcher(check=lambda x: False), TypeError), + ): + raise ExceptionGroup("", [ValueError("foo"), ValueError("bar")]) + + +def test_misordering_example() -> None: + with ( + fails_raises_group( + "\n" + "3 matched exceptions. \n" + "The following expected exceptions did not find a match:\n" + " Matcher(ValueError, match='foo')\n" + " It matches ValueError('foo') which was paired with 'ValueError'\n" + " It matches ValueError('foo') which was paired with 'ValueError'\n" + " It matches ValueError('foo') which was paired with 'ValueError'\n" + "The following raised exceptions did not find a match\n" + " ValueError('bar'):\n" + " It matches 'ValueError' which was paired with ValueError('foo')\n" + " It matches 'ValueError' which was paired with ValueError('foo')\n" + " It matches 'ValueError' which was paired with ValueError('foo')\n" + " Matcher(ValueError, match='foo'): Regex pattern 'foo' did not match 'bar'\n" + "There exist a possible match when attempting an exhaustive check, but RaisesGroup uses a greedy algorithm. Please make your expected exceptions more stringent with `Matcher` etc so the greedy algorithm can function." + ), + RaisesGroup( + ValueError, ValueError, ValueError, Matcher(ValueError, match="foo") + ), + ): + raise ExceptionGroup( + "", + [ + ValueError("foo"), + ValueError("foo"), + ValueError("foo"), + ValueError("bar"), + ], + ) + + +def test_brief_error_on_one_fail() -> None: + """If only one raised and one expected fail to match up, we print a full table iff + the raised exception would match one of the expected that previously got matched""" + # no also-matched + with ( + fails_raises_group( + "1 matched exception. 'TypeError' is not of type 'RuntimeError'" + ), + RaisesGroup(ValueError, RuntimeError), + ): + raise ExceptionGroup("", [ValueError(), TypeError()]) + + # raised would match an expected + with ( + fails_raises_group( + "\n" + "1 matched exception. \n" + "The following expected exceptions did not find a match:\n" + " 'RuntimeError'\n" + "The following raised exceptions did not find a match\n" + " TypeError():\n" + " It matches 'Exception' which was paired with ValueError()\n" + " 'TypeError' is not of type 'RuntimeError'" + ), + RaisesGroup(Exception, RuntimeError), + ): + raise ExceptionGroup("", [ValueError(), TypeError()]) + + # expected would match a raised + with ( + fails_raises_group( + "\n" + "1 matched exception. \n" + "The following expected exceptions did not find a match:\n" + " 'ValueError'\n" + " It matches ValueError() which was paired with 'ValueError'\n" + "The following raised exceptions did not find a match\n" + " TypeError():\n" + " 'TypeError' is not of type 'ValueError'" + ), + RaisesGroup(ValueError, ValueError), + ): + raise ExceptionGroup("", [ValueError(), TypeError()]) + + +def test_identity_oopsies() -> None: + # it's both possible to have several instances of the same exception in the same group + # and to expect multiple of the same type + # this previously messed up the logic + + with ( + fails_raises_group( + "3 matched exceptions. 'RuntimeError' is not of type 'TypeError'" + ), + RaisesGroup(ValueError, ValueError, ValueError, TypeError), + ): + raise ExceptionGroup( + "", [ValueError(), ValueError(), ValueError(), RuntimeError()] + ) + + e = ValueError("foo") + m = Matcher(match="bar") + with ( + fails_raises_group( + "\n" + "The following expected exceptions did not find a match:\n" + " Matcher(match='bar')\n" + " Matcher(match='bar')\n" + " Matcher(match='bar')\n" + "The following raised exceptions did not find a match\n" + " ValueError('foo'):\n" + " Matcher(match='bar'): Regex pattern 'bar' did not match 'foo'\n" + " Matcher(match='bar'): Regex pattern 'bar' did not match 'foo'\n" + " Matcher(match='bar'): Regex pattern 'bar' did not match 'foo'\n" + " ValueError('foo'):\n" + " Matcher(match='bar'): Regex pattern 'bar' did not match 'foo'\n" + " Matcher(match='bar'): Regex pattern 'bar' did not match 'foo'\n" + " Matcher(match='bar'): Regex pattern 'bar' did not match 'foo'\n" + " ValueError('foo'):\n" + " Matcher(match='bar'): Regex pattern 'bar' did not match 'foo'\n" + " Matcher(match='bar'): Regex pattern 'bar' did not match 'foo'\n" + " Matcher(match='bar'): Regex pattern 'bar' did not match 'foo'" + ), + RaisesGroup(m, m, m), + ): + raise ExceptionGroup("", [e, e, e]) + + +def test_matcher() -> None: + with pytest.raises( + ValueError, + match=r"^You must specify at least one parameter to match on.$", + ): + Matcher() # type: ignore[call-overload] + with pytest.raises( + TypeError, + match=f"^exception_type {re.escape(repr(object))} must be a subclass of BaseException$", + ): + Matcher(object) # type: ignore[type-var] + + with RaisesGroup(Matcher(ValueError)): + raise ExceptionGroup("", (ValueError(),)) + with ( + fails_raises_group( + "Matcher(TypeError): 'ValueError' is not of type 'TypeError'" + ), + RaisesGroup(Matcher(TypeError)), + ): + raise ExceptionGroup("", (ValueError(),)) + + +def test_matcher_match() -> None: + with RaisesGroup(Matcher(ValueError, "foo")): + raise ExceptionGroup("", (ValueError("foo"),)) + with ( + fails_raises_group( + "Matcher(ValueError, match='foo'): Regex pattern 'foo' did not match 'bar'" + ), + RaisesGroup(Matcher(ValueError, "foo")), + ): + raise ExceptionGroup("", (ValueError("bar"),)) + + # Can be used without specifying the type + with RaisesGroup(Matcher(match="foo")): + raise ExceptionGroup("", (ValueError("foo"),)) + with ( + fails_raises_group( + "Matcher(match='foo'): Regex pattern 'foo' did not match 'bar'" + ), + RaisesGroup(Matcher(match="foo")), + ): + raise ExceptionGroup("", (ValueError("bar"),)) + + # check ^$ + with RaisesGroup(Matcher(ValueError, match="^bar$")): + raise ExceptionGroup("", [ValueError("bar")]) + with ( + fails_raises_group( + "Matcher(ValueError, match='^bar$'): Regex pattern '^bar$' did not match 'barr'" + ), + RaisesGroup(Matcher(ValueError, match="^bar$")), + ): + raise ExceptionGroup("", [ValueError("barr")]) + + +def test_Matcher_check() -> None: + def check_oserror_and_errno_is_5(e: BaseException) -> bool: + return isinstance(e, OSError) and e.errno == 5 + + with RaisesGroup(Matcher(check=check_oserror_and_errno_is_5)): + raise ExceptionGroup("", (OSError(5, ""),)) + + # specifying exception_type narrows the parameter type to the callable + def check_errno_is_5(e: OSError) -> bool: + return e.errno == 5 + + with RaisesGroup(Matcher(OSError, check=check_errno_is_5)): + raise ExceptionGroup("", (OSError(5, ""),)) + + # avoid printing overly verbose repr multiple times + with ( + fails_raises_group( + f"Matcher(OSError, check={check_errno_is_5!r}): check did not return True" + ), + RaisesGroup(Matcher(OSError, check=check_errno_is_5)), + ): + raise ExceptionGroup("", (OSError(6, ""),)) + + # in nested cases you still get it multiple times though + # to address this you'd need logic in Matcher.__repr__ and RaisesGroup.__repr__ + with ( + fails_raises_group( + f"RaisesGroup(Matcher(OSError, check={check_errno_is_5!r})): Matcher(OSError, check={check_errno_is_5!r}): check did not return True" + ), + RaisesGroup(RaisesGroup(Matcher(OSError, check=check_errno_is_5))), + ): + raise ExceptionGroup("", [ExceptionGroup("", [OSError(6, "")])]) + + +def test_matcher_tostring() -> None: + assert str(Matcher(ValueError)) == "Matcher(ValueError)" + assert str(Matcher(match="[a-z]")) == "Matcher(match='[a-z]')" + pattern_no_flags = re.compile(r"noflag", 0) + assert str(Matcher(match=pattern_no_flags)) == "Matcher(match='noflag')" + pattern_flags = re.compile(r"noflag", re.IGNORECASE) + assert str(Matcher(match=pattern_flags)) == f"Matcher(match={pattern_flags!r})" + assert ( + str(Matcher(ValueError, match="re", check=bool)) + == f"Matcher(ValueError, match='re', check={bool!r})" + ) + + +def test_raisesgroup_tostring() -> None: + def check_str_and_repr(s: str) -> None: + evaled = eval(s) + assert s == str(evaled) == repr(evaled) + + check_str_and_repr("RaisesGroup(ValueError)") + check_str_and_repr("RaisesGroup(RaisesGroup(ValueError))") + check_str_and_repr("RaisesGroup(Matcher(ValueError))") + check_str_and_repr("RaisesGroup(ValueError, allow_unwrapped=True)") + check_str_and_repr("RaisesGroup(ValueError, match='aoeu')") + + assert ( + str(RaisesGroup(ValueError, match="[a-z]", check=bool)) + == f"RaisesGroup(ValueError, match='[a-z]', check={bool!r})" + ) + + +def test_assert_matches() -> None: + e = ValueError() + + # it's easy to do this + assert Matcher(ValueError).matches(e) + + # but you don't get a helpful error + with pytest.raises(AssertionError, match=r"assert False\n \+ where False = .*"): + assert Matcher(TypeError).matches(e) + + # you'd need to do this arcane incantation + with pytest.raises(AssertionError, match="'ValueError' is not of type 'TypeError'"): + assert (m := Matcher(TypeError)).matches(e), m.fail_reason + + # but even if we add assert_matches, will people remember to use it? + # other than writing a linter rule, I don't think we can catch `assert Matcher(...).matches` diff --git a/testing/typing_raises_group.py b/testing/typing_raises_group.py new file mode 100644 index 00000000000..2dc35031dac --- /dev/null +++ b/testing/typing_raises_group.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +import sys +from typing import Callable +from typing import Union + +from typing_extensions import assert_type + +from _pytest._raises_group import Matcher +from _pytest._raises_group import RaisesGroup + + +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + from exceptiongroup import ExceptionGroup + +# split into functions to isolate the different scopes + + +def check_matcher_typevar_default(e: Matcher) -> None: + assert e.exception_type is not None + _exc: type[BaseException] = e.exception_type + # this would previously pass, as the type would be `Any` + e.exception_type().blah() # type: ignore + + +def check_basic_contextmanager() -> None: + with RaisesGroup(ValueError) as e: + raise ExceptionGroup("foo", (ValueError(),)) + assert_type(e.value, ExceptionGroup[ValueError]) + + +def check_basic_matches() -> None: + # check that matches gets rid of the naked ValueError in the union + exc: ExceptionGroup[ValueError] | ValueError = ExceptionGroup("", (ValueError(),)) + if RaisesGroup(ValueError).matches(exc): + assert_type(exc, ExceptionGroup[ValueError]) + + # also check that BaseExceptionGroup shows up for BaseExceptions + if RaisesGroup(KeyboardInterrupt).matches(exc): + assert_type(exc, BaseExceptionGroup[KeyboardInterrupt]) + + +def check_matches_with_different_exception_type() -> None: + e: BaseExceptionGroup[KeyboardInterrupt] = BaseExceptionGroup( + "", + (KeyboardInterrupt(),), + ) + + # note: it might be tempting to have this warn. + # however, that isn't possible with current typing + if RaisesGroup(ValueError).matches(e): + assert_type(e, ExceptionGroup[ValueError]) + + +def check_matcher_init() -> None: + def check_exc(exc: BaseException) -> bool: + return isinstance(exc, ValueError) + + # Check various combinations of constructor signatures. + # At least 1 arg must be provided. + Matcher() # type: ignore + Matcher(ValueError) + Matcher(ValueError, "regex") + Matcher(ValueError, "regex", check_exc) + Matcher(exception_type=ValueError) + Matcher(match="regex") + Matcher(check=check_exc) + Matcher(ValueError, match="regex") + Matcher(match="regex", check=check_exc) + + def check_filenotfound(exc: FileNotFoundError) -> bool: + return not exc.filename.endswith(".tmp") + + # If exception_type is provided, that narrows the `check` method's argument. + Matcher(FileNotFoundError, check=check_filenotfound) + Matcher(ValueError, check=check_filenotfound) # type: ignore + Matcher(check=check_filenotfound) # type: ignore + Matcher(FileNotFoundError, match="regex", check=check_filenotfound) + + +def raisesgroup_check_type_narrowing() -> None: + """Check type narrowing on the `check` argument to `RaisesGroup`. + All `type: ignore`s are correctly pointing out type errors. + """ + + def handle_exc(e: BaseExceptionGroup[BaseException]) -> bool: + return True + + def handle_kbi(e: BaseExceptionGroup[KeyboardInterrupt]) -> bool: + return True + + def handle_value(e: BaseExceptionGroup[ValueError]) -> bool: + return True + + RaisesGroup(BaseException, check=handle_exc) + RaisesGroup(BaseException, check=handle_kbi) # type: ignore + + RaisesGroup(Exception, check=handle_exc) + RaisesGroup(Exception, check=handle_value) # type: ignore + + RaisesGroup(KeyboardInterrupt, check=handle_exc) + RaisesGroup(KeyboardInterrupt, check=handle_kbi) + RaisesGroup(KeyboardInterrupt, check=handle_value) # type: ignore + + RaisesGroup(ValueError, check=handle_exc) + RaisesGroup(ValueError, check=handle_kbi) # type: ignore + RaisesGroup(ValueError, check=handle_value) + + RaisesGroup(ValueError, KeyboardInterrupt, check=handle_exc) + RaisesGroup(ValueError, KeyboardInterrupt, check=handle_kbi) # type: ignore + RaisesGroup(ValueError, KeyboardInterrupt, check=handle_value) # type: ignore + + +def raisesgroup_narrow_baseexceptiongroup() -> None: + """Check type narrowing specifically for the container exceptiongroup.""" + + def handle_group(e: ExceptionGroup[Exception]) -> bool: + return True + + def handle_group_value(e: ExceptionGroup[ValueError]) -> bool: + return True + + RaisesGroup(ValueError, check=handle_group_value) + + RaisesGroup(Exception, check=handle_group) + + +def check_matcher_transparent() -> None: + with RaisesGroup(Matcher(ValueError)) as e: + ... + _: BaseExceptionGroup[ValueError] = e.value + assert_type(e.value, ExceptionGroup[ValueError]) + + +def check_nested_raisesgroups_contextmanager() -> None: + with RaisesGroup(RaisesGroup(ValueError)) as excinfo: + raise ExceptionGroup("foo", (ValueError(),)) + + _: BaseExceptionGroup[BaseExceptionGroup[ValueError]] = excinfo.value + + assert_type( + excinfo.value, + ExceptionGroup[ExceptionGroup[ValueError]], + ) + + assert_type( + excinfo.value.exceptions[0], + # this union is because of how typeshed defines .exceptions + Union[ + ExceptionGroup[ValueError], + ExceptionGroup[ExceptionGroup[ValueError]], + ], + ) + + +def check_nested_raisesgroups_matches() -> None: + """Check nested RaisesGroups with .matches""" + exc: ExceptionGroup[ExceptionGroup[ValueError]] = ExceptionGroup( + "", + (ExceptionGroup("", (ValueError(),)),), + ) + + if RaisesGroup(RaisesGroup(ValueError)).matches(exc): + assert_type(exc, ExceptionGroup[ExceptionGroup[ValueError]]) + + +def check_multiple_exceptions_1() -> None: + a = RaisesGroup(ValueError, ValueError) + b = RaisesGroup(Matcher(ValueError), Matcher(ValueError)) + c = RaisesGroup(ValueError, Matcher(ValueError)) + + d: RaisesGroup[ValueError] + d = a + d = b + d = c + assert d + + +def check_multiple_exceptions_2() -> None: + # This previously failed due to lack of covariance in the TypeVar + a = RaisesGroup(Matcher(ValueError), Matcher(TypeError)) + b = RaisesGroup(Matcher(ValueError), TypeError) + c = RaisesGroup(ValueError, TypeError) + + d: RaisesGroup[Exception] + d = a + d = b + d = c + assert d + + +def check_raisesgroup_overloads() -> None: + # allow_unwrapped=True does not allow: + # multiple exceptions + RaisesGroup(ValueError, TypeError, allow_unwrapped=True) # type: ignore + # nested RaisesGroup + RaisesGroup(RaisesGroup(ValueError), allow_unwrapped=True) # type: ignore + # specifying match + RaisesGroup(ValueError, match="foo", allow_unwrapped=True) # type: ignore + # specifying check + RaisesGroup(ValueError, check=bool, allow_unwrapped=True) # type: ignore + # allowed variants + RaisesGroup(ValueError, allow_unwrapped=True) + RaisesGroup(ValueError, allow_unwrapped=True, flatten_subgroups=True) + RaisesGroup(Matcher(ValueError), allow_unwrapped=True) + + # flatten_subgroups=True does not allow nested RaisesGroup + RaisesGroup(RaisesGroup(ValueError), flatten_subgroups=True) # type: ignore + # but rest is plenty fine + RaisesGroup(ValueError, TypeError, flatten_subgroups=True) + RaisesGroup(ValueError, match="foo", flatten_subgroups=True) + RaisesGroup(ValueError, check=bool, flatten_subgroups=True) + RaisesGroup(ValueError, flatten_subgroups=True) + RaisesGroup(Matcher(ValueError), flatten_subgroups=True) + + # if they're both false we can of course specify nested raisesgroup + RaisesGroup(RaisesGroup(ValueError)) + + +def check_triple_nested_raisesgroup() -> None: + with RaisesGroup(RaisesGroup(RaisesGroup(ValueError))) as e: + assert_type(e.value, ExceptionGroup[ExceptionGroup[ExceptionGroup[ValueError]]]) + + +def check_check_typing() -> None: + # `BaseExceptiongroup` should perhaps be `ExceptionGroup`, but close enough + assert_type( + RaisesGroup(ValueError).check, + Union[ + Callable[[BaseExceptionGroup[ValueError]], bool], + None, + ], + ) From 4737c8cf95eb6b65d936d51479d29dbbfa009575 Mon Sep 17 00:00:00 2001 From: jakkdl Date: Tue, 4 Feb 2025 15:33:06 +0100 Subject: [PATCH 2/5] add AbstractMatcher support to xfail --- src/_pytest/mark/structures.py | 6 +++++- src/_pytest/skipping.py | 23 +++++++++++++++++++---- testing/python/raises_group.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 1a0b3c5b5b8..b5f54d559e5 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -23,6 +23,7 @@ from .._code import getfslineno from ..compat import NOTSET from ..compat import NotSetType +from _pytest._raises_group import AbstractMatcher from _pytest.config import Config from _pytest.deprecated import check_ispytest from _pytest.deprecated import MARKED_FIXTURE @@ -459,7 +460,10 @@ def __call__( *conditions: str | bool, reason: str = ..., run: bool = ..., - raises: None | type[BaseException] | tuple[type[BaseException], ...] = ..., + raises: None + | type[BaseException] + | tuple[type[BaseException], ...] + | AbstractMatcher[BaseException] = ..., strict: bool = ..., ) -> MarkDecorator: ... diff --git a/src/_pytest/skipping.py b/src/_pytest/skipping.py index d21be181955..0736f48aa45 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -12,6 +12,7 @@ import traceback from typing import Optional +from _pytest._raises_group import AbstractMatcher from _pytest.config import Config from _pytest.config import hookimpl from _pytest.config.argparsing import Parser @@ -201,7 +202,12 @@ class Xfail: reason: str run: bool strict: bool - raises: tuple[type[BaseException], ...] | None + raises: ( + type[BaseException] + | tuple[type[BaseException], ...] + | AbstractMatcher[BaseException] + | None + ) def evaluate_xfail_marks(item: Item) -> Xfail | None: @@ -277,11 +283,20 @@ def pytest_runtest_makereport( elif not rep.skipped and xfailed: if call.excinfo: raises = xfailed.raises - if raises is not None and not isinstance(call.excinfo.value, raises): - rep.outcome = "failed" - else: + if raises is None or ( + ( + isinstance(raises, (type, tuple)) + and isinstance(call.excinfo.value, raises) + ) + or ( + isinstance(raises, AbstractMatcher) + and raises.matches(call.excinfo.value) + ) + ): rep.outcome = "skipped" rep.wasxfail = xfailed.reason + else: + rep.outcome = "failed" elif call.when == "call": if xfailed.strict: rep.outcome = "failed" diff --git a/testing/python/raises_group.py b/testing/python/raises_group.py index c10398d0b9e..73715ee1c21 100644 --- a/testing/python/raises_group.py +++ b/testing/python/raises_group.py @@ -11,6 +11,7 @@ from _pytest._raises_group import RaisesGroup from _pytest._raises_group import repr_callable from _pytest.outcomes import Failed +from _pytest.pytester import Pytester import pytest @@ -1135,3 +1136,30 @@ def test_assert_matches() -> None: # but even if we add assert_matches, will people remember to use it? # other than writing a linter rule, I don't think we can catch `assert Matcher(...).matches` + + +# https://github.com/pytest-dev/pytest/issues/12504 +def test_xfail_raisesgroup(pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + @pytest.mark.xfail(raises=pytest.RaisesGroup(ValueError)) + def test_foo() -> None: + raise ExceptionGroup("foo", [ValueError()]) + """ + ) + result = pytester.runpytest() + result.assert_outcomes(xfailed=1) + + +def test_xfail_Matcher(pytester: Pytester) -> None: + pytester.makepyfile( + """ + import pytest + @pytest.mark.xfail(raises=pytest.Matcher(ValueError)) + def test_foo() -> None: + raise ValueError + """ + ) + result = pytester.runpytest() + result.assert_outcomes(xfailed=1) From e1e1874cda7b8ee51172092158ab774960fa8d6c Mon Sep 17 00:00:00 2001 From: jakkdl <11260241+jakkdl@users.noreply.github.com> Date: Thu, 6 Feb 2025 15:39:25 +0100 Subject: [PATCH 3/5] rename AbstractMatcher -> AbstractRaises, Matcher->RaisesExc. Add docs on RaisesGroup&RaisesExc. Add warnings to group_contains. Remove group_contains example from getting-started page --- changelog/11538.feature.rst | 1 + changelog/11671.feature.rst | 1 - changelog/12504.feature.rst | 1 + doc/en/conf.py | 2 + doc/en/getting-started.rst | 26 +---- doc/en/how-to/assert.rst | 89 +++++++++++++- doc/en/reference/reference.rst | 12 ++ src/_pytest/_code/code.py | 7 ++ src/_pytest/_raises_group.py | 126 ++++++++++---------- src/_pytest/mark/structures.py | 4 +- src/_pytest/python_api.py | 5 + src/_pytest/skipping.py | 6 +- src/pytest/__init__.py | 4 +- testing/python/raises_group.py | 208 ++++++++++++++++++--------------- testing/typing_raises_group.py | 48 ++++---- 15 files changed, 325 insertions(+), 215 deletions(-) create mode 100644 changelog/11538.feature.rst delete mode 100644 changelog/11671.feature.rst create mode 100644 changelog/12504.feature.rst diff --git a/changelog/11538.feature.rst b/changelog/11538.feature.rst new file mode 100644 index 00000000000..60f191d05cb --- /dev/null +++ b/changelog/11538.feature.rst @@ -0,0 +1 @@ +Added :class:`pytest.RaisesGroup` (also export as ``pytest.raises_group``) and :class:`pytest.RaisesExc`, as an equivalent to :func:`pytest.raises` for expecting :exc:`ExceptionGroup`. It includes the ability to specify multiple different expected exceptions, the structure of nested exception groups, and flags for emulating :ref:`except* `. See :ref:`assert-matching-exception-groups` and docstrings for more information. diff --git a/changelog/11671.feature.rst b/changelog/11671.feature.rst deleted file mode 100644 index 9e401112ad0..00000000000 --- a/changelog/11671.feature.rst +++ /dev/null @@ -1 +0,0 @@ -Added `RaisesGroup` (also available as `raises_group`) and `Matcher`, as an equivalent to `raises` for expecting `ExceptionGroup`. It includes the ability to specity multiple different expected exceptions, the structure of nested exception groups, and/or closely emulating `except_star`. diff --git a/changelog/12504.feature.rst b/changelog/12504.feature.rst new file mode 100644 index 00000000000..d72b97958c2 --- /dev/null +++ b/changelog/12504.feature.rst @@ -0,0 +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. diff --git a/doc/en/conf.py b/doc/en/conf.py index 47fc70dce85..c89e14d07fa 100644 --- a/doc/en/conf.py +++ b/doc/en/conf.py @@ -106,6 +106,8 @@ ("py:obj", "_pytest.fixtures.FixtureValue"), ("py:obj", "_pytest.stash.T"), ("py:class", "_ScopeName"), + ("py:class", "BaseExcT_1"), + ("py:class", "ExcT_1"), ] add_module_names = False diff --git a/doc/en/getting-started.rst b/doc/en/getting-started.rst index faf81154c48..73ce82f6b7c 100644 --- a/doc/en/getting-started.rst +++ b/doc/en/getting-started.rst @@ -97,30 +97,6 @@ Use the :ref:`raises ` helper to assert that some code raises an e with pytest.raises(SystemExit): f() -You can also use the context provided by :ref:`raises ` to -assert that an expected exception is part of a raised :class:`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: .. code-block:: pytest @@ -133,6 +109,8 @@ Execute the test function with “quiet” reporting mode: The ``-q/--quiet`` flag keeps the output brief in this and following examples. +See :ref:`assertraises` for specifying more details about the expected exception. + Group multiple tests in a class -------------------------------------------------------------- diff --git a/doc/en/how-to/assert.rst b/doc/en/how-to/assert.rst index 7b027744695..08e030b8cab 100644 --- a/doc/en/how-to/assert.rst +++ b/doc/en/how-to/assert.rst @@ -145,8 +145,93 @@ Notes: .. _`assert-matching-exception-groups`: -Matching exception groups -~~~~~~~~~~~~~~~~~~~~~~~~~ +Assertions about expected exception groups +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When expecting a :exc:`BaseExceptionGroup` or :exc:`ExceptionGroup` you can use :class:`pytest.RaisesGroup`, also available as :class:`pytest.raises_group `: + +.. code-block:: python + + def test_exception_in_group(): + with pytest.raises_group(ValueError): + raise ExceptionGroup("group msg", [ValueError("value msg")]) + with pytest.raises_group(ValueError, TypeError): + raise ExceptionGroup("msg", [ValueError("foo"), TypeError("bar")]) + + +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``. + +.. code-block:: python + + def test_raisesgroup_match_and_check(): + with pytest.raises_group(BaseException, match="my group msg"): + raise BaseExceptionGroup("my group msg", [KeyboardInterrupt()]) + with pytest.raises_group( + Exception, check=lambda eg: isinstance(eg.__cause__, ValueError) + ): + raise ExceptionGroup("", [TypeError()]) from ValueError() + +It is strict about structure and unwrapped exceptions, unlike :ref:`except* `, so you might want to set the ``flatten_subgroups`` and/or ``allow_unwrapped`` parameters. + +.. code-block:: python + + def test_structure(): + with pytest.raises_group(pytest.raises_group(ValueError)): + raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)) + with pytest.raises_group(ValueError, flatten_subgroups=True): + raise ExceptionGroup("1st group", [ExceptionGroup("2nd group", [ValueError()])]) + with pytest.raises_group(ValueError, allow_unwrapped=True): + raise ValueError + +To specify more details about the contained exception you can use :class:`pytest.RaisesExc` + +.. code-block:: python + + def test_raises_exc(): + with pytest.raises_group(pytest.RaisesExc(ValueError, match="foo")): + raise ExceptionGroup("", (ValueError("foo"))) + +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__``. + +.. code-block:: python + + def test_matches(): + exc = ValueError() + exc_group = ExceptionGroup("", [exc]) + if RaisesGroup(ValueError).matches(exc_group): + ... + # helpful error is available in `.fail_reason` if it fails to match + r = RaisesExc(ValueError) + assert r.matches(e), r.fail_reason + +Check the documentation on :class:`pytest.RaisesGroup` and :class:`pytest.RaisesExc` for more details and examples. + +``ExceptionInfo.group_contains()`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. warning:: + + 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: + + .. code-block:: python + + class EXTREMELYBADERROR(BaseException): + """This is a very bad error to miss""" + + + def test_for_value_error(): + with pytest.raises(ExceptionGroup) as excinfo: + excs = [ValueError()] + if very_unlucky(): + excs.append(EXTREMELYBADERROR()) + raise ExceptionGroup("", excs) + # this passes regardless of if there's other exceptions + assert excinfo.group_contains(ValueError) + # you can't simply list all exceptions you *don't* want to get here + + + There is no good way of using :func:`excinfo.group_contains() ` to ensure you're not getting *any* other exceptions than the one you expected. + You should instead use :class:`pytest.raises_group `, see :ref:`assert-matching-exception-groups`. You can also use the :func:`excinfo.group_contains() ` method to test for exceptions returned as part of an :class:`ExceptionGroup`: diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index 809e97b4747..d7cf09100b7 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -1014,6 +1014,18 @@ PytestPluginManager :inherited-members: :show-inheritance: +RaisesExc +~~~~~~~~~ + +.. autoclass:: pytest.RaisesExc() + :members: + +RaisesGroup +~~~~~~~~~~~ + +.. autoclass:: pytest.RaisesGroup() + :members: + TerminalReporter ~~~~~~~~~~~~~~~~ diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index f812f0633c8..5b85d295d36 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -828,6 +828,13 @@ def group_contains( the exceptions contained within the topmost exception group). .. versionadded:: 8.0 + + .. warning:: + 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*. + You should instead consider using :class:`pytest.raises_group ` + """ msg = "Captured exception is not an instance of `BaseExceptionGroup`" assert isinstance(self.value, BaseExceptionGroup), msg diff --git a/src/_pytest/_raises_group.py b/src/_pytest/_raises_group.py index 68303c4a3fe..db4e51fc211 100644 --- a/src/_pytest/_raises_group.py +++ b/src/_pytest/_raises_group.py @@ -28,8 +28,8 @@ from typing_extensions import TypeVar # this conditional definition is because we want to allow a TypeVar default - MatchE = TypeVar( - "MatchE", + BaseExcT_co_default = TypeVar( + "BaseExcT_co_default", bound=BaseException, default=BaseException, covariant=True, @@ -37,7 +37,9 @@ else: from typing import TypeVar - MatchE = TypeVar("MatchE", bound=BaseException, covariant=True) + BaseExcT_co_default = TypeVar( + "BaseExcT_co_default", bound=BaseException, covariant=True + ) # RaisesGroup doesn't work with a default. BaseExcT_co = TypeVar("BaseExcT_co", bound=BaseException, covariant=True) @@ -104,8 +106,8 @@ def _check_raw_type( return None -class AbstractMatcher(ABC, Generic[BaseExcT_co]): - """ABC with common functionality shared between Matcher and RaisesGroup""" +class AbstractRaises(ABC, Generic[BaseExcT_co]): + """ABC with common functionality shared between RaisesExc and RaisesGroup""" def __init__( self, @@ -130,7 +132,7 @@ def fail_reason(self) -> str | None: return self._fail_reason def _check_check( - self: AbstractMatcher[BaseExcT_1], + self: AbstractRaises[BaseExcT_1], exception: BaseExcT_1, ) -> bool: if self.check is None: @@ -165,29 +167,29 @@ def _check_match(self, e: BaseException) -> bool: @abstractmethod def matches( - self: AbstractMatcher[BaseExcT_1], exc_val: BaseException + self: AbstractRaises[BaseExcT_1], exc_val: BaseException ) -> TypeGuard[BaseExcT_1]: - """Check if an exception matches the requirements of this AbstractMatcher. - If it fails, `AbstractMatcher.fail_reason` should be set. + """Check if an exception matches the requirements of this AbstractRaises. + If it fails, `AbstractRaises.fail_reason` should be set. """ @final -class Matcher(AbstractMatcher[MatchE]): +class RaisesExc(AbstractRaises[BaseExcT_co_default]): """Helper class to be used together with RaisesGroups when you want to specify requirements on sub-exceptions. Only specifying the type is redundant, and it's also unnecessary when the type is a nested `RaisesGroup` since it supports the same arguments. The type is checked with `isinstance`, and does not need to be an exact match. If that is wanted you can use the ``check`` parameter. - :meth:`Matcher.matches` can also be used standalone to check individual exceptions. + :meth:`RaisesExc.matches` can also be used standalone to check individual exceptions. Examples:: - with RaisesGroups(Matcher(ValueError, match="string")) + with RaisesGroups(RaisesExc(ValueError, match="string")) ... - with RaisesGroups(Matcher(check=lambda x: x.args == (3, "hello"))): + with RaisesGroups(RaisesExc(check=lambda x: x.args == (3, "hello"))): ... - with RaisesGroups(Matcher(check=lambda x: type(x) is ValueError)): + with RaisesGroups(RaisesExc(check=lambda x: type(x) is ValueError)): ... Tip: if you install ``hypothesis`` and import it in ``conftest.py`` you will get @@ -202,15 +204,15 @@ class Matcher(AbstractMatcher[MatchE]): # At least one of the three parameters must be passed. @overload def __init__( - self: Matcher[MatchE], - exception_type: type[MatchE], + self: RaisesExc[BaseExcT_co_default], + exception_type: type[BaseExcT_co_default], match: str | Pattern[str] = ..., - check: Callable[[MatchE], bool] = ..., + check: Callable[[BaseExcT_co_default], bool] = ..., ) -> None: ... @overload def __init__( - self: Matcher[BaseException], # Give E a value. + self: RaisesExc[BaseException], # Give E a value. *, match: str | Pattern[str], # If exception_type is not provided, check() must do any typechecks itself. @@ -222,9 +224,9 @@ def __init__(self, *, check: Callable[[BaseException], bool]) -> None: ... def __init__( self, - exception_type: type[MatchE] | None = None, + exception_type: type[BaseExcT_co_default] | None = None, match: str | Pattern[str] | None = None, - check: Callable[[MatchE], bool] | None = None, + check: Callable[[BaseExcT_co_default], bool] | None = None, ): super().__init__(match, check) if exception_type is None and match is None and check is None: @@ -238,20 +240,20 @@ def __init__( def matches( self, exception: BaseException, - ) -> TypeGuard[MatchE]: - """Check if an exception matches the requirements of this Matcher. - If it fails, `Matcher.fail_reason` will be set. + ) -> TypeGuard[BaseExcT_co_default]: + """Check if an exception matches the requirements of this RaisesExc. + If it fails, `RaisesExc.fail_reason` will be set. Examples:: - assert Matcher(ValueError).matches(my_exception): + assert RaisesExc(ValueError).matches(my_exception): # is equivalent to assert isinstance(my_exception, ValueError) # this can be useful when checking e.g. the ``__cause__`` of an exception. with pytest.raises(ValueError) as excinfo: ... - assert Matcher(SyntaxError, match="foo").matches(excinfo.value.__cause__) + assert RaisesExc(SyntaxError, match="foo").matches(excinfo.value.__cause__) # above line is equivalent to assert isinstance(excinfo.value.__cause__, SyntaxError) assert re.search("foo", str(excinfo.value.__cause__) @@ -276,15 +278,15 @@ def __repr__(self) -> str: ) if self.check is not None: parameters.append(f"check={repr_callable(self.check)}") - return f"Matcher({', '.join(parameters)})" + return f"RaisesExc({', '.join(parameters)})" - def _check_type(self, exception: BaseException) -> TypeGuard[MatchE]: + def _check_type(self, exception: BaseException) -> TypeGuard[BaseExcT_co_default]: self._fail_reason = _check_raw_type(self.exception_type, exception) return self._fail_reason is None @final -class RaisesGroup(AbstractMatcher[BaseExceptionGroup[BaseExcT_co]]): +class RaisesGroup(AbstractRaises[BaseExceptionGroup[BaseExcT_co]]): """Contextmanager for checking for an expected `ExceptionGroup`. This works similar to ``pytest.raises``, but allows for specifying the structure of an `ExceptionGroup`. `ExceptionInfo.group_contains` also tries to handle exception groups, @@ -299,13 +301,13 @@ class RaisesGroup(AbstractMatcher[BaseExceptionGroup[BaseExcT_co]]): #. All specified exceptions must be present, *and no others*. * If you expect a variable number of exceptions you need to use ``pytest.raises(ExceptionGroup)`` and manually - check the contained exceptions. Consider making use of :func:`Matcher.matches`. + check the contained exceptions. Consider making use of :func:`RaisesExc.matches`. #. It will only catch exceptions wrapped in an exceptiongroup by default. - * With ``allow_unwrapped=True`` you can specify a single expected exception or `Matcher` and it will match + * With ``allow_unwrapped=True`` you can specify a single expected exception or `RaisesExc` and it will match the exception even if it is not inside an `ExceptionGroup`. - If you expect one of several different exception types you need to use a `Matcher` object. + If you expect one of several different exception types you need to use a `RaisesExc` object. #. By default it cares about the full structure with nested `ExceptionGroup`'s. You can specify nested `ExceptionGroup`'s by passing `RaisesGroup` objects as expected exceptions. @@ -323,7 +325,7 @@ class RaisesGroup(AbstractMatcher[BaseExceptionGroup[BaseExcT_co]]): with RaisesGroups(ValueError): raise ExceptionGroup("", (ValueError(),)) with RaisesGroups( - ValueError, ValueError, Matcher(TypeError, match="expected int") + ValueError, ValueError, RaisesExc(TypeError, match="expected int") ): ... with RaisesGroups( @@ -349,11 +351,11 @@ class RaisesGroup(AbstractMatcher[BaseExceptionGroup[BaseExcT_co]]): The matching algorithm is greedy, which means cases such as this may fail:: - with RaisesGroups(ValueError, Matcher(ValueError, match="hello")): + with RaisesGroups(ValueError, RaisesExc(ValueError, match="hello")): raise ExceptionGroup("", (ValueError("hello"), ValueError("goodbye"))) even though it generally does not care about the order of the exceptions in the group. - To avoid the above you should specify the first ValueError with a Matcher as well. + To avoid the above you should specify the first ValueError with a RaisesExc as well. Tip: if you install ``hypothesis`` and import it in ``conftest.py`` you will get readable ``repr``s of ``check`` callables in the output. @@ -364,7 +366,7 @@ class RaisesGroup(AbstractMatcher[BaseExceptionGroup[BaseExcT_co]]): @overload def __init__( self, - exception: type[BaseExcT_co] | Matcher[BaseExcT_co], + exception: type[BaseExcT_co] | RaisesExc[BaseExcT_co], *, allow_unwrapped: Literal[True], flatten_subgroups: bool = False, @@ -374,8 +376,8 @@ def __init__( @overload def __init__( self, - exception: type[BaseExcT_co] | Matcher[BaseExcT_co], - *other_exceptions: type[BaseExcT_co] | Matcher[BaseExcT_co], + exception: type[BaseExcT_co] | RaisesExc[BaseExcT_co], + *other_exceptions: type[BaseExcT_co] | RaisesExc[BaseExcT_co], flatten_subgroups: Literal[True], match: str | Pattern[str] | None = None, check: Callable[[BaseExceptionGroup[BaseExcT_co]], bool] | None = None, @@ -389,8 +391,8 @@ def __init__( @overload def __init__( self: RaisesGroup[ExcT_1], - exception: type[ExcT_1] | Matcher[ExcT_1], - *other_exceptions: type[ExcT_1] | Matcher[ExcT_1], + exception: type[ExcT_1] | RaisesExc[ExcT_1], + *other_exceptions: type[ExcT_1] | RaisesExc[ExcT_1], match: str | Pattern[str] | None = None, check: Callable[[ExceptionGroup[ExcT_1]], bool] | None = None, ) -> None: ... @@ -407,8 +409,8 @@ def __init__( @overload def __init__( self: RaisesGroup[ExcT_1 | ExceptionGroup[ExcT_2]], - exception: type[ExcT_1] | Matcher[ExcT_1] | RaisesGroup[ExcT_2], - *other_exceptions: type[ExcT_1] | Matcher[ExcT_1] | RaisesGroup[ExcT_2], + exception: type[ExcT_1] | RaisesExc[ExcT_1] | RaisesGroup[ExcT_2], + *other_exceptions: type[ExcT_1] | RaisesExc[ExcT_1] | RaisesGroup[ExcT_2], match: str | Pattern[str] | None = None, check: ( Callable[[ExceptionGroup[ExcT_1 | ExceptionGroup[ExcT_2]]], bool] | None @@ -419,8 +421,8 @@ def __init__( @overload def __init__( self: RaisesGroup[BaseExcT_1], - exception: type[BaseExcT_1] | Matcher[BaseExcT_1], - *other_exceptions: type[BaseExcT_1] | Matcher[BaseExcT_1], + exception: type[BaseExcT_1] | RaisesExc[BaseExcT_1], + *other_exceptions: type[BaseExcT_1] | RaisesExc[BaseExcT_1], match: str | Pattern[str] | None = None, check: Callable[[BaseExceptionGroup[BaseExcT_1]], bool] | None = None, ) -> None: ... @@ -439,9 +441,9 @@ def __init__( @overload def __init__( self: RaisesGroup[BaseExcT_1 | BaseExceptionGroup[BaseExcT_2]], - exception: type[BaseExcT_1] | Matcher[BaseExcT_1] | RaisesGroup[BaseExcT_2], + exception: type[BaseExcT_1] | RaisesExc[BaseExcT_1] | RaisesGroup[BaseExcT_2], *other_exceptions: type[BaseExcT_1] - | Matcher[BaseExcT_1] + | RaisesExc[BaseExcT_1] | RaisesGroup[BaseExcT_2], match: str | Pattern[str] | None = None, check: ( @@ -455,9 +457,9 @@ def __init__( def __init__( self: RaisesGroup[ExcT_1 | BaseExcT_1 | BaseExceptionGroup[BaseExcT_2]], - exception: type[BaseExcT_1] | Matcher[BaseExcT_1] | RaisesGroup[BaseExcT_2], + exception: type[BaseExcT_1] | RaisesExc[BaseExcT_1] | RaisesGroup[BaseExcT_2], *other_exceptions: type[BaseExcT_1] - | Matcher[BaseExcT_1] + | RaisesExc[BaseExcT_1] | RaisesGroup[BaseExcT_2], allow_unwrapped: bool = False, flatten_subgroups: bool = False, @@ -479,7 +481,7 @@ def __init__( ) super().__init__(match, check) self.expected_exceptions: tuple[ - type[BaseExcT_co] | Matcher[BaseExcT_co] | RaisesGroup[BaseException], ... + type[BaseExcT_co] | RaisesExc[BaseExcT_co] | RaisesGroup[BaseException], ... ] = ( exception, *other_exceptions, @@ -492,8 +494,8 @@ def __init__( raise ValueError( "You cannot specify multiple exceptions with `allow_unwrapped=True.`" " If you want to match one of multiple possible exceptions you should" - " use a `Matcher`." - " E.g. `Matcher(check=lambda e: isinstance(e, (...)))`", + " use a `RaisesExc`." + " E.g. `RaisesExc(check=lambda e: isinstance(e, (...)))`", ) if allow_unwrapped and isinstance(exception, RaisesGroup): raise ValueError( @@ -505,7 +507,7 @@ def __init__( raise ValueError( "`allow_unwrapped=True` bypasses the `match` and `check` parameters" " if the exception is unwrapped. If you intended to match/check the" - " exception you should use a `Matcher` object. If you want to match/check" + " exception you should use a `RaisesExc` object. If you want to match/check" " the exceptiongroup when the exception *is* wrapped you need to" " do e.g. `if isinstance(exc.value, ExceptionGroup):" " assert RaisesGroup(...).matches(exc.value)` afterwards.", @@ -523,9 +525,9 @@ def __init__( ) self.is_baseexceptiongroup |= exc.is_baseexceptiongroup exc._nested = True - elif isinstance(exc, Matcher): + elif isinstance(exc, RaisesExc): if exc.exception_type is not None: - # Matcher __init__ assures it's a subclass of BaseException + # RaisesExc __init__ assures it's a subclass of BaseException self.is_baseexceptiongroup |= not issubclass( exc.exception_type, Exception, @@ -535,7 +537,7 @@ def __init__( self.is_baseexceptiongroup |= not issubclass(exc, Exception) else: raise TypeError( - f'Invalid argument "{exc!r}" must be exception type, Matcher, or' + f'Invalid argument "{exc!r}" must be exception type, RaisesExc, or' " RaisesGroup.", ) @@ -657,7 +659,7 @@ def matches( assert self._fail_reason is old_reason is not None self._fail_reason += ( f", but matched the expected {self._repr_expected(expected)}." - f" You might want RaisesGroup(Matcher({expected.__name__}, match={_match_pattern(self.match)!r}))" + f" You might want RaisesGroup(RaisesExc({expected.__name__}, match={_match_pattern(self.match)!r}))" ) else: self._fail_reason = old_reason @@ -706,7 +708,7 @@ def matches( ): self._fail_reason = reason + ( f", but did return True for the expected {self._repr_expected(expected)}." - f" You might want RaisesGroup(Matcher({expected.__name__}, check=<...>))" + f" You might want RaisesGroup(RaisesExc({expected.__name__}, check=<...>))" ) else: self._fail_reason = reason @@ -717,7 +719,7 @@ def matches( @staticmethod def _check_expected( expected_type: ( - type[BaseException] | Matcher[BaseException] | RaisesGroup[BaseException] + type[BaseException] | RaisesExc[BaseException] | RaisesGroup[BaseException] ), exception: BaseException, ) -> str | None: @@ -734,8 +736,8 @@ def _check_expected( return f"{expected_type!r}: {expected_type.fail_reason}" @staticmethod - def _repr_expected(e: type[BaseException] | AbstractMatcher[BaseException]) -> str: - """Get the repr of an expected type/Matcher/RaisesGroup, but we only want + def _repr_expected(e: type[BaseException] | AbstractRaises[BaseException]) -> str: + """Get the repr of an expected type/RaisesExc/RaisesGroup, but we only want the name if it's a type""" if isinstance(e, type): return _exception_type_name(e) @@ -887,7 +889,7 @@ def _check_exceptions( s += ( "\nThere exist a possible match when attempting an exhaustive check," " but RaisesGroup uses a greedy algorithm. " - "Please make your expected exceptions more stringent with `Matcher` etc" + "Please make your expected exceptions more stringent with `RaisesExc` etc" " so the greedy algorithm can function." ) self._fail_reason = s @@ -928,7 +930,7 @@ def __exit__( def expected_type(self) -> str: subexcs = [] for e in self.expected_exceptions: - if isinstance(e, Matcher): + if isinstance(e, RaisesExc): subexcs.append(str(e)) elif isinstance(e, RaisesGroup): subexcs.append(e.expected_type()) @@ -953,7 +955,7 @@ class ResultHolder: def __init__( self, expected_exceptions: tuple[ - type[BaseException] | AbstractMatcher[BaseException], ... + type[BaseException] | AbstractRaises[BaseException], ... ], actual_exceptions: Sequence[BaseException], ) -> None: diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index b5f54d559e5..50225f7529f 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -23,7 +23,7 @@ from .._code import getfslineno from ..compat import NOTSET from ..compat import NotSetType -from _pytest._raises_group import AbstractMatcher +from _pytest._raises_group import AbstractRaises from _pytest.config import Config from _pytest.deprecated import check_ispytest from _pytest.deprecated import MARKED_FIXTURE @@ -463,7 +463,7 @@ def __call__( raises: None | type[BaseException] | tuple[type[BaseException], ...] - | AbstractMatcher[BaseException] = ..., + | AbstractRaises[BaseException] = ..., strict: bool = ..., ) -> MarkDecorator: ... diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index ddbf9b87251..c90f67f7b3d 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -908,6 +908,11 @@ def raises( ... >>> assert exc_info.type is ValueError + **Expecting exception groups** + + When expecting exceptions wrapped in :exc:`BaseExceptionGroup` or + :exc:`ExceptionGroup`, you should instead use :class:`pytest.RaisesGroup`. + **Using with** ``pytest.mark.parametrize`` When using :ref:`pytest.mark.parametrize ref` diff --git a/src/_pytest/skipping.py b/src/_pytest/skipping.py index 0736f48aa45..293bea704cb 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -12,7 +12,7 @@ import traceback from typing import Optional -from _pytest._raises_group import AbstractMatcher +from _pytest._raises_group import AbstractRaises from _pytest.config import Config from _pytest.config import hookimpl from _pytest.config.argparsing import Parser @@ -205,7 +205,7 @@ class Xfail: raises: ( type[BaseException] | tuple[type[BaseException], ...] - | AbstractMatcher[BaseException] + | AbstractRaises[BaseException] | None ) @@ -289,7 +289,7 @@ def pytest_runtest_makereport( and isinstance(call.excinfo.value, raises) ) or ( - isinstance(raises, AbstractMatcher) + isinstance(raises, AbstractRaises) and raises.matches(call.excinfo.value) ) ): diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index ca2c66fee03..a16377ca6bb 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -6,7 +6,7 @@ from _pytest import __version__ from _pytest import version_tuple from _pytest._code import ExceptionInfo -from _pytest._raises_group import Matcher +from _pytest._raises_group import RaisesExc from _pytest._raises_group import RaisesGroup from _pytest._raises_group import RaisesGroup as raises_group from _pytest.assertion import register_assert_rewrite @@ -116,7 +116,6 @@ "Mark", "MarkDecorator", "MarkGenerator", - "Matcher", "Metafunc", "Module", "MonkeyPatch", @@ -137,6 +136,7 @@ "PytestUnraisableExceptionWarning", "PytestWarning", "Pytester", + "RaisesExc", "RaisesGroup", "RecordedHookCall", "RunResult", diff --git a/testing/python/raises_group.py b/testing/python/raises_group.py index 73715ee1c21..d0d443cc0cc 100644 --- a/testing/python/raises_group.py +++ b/testing/python/raises_group.py @@ -3,11 +3,13 @@ # several expected multi-line strings contain long lines. We don't wanna break them up # as that makes it confusing to see where the line breaks are. # ruff: noqa: E501 +from contextlib import AbstractContextManager import re import sys from typing import TYPE_CHECKING -from _pytest._raises_group import Matcher +from _pytest._code import ExceptionInfo +from _pytest._raises_group import RaisesExc from _pytest._raises_group import RaisesGroup from _pytest._raises_group import repr_callable from _pytest.outcomes import Failed @@ -39,7 +41,7 @@ def test_raises_group() -> None: with pytest.raises( TypeError, match=wrap_escape( - f'Invalid argument "{ValueError()!r}" must be exception type, Matcher, or RaisesGroup.', + f'Invalid argument "{ValueError()!r}" must be exception type, RaisesExc, or RaisesGroup.', ), ): RaisesGroup(ValueError()) # type: ignore[call-overload] @@ -276,10 +278,10 @@ def test_catch_unwrapped_exceptions() -> None: match=r"^You cannot specify multiple exceptions with", ): RaisesGroup(SyntaxError, ValueError, allow_unwrapped=True) # type: ignore[call-overload] - # if users want one of several exception types they need to use a Matcher + # if users want one of several exception types they need to use a RaisesExc # (which the error message suggests) with RaisesGroup( - Matcher(check=lambda e: isinstance(e, (SyntaxError, ValueError))), + RaisesExc(check=lambda e: isinstance(e, (SyntaxError, ValueError))), allow_unwrapped=True, ): raise ValueError @@ -289,7 +291,7 @@ def test_catch_unwrapped_exceptions() -> None: RaisesGroup(RaisesGroup(ValueError), allow_unwrapped=True) # type: ignore[call-overload] # But it *can* be used to check for nesting level +- 1 if they move it to - # the nested RaisesGroup. Users should probably use `Matcher`s instead though. + # the nested RaisesGroup. Users should probably use `RaisesExc`s instead though. with RaisesGroup(RaisesGroup(ValueError, allow_unwrapped=True)): raise ExceptionGroup("", [ExceptionGroup("", [ValueError()])]) with RaisesGroup(RaisesGroup(ValueError, allow_unwrapped=True)): @@ -329,10 +331,10 @@ def test_catch_unwrapped_exceptions() -> None: raise TypeError("this text doesn't show up in the error message") with ( fails_raises_group( - "Raised exception (group) did not match: Matcher(ValueError): 'TypeError' is not of type 'ValueError'", + "Raised exception (group) did not match: RaisesExc(ValueError): 'TypeError' is not of type 'ValueError'", add_prefix=False, ), - RaisesGroup(Matcher(ValueError), allow_unwrapped=True), + RaisesGroup(RaisesExc(ValueError), allow_unwrapped=True), ): raise TypeError @@ -360,7 +362,7 @@ def test_match() -> None: raise e # and technically you can match it all with ^$ - # but you're probably better off using a Matcher at that point + # but you're probably better off using a RaisesExc at that point with RaisesGroup(ValueError, match="^bar\nmy note$"): e = ExceptionGroup("bar", (ValueError(),)) e.add_note("my note") @@ -375,12 +377,12 @@ def test_match() -> None: raise ExceptionGroup("bar", (ValueError(),)) # Suggest a fix for easy pitfall of adding match to the RaisesGroup instead of - # using a Matcher. + # using a RaisesExc. # This requires a single expected & raised exception, the expected is a type, # and `isinstance(raised, expected_type)`. with ( fails_raises_group( - "Regex pattern 'foo' did not match 'bar' of 'ExceptionGroup', but matched the expected 'ValueError'. You might want RaisesGroup(Matcher(ValueError, match='foo'))" + "Regex pattern 'foo' did not match 'bar' of 'ExceptionGroup', but matched the expected 'ValueError'. You might want RaisesGroup(RaisesExc(ValueError, match='foo'))" ), RaisesGroup(ValueError, match="foo"), ): @@ -411,7 +413,7 @@ def is_value_error(e: BaseException) -> bool: # helpful suggestion if the user thinks the check is for the sub-exception with ( fails_raises_group( - f"check {is_value_error} did not return True on the ExceptionGroup, but did return True for the expected 'ValueError'. You might want RaisesGroup(Matcher(ValueError, check=<...>))" + f"check {is_value_error} did not return True on the ExceptionGroup, but did return True for the expected 'ValueError'. You might want RaisesGroup(RaisesExc(ValueError, check=<...>))" ), RaisesGroup(ValueError, check=is_value_error), ): @@ -425,7 +427,7 @@ def my_check(e: object) -> bool: # pragma: no cover msg = ( "`allow_unwrapped=True` bypasses the `match` and `check` parameters" " if the exception is unwrapped. If you intended to match/check the" - " exception you should use a `Matcher` object. If you want to match/check" + " exception you should use a `RaisesExc` object. If you want to match/check" " the exceptiongroup when the exception *is* wrapped you need to" " do e.g. `if isinstance(exc.value, ExceptionGroup):" " assert RaisesGroup(...).matches(exc.value)` afterwards." @@ -435,8 +437,8 @@ def my_check(e: object) -> bool: # pragma: no cover with pytest.raises(ValueError, match=re.escape(msg)): RaisesGroup(ValueError, allow_unwrapped=True, check=my_check) # type: ignore[call-overload] - # Users should instead use a Matcher - rg = RaisesGroup(Matcher(ValueError, match="^foo$"), allow_unwrapped=True) + # Users should instead use a RaisesExc + rg = RaisesGroup(RaisesExc(ValueError, match="^foo$"), allow_unwrapped=True) with rg: raise ValueError("foo") with rg: @@ -483,14 +485,14 @@ def check_message( RaisesGroup(RaisesGroup(ValueError)), ) - # Matcher + # RaisesExc check_message( - "ExceptionGroup(Matcher(ValueError, match='my_str'))", - RaisesGroup(Matcher(ValueError, "my_str")), + "ExceptionGroup(RaisesExc(ValueError, match='my_str'))", + RaisesGroup(RaisesExc(ValueError, "my_str")), ) check_message( - "ExceptionGroup(Matcher(match='my_str'))", - RaisesGroup(Matcher(match="my_str")), + "ExceptionGroup(RaisesExc(match='my_str'))", + RaisesGroup(RaisesExc(match="my_str")), ) # BaseExceptionGroup @@ -498,10 +500,10 @@ def check_message( "BaseExceptionGroup(KeyboardInterrupt)", RaisesGroup(KeyboardInterrupt), ) - # BaseExceptionGroup with type inside Matcher + # BaseExceptionGroup with type inside RaisesExc check_message( - "BaseExceptionGroup(Matcher(KeyboardInterrupt))", - RaisesGroup(Matcher(KeyboardInterrupt)), + "BaseExceptionGroup(RaisesExc(KeyboardInterrupt))", + RaisesGroup(RaisesExc(KeyboardInterrupt)), ) # Base-ness transfers to parent containers check_message( @@ -556,7 +558,7 @@ def test_assert_message() -> None: "The following raised exceptions did not find a match\n" " RuntimeError():\n" # " 'RuntimeError' is not of type 'ValueError'\n" - # " Matcher(TypeError): 'RuntimeError' is not of type 'TypeError'\n" + # " RaisesExc(TypeError): 'RuntimeError' is not of type 'TypeError'\n" " RaisesGroup(RuntimeError): 'RuntimeError' is not an exception group, but would match with `allow_unwrapped=True`\n" " RaisesGroup(ValueError): 'RuntimeError' is not an exception group\n" " ValueError('bar'):\n" @@ -567,7 +569,7 @@ def test_assert_message() -> None: ), RaisesGroup( ValueError, - Matcher(TypeError), + RaisesExc(TypeError), RaisesGroup(RuntimeError), RaisesGroup(ValueError), ), @@ -587,9 +589,9 @@ def test_assert_message() -> None: with ( fails_raises_group( - "Matcher(ValueError): 'TypeError' is not of type 'ValueError'" + "RaisesExc(ValueError): 'TypeError' is not of type 'ValueError'" ), - RaisesGroup(Matcher(ValueError)), + RaisesGroup(RaisesExc(ValueError)), ): raise ExceptionGroup("a", [TypeError()]) @@ -605,10 +607,10 @@ def test_assert_message() -> None: raise ExceptionGroup("h(ell)o", [ValueError()]) with ( fails_raises_group( - "Matcher(match='h(ell)o'): Regex pattern 'h(ell)o' did not match 'h(ell)o'\n" + "RaisesExc(match='h(ell)o'): Regex pattern 'h(ell)o' did not match 'h(ell)o'\n" " Did you mean to `re.escape()` the regex?", ), - RaisesGroup(Matcher(match="h(ell)o")), + RaisesGroup(RaisesExc(match="h(ell)o")), ): raise ExceptionGroup("", [ValueError("h(ell)o")]) @@ -775,18 +777,18 @@ def test_assert_message_nested() -> None: "The following expected exceptions did not find a match:\n" " RaisesGroup(ValueError)\n" " RaisesGroup(RaisesGroup(ValueError))\n" - " RaisesGroup(Matcher(TypeError, match='foo'))\n" + " RaisesGroup(RaisesExc(TypeError, match='foo'))\n" " RaisesGroup(TypeError, ValueError)\n" "The following raised exceptions did not find a match\n" " TypeError('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'):\n" " RaisesGroup(ValueError): 'TypeError' is not an exception group\n" " RaisesGroup(RaisesGroup(ValueError)): 'TypeError' is not an exception group\n" - " RaisesGroup(Matcher(TypeError, match='foo')): 'TypeError' is not an exception group\n" + " RaisesGroup(RaisesExc(TypeError, match='foo')): 'TypeError' is not an exception group\n" " RaisesGroup(TypeError, ValueError): 'TypeError' is not an exception group\n" " ExceptionGroup('Exceptions from Trio nursery', [TypeError('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb')]):\n" " RaisesGroup(ValueError): 'TypeError' is not of type 'ValueError'\n" " RaisesGroup(RaisesGroup(ValueError)): RaisesGroup(ValueError): 'TypeError' is not an exception group\n" - " RaisesGroup(Matcher(TypeError, match='foo')): Matcher(TypeError, match='foo'): Regex pattern 'foo' did not match 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'\n" + " RaisesGroup(RaisesExc(TypeError, match='foo')): RaisesExc(TypeError, match='foo'): Regex pattern 'foo' did not match 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'\n" " RaisesGroup(TypeError, ValueError): 1 matched exception. Too few exceptions raised, found no match for: ['ValueError']\n" " ExceptionGroup('Exceptions from Trio nursery', [TypeError('cccccccccccccccccccccccccccccc'), TypeError('dddddddddddddddddddddddddddddd')]):\n" " RaisesGroup(ValueError): \n" @@ -805,14 +807,14 @@ def test_assert_message_nested() -> None: " RaisesGroup(ValueError): 'TypeError' is not an exception group\n" " TypeError('dddddddddddddddddddddddddddddd'):\n" " RaisesGroup(ValueError): 'TypeError' is not an exception group\n" - " RaisesGroup(Matcher(TypeError, match='foo')): \n" + " RaisesGroup(RaisesExc(TypeError, match='foo')): \n" " The following expected exceptions did not find a match:\n" - " Matcher(TypeError, match='foo')\n" + " RaisesExc(TypeError, match='foo')\n" " The following raised exceptions did not find a match\n" " TypeError('cccccccccccccccccccccccccccccc'):\n" - " Matcher(TypeError, match='foo'): Regex pattern 'foo' did not match 'cccccccccccccccccccccccccccccc'\n" + " RaisesExc(TypeError, match='foo'): Regex pattern 'foo' did not match 'cccccccccccccccccccccccccccccc'\n" " TypeError('dddddddddddddddddddddddddddddd'):\n" - " Matcher(TypeError, match='foo'): Regex pattern 'foo' did not match 'dddddddddddddddddddddddddddddd'\n" + " RaisesExc(TypeError, match='foo'): Regex pattern 'foo' did not match 'dddddddddddddddddddddddddddddd'\n" " RaisesGroup(TypeError, ValueError): \n" " 1 matched exception. \n" " The following expected exceptions did not find a match:\n" @@ -826,7 +828,7 @@ def test_assert_message_nested() -> None: RaisesGroup( RaisesGroup(ValueError), RaisesGroup(RaisesGroup(ValueError)), - RaisesGroup(Matcher(TypeError, match="foo")), + RaisesGroup(RaisesExc(TypeError, match="foo")), RaisesGroup(TypeError, ValueError), ), ): @@ -861,19 +863,19 @@ def test_check_no_patched_repr() -> None: match_str = ( r"^Raised exception group did not match: \n" r"The following expected exceptions did not find a match:\n" - r" Matcher\(check=. at .*>\)\n" + r" RaisesExc\(check=. at .*>\)\n" r" 'TypeError'\n" r"The following raised exceptions did not find a match\n" r" ValueError\('foo'\):\n" - r" Matcher\(check=. at .*>\): check did not return True\n" + r" RaisesExc\(check=. at .*>\): check did not return True\n" r" 'ValueError' is not of type 'TypeError'\n" r" ValueError\('bar'\):\n" - r" Matcher\(check=. at .*>\): check did not return True\n" + r" RaisesExc\(check=. at .*>\): check did not return True\n" r" 'ValueError' is not of type 'TypeError'$" ) with ( pytest.raises(Failed, match=match_str), - RaisesGroup(Matcher(check=lambda x: False), TypeError), + RaisesGroup(RaisesExc(check=lambda x: False), TypeError), ): raise ExceptionGroup("", [ValueError("foo"), ValueError("bar")]) @@ -884,7 +886,7 @@ def test_misordering_example() -> None: "\n" "3 matched exceptions. \n" "The following expected exceptions did not find a match:\n" - " Matcher(ValueError, match='foo')\n" + " RaisesExc(ValueError, match='foo')\n" " It matches ValueError('foo') which was paired with 'ValueError'\n" " It matches ValueError('foo') which was paired with 'ValueError'\n" " It matches ValueError('foo') which was paired with 'ValueError'\n" @@ -893,11 +895,11 @@ def test_misordering_example() -> None: " It matches 'ValueError' which was paired with ValueError('foo')\n" " It matches 'ValueError' which was paired with ValueError('foo')\n" " It matches 'ValueError' which was paired with ValueError('foo')\n" - " Matcher(ValueError, match='foo'): Regex pattern 'foo' did not match 'bar'\n" - "There exist a possible match when attempting an exhaustive check, but RaisesGroup uses a greedy algorithm. Please make your expected exceptions more stringent with `Matcher` etc so the greedy algorithm can function." + " RaisesExc(ValueError, match='foo'): Regex pattern 'foo' did not match 'bar'\n" + "There exist a possible match when attempting an exhaustive check, but RaisesGroup uses a greedy algorithm. Please make your expected exceptions more stringent with `RaisesExc` etc so the greedy algorithm can function." ), RaisesGroup( - ValueError, ValueError, ValueError, Matcher(ValueError, match="foo") + ValueError, ValueError, ValueError, RaisesExc(ValueError, match="foo") ), ): raise ExceptionGroup( @@ -972,134 +974,134 @@ def test_identity_oopsies() -> None: ) e = ValueError("foo") - m = Matcher(match="bar") + m = RaisesExc(match="bar") with ( fails_raises_group( "\n" "The following expected exceptions did not find a match:\n" - " Matcher(match='bar')\n" - " Matcher(match='bar')\n" - " Matcher(match='bar')\n" + " RaisesExc(match='bar')\n" + " RaisesExc(match='bar')\n" + " RaisesExc(match='bar')\n" "The following raised exceptions did not find a match\n" " ValueError('foo'):\n" - " Matcher(match='bar'): Regex pattern 'bar' did not match 'foo'\n" - " Matcher(match='bar'): Regex pattern 'bar' did not match 'foo'\n" - " Matcher(match='bar'): Regex pattern 'bar' did not match 'foo'\n" + " RaisesExc(match='bar'): Regex pattern 'bar' did not match 'foo'\n" + " RaisesExc(match='bar'): Regex pattern 'bar' did not match 'foo'\n" + " RaisesExc(match='bar'): Regex pattern 'bar' did not match 'foo'\n" " ValueError('foo'):\n" - " Matcher(match='bar'): Regex pattern 'bar' did not match 'foo'\n" - " Matcher(match='bar'): Regex pattern 'bar' did not match 'foo'\n" - " Matcher(match='bar'): Regex pattern 'bar' did not match 'foo'\n" + " RaisesExc(match='bar'): Regex pattern 'bar' did not match 'foo'\n" + " RaisesExc(match='bar'): Regex pattern 'bar' did not match 'foo'\n" + " RaisesExc(match='bar'): Regex pattern 'bar' did not match 'foo'\n" " ValueError('foo'):\n" - " Matcher(match='bar'): Regex pattern 'bar' did not match 'foo'\n" - " Matcher(match='bar'): Regex pattern 'bar' did not match 'foo'\n" - " Matcher(match='bar'): Regex pattern 'bar' did not match 'foo'" + " RaisesExc(match='bar'): Regex pattern 'bar' did not match 'foo'\n" + " RaisesExc(match='bar'): Regex pattern 'bar' did not match 'foo'\n" + " RaisesExc(match='bar'): Regex pattern 'bar' did not match 'foo'" ), RaisesGroup(m, m, m), ): raise ExceptionGroup("", [e, e, e]) -def test_matcher() -> None: +def test_raisesexc() -> None: with pytest.raises( ValueError, match=r"^You must specify at least one parameter to match on.$", ): - Matcher() # type: ignore[call-overload] + RaisesExc() # type: ignore[call-overload] with pytest.raises( TypeError, match=f"^exception_type {re.escape(repr(object))} must be a subclass of BaseException$", ): - Matcher(object) # type: ignore[type-var] + RaisesExc(object) # type: ignore[type-var] - with RaisesGroup(Matcher(ValueError)): + with RaisesGroup(RaisesExc(ValueError)): raise ExceptionGroup("", (ValueError(),)) with ( fails_raises_group( - "Matcher(TypeError): 'ValueError' is not of type 'TypeError'" + "RaisesExc(TypeError): 'ValueError' is not of type 'TypeError'" ), - RaisesGroup(Matcher(TypeError)), + RaisesGroup(RaisesExc(TypeError)), ): raise ExceptionGroup("", (ValueError(),)) -def test_matcher_match() -> None: - with RaisesGroup(Matcher(ValueError, "foo")): +def test_raisesexc_match() -> None: + with RaisesGroup(RaisesExc(ValueError, "foo")): raise ExceptionGroup("", (ValueError("foo"),)) with ( fails_raises_group( - "Matcher(ValueError, match='foo'): Regex pattern 'foo' did not match 'bar'" + "RaisesExc(ValueError, match='foo'): Regex pattern 'foo' did not match 'bar'" ), - RaisesGroup(Matcher(ValueError, "foo")), + RaisesGroup(RaisesExc(ValueError, "foo")), ): raise ExceptionGroup("", (ValueError("bar"),)) # Can be used without specifying the type - with RaisesGroup(Matcher(match="foo")): + with RaisesGroup(RaisesExc(match="foo")): raise ExceptionGroup("", (ValueError("foo"),)) with ( fails_raises_group( - "Matcher(match='foo'): Regex pattern 'foo' did not match 'bar'" + "RaisesExc(match='foo'): Regex pattern 'foo' did not match 'bar'" ), - RaisesGroup(Matcher(match="foo")), + RaisesGroup(RaisesExc(match="foo")), ): raise ExceptionGroup("", (ValueError("bar"),)) # check ^$ - with RaisesGroup(Matcher(ValueError, match="^bar$")): + with RaisesGroup(RaisesExc(ValueError, match="^bar$")): raise ExceptionGroup("", [ValueError("bar")]) with ( fails_raises_group( - "Matcher(ValueError, match='^bar$'): Regex pattern '^bar$' did not match 'barr'" + "RaisesExc(ValueError, match='^bar$'): Regex pattern '^bar$' did not match 'barr'" ), - RaisesGroup(Matcher(ValueError, match="^bar$")), + RaisesGroup(RaisesExc(ValueError, match="^bar$")), ): raise ExceptionGroup("", [ValueError("barr")]) -def test_Matcher_check() -> None: +def test_RaisesExc_check() -> None: def check_oserror_and_errno_is_5(e: BaseException) -> bool: return isinstance(e, OSError) and e.errno == 5 - with RaisesGroup(Matcher(check=check_oserror_and_errno_is_5)): + with RaisesGroup(RaisesExc(check=check_oserror_and_errno_is_5)): raise ExceptionGroup("", (OSError(5, ""),)) # specifying exception_type narrows the parameter type to the callable def check_errno_is_5(e: OSError) -> bool: return e.errno == 5 - with RaisesGroup(Matcher(OSError, check=check_errno_is_5)): + with RaisesGroup(RaisesExc(OSError, check=check_errno_is_5)): raise ExceptionGroup("", (OSError(5, ""),)) # avoid printing overly verbose repr multiple times with ( fails_raises_group( - f"Matcher(OSError, check={check_errno_is_5!r}): check did not return True" + f"RaisesExc(OSError, check={check_errno_is_5!r}): check did not return True" ), - RaisesGroup(Matcher(OSError, check=check_errno_is_5)), + RaisesGroup(RaisesExc(OSError, check=check_errno_is_5)), ): raise ExceptionGroup("", (OSError(6, ""),)) # in nested cases you still get it multiple times though - # to address this you'd need logic in Matcher.__repr__ and RaisesGroup.__repr__ + # to address this you'd need logic in RaisesExc.__repr__ and RaisesGroup.__repr__ with ( fails_raises_group( - f"RaisesGroup(Matcher(OSError, check={check_errno_is_5!r})): Matcher(OSError, check={check_errno_is_5!r}): check did not return True" + f"RaisesGroup(RaisesExc(OSError, check={check_errno_is_5!r})): RaisesExc(OSError, check={check_errno_is_5!r}): check did not return True" ), - RaisesGroup(RaisesGroup(Matcher(OSError, check=check_errno_is_5))), + RaisesGroup(RaisesGroup(RaisesExc(OSError, check=check_errno_is_5))), ): raise ExceptionGroup("", [ExceptionGroup("", [OSError(6, "")])]) -def test_matcher_tostring() -> None: - assert str(Matcher(ValueError)) == "Matcher(ValueError)" - assert str(Matcher(match="[a-z]")) == "Matcher(match='[a-z]')" +def test_raisesexc_tostring() -> None: + assert str(RaisesExc(ValueError)) == "RaisesExc(ValueError)" + assert str(RaisesExc(match="[a-z]")) == "RaisesExc(match='[a-z]')" pattern_no_flags = re.compile(r"noflag", 0) - assert str(Matcher(match=pattern_no_flags)) == "Matcher(match='noflag')" + assert str(RaisesExc(match=pattern_no_flags)) == "RaisesExc(match='noflag')" pattern_flags = re.compile(r"noflag", re.IGNORECASE) - assert str(Matcher(match=pattern_flags)) == f"Matcher(match={pattern_flags!r})" + assert str(RaisesExc(match=pattern_flags)) == f"RaisesExc(match={pattern_flags!r})" assert ( - str(Matcher(ValueError, match="re", check=bool)) - == f"Matcher(ValueError, match='re', check={bool!r})" + str(RaisesExc(ValueError, match="re", check=bool)) + == f"RaisesExc(ValueError, match='re', check={bool!r})" ) @@ -1110,7 +1112,7 @@ def check_str_and_repr(s: str) -> None: check_str_and_repr("RaisesGroup(ValueError)") check_str_and_repr("RaisesGroup(RaisesGroup(ValueError))") - check_str_and_repr("RaisesGroup(Matcher(ValueError))") + check_str_and_repr("RaisesGroup(RaisesExc(ValueError))") check_str_and_repr("RaisesGroup(ValueError, allow_unwrapped=True)") check_str_and_repr("RaisesGroup(ValueError, match='aoeu')") @@ -1124,18 +1126,18 @@ def test_assert_matches() -> None: e = ValueError() # it's easy to do this - assert Matcher(ValueError).matches(e) + assert RaisesExc(ValueError).matches(e) # but you don't get a helpful error with pytest.raises(AssertionError, match=r"assert False\n \+ where False = .*"): - assert Matcher(TypeError).matches(e) + assert RaisesExc(TypeError).matches(e) # you'd need to do this arcane incantation with pytest.raises(AssertionError, match="'ValueError' is not of type 'TypeError'"): - assert (m := Matcher(TypeError)).matches(e), m.fail_reason + assert (m := RaisesExc(TypeError)).matches(e), m.fail_reason # but even if we add assert_matches, will people remember to use it? - # other than writing a linter rule, I don't think we can catch `assert Matcher(...).matches` + # other than writing a linter rule, I don't think we can catch `assert RaisesExc(...).matches` # https://github.com/pytest-dev/pytest/issues/12504 @@ -1152,14 +1154,30 @@ def test_foo() -> None: result.assert_outcomes(xfailed=1) -def test_xfail_Matcher(pytester: Pytester) -> None: +def test_xfail_RaisesExc(pytester: Pytester) -> None: pytester.makepyfile( """ import pytest - @pytest.mark.xfail(raises=pytest.Matcher(ValueError)) + @pytest.mark.xfail(raises=pytest.RaisesExc(ValueError)) def test_foo() -> None: raise ValueError """ ) result = pytester.runpytest() result.assert_outcomes(xfailed=1) + + +@pytest.mark.parametrize( + "wrap_in_group,handler", + [ + (False, pytest.raises(ValueError)), + (True, RaisesGroup(ValueError)), + ], +) +def test_parametrizing_conditional_raisesgroup( + wrap_in_group: bool, handler: AbstractContextManager[ExceptionInfo[BaseException]] +) -> None: + with handler: + if wrap_in_group: + raise ExceptionGroup("", [ValueError()]) + raise ValueError() diff --git a/testing/typing_raises_group.py b/testing/typing_raises_group.py index 2dc35031dac..87cce35b72d 100644 --- a/testing/typing_raises_group.py +++ b/testing/typing_raises_group.py @@ -6,7 +6,7 @@ from typing_extensions import assert_type -from _pytest._raises_group import Matcher +from _pytest._raises_group import RaisesExc from _pytest._raises_group import RaisesGroup @@ -17,7 +17,7 @@ # split into functions to isolate the different scopes -def check_matcher_typevar_default(e: Matcher) -> None: +def check_raisesexc_typevar_default(e: RaisesExc) -> None: assert e.exception_type is not None _exc: type[BaseException] = e.exception_type # this would previously pass, as the type would be `Any` @@ -53,30 +53,30 @@ def check_matches_with_different_exception_type() -> None: assert_type(e, ExceptionGroup[ValueError]) -def check_matcher_init() -> None: +def check_raisesexc_init() -> None: def check_exc(exc: BaseException) -> bool: return isinstance(exc, ValueError) # Check various combinations of constructor signatures. # At least 1 arg must be provided. - Matcher() # type: ignore - Matcher(ValueError) - Matcher(ValueError, "regex") - Matcher(ValueError, "regex", check_exc) - Matcher(exception_type=ValueError) - Matcher(match="regex") - Matcher(check=check_exc) - Matcher(ValueError, match="regex") - Matcher(match="regex", check=check_exc) + RaisesExc() # type: ignore + RaisesExc(ValueError) + RaisesExc(ValueError, "regex") + RaisesExc(ValueError, "regex", check_exc) + RaisesExc(exception_type=ValueError) + RaisesExc(match="regex") + RaisesExc(check=check_exc) + RaisesExc(ValueError, match="regex") + RaisesExc(match="regex", check=check_exc) def check_filenotfound(exc: FileNotFoundError) -> bool: return not exc.filename.endswith(".tmp") # If exception_type is provided, that narrows the `check` method's argument. - Matcher(FileNotFoundError, check=check_filenotfound) - Matcher(ValueError, check=check_filenotfound) # type: ignore - Matcher(check=check_filenotfound) # type: ignore - Matcher(FileNotFoundError, match="regex", check=check_filenotfound) + RaisesExc(FileNotFoundError, check=check_filenotfound) + RaisesExc(ValueError, check=check_filenotfound) # type: ignore + RaisesExc(check=check_filenotfound) # type: ignore + RaisesExc(FileNotFoundError, match="regex", check=check_filenotfound) def raisesgroup_check_type_narrowing() -> None: @@ -126,8 +126,8 @@ def handle_group_value(e: ExceptionGroup[ValueError]) -> bool: RaisesGroup(Exception, check=handle_group) -def check_matcher_transparent() -> None: - with RaisesGroup(Matcher(ValueError)) as e: +def check_raisesexc_transparent() -> None: + with RaisesGroup(RaisesExc(ValueError)) as e: ... _: BaseExceptionGroup[ValueError] = e.value assert_type(e.value, ExceptionGroup[ValueError]) @@ -167,8 +167,8 @@ def check_nested_raisesgroups_matches() -> None: def check_multiple_exceptions_1() -> None: a = RaisesGroup(ValueError, ValueError) - b = RaisesGroup(Matcher(ValueError), Matcher(ValueError)) - c = RaisesGroup(ValueError, Matcher(ValueError)) + b = RaisesGroup(RaisesExc(ValueError), RaisesExc(ValueError)) + c = RaisesGroup(ValueError, RaisesExc(ValueError)) d: RaisesGroup[ValueError] d = a @@ -179,8 +179,8 @@ def check_multiple_exceptions_1() -> None: def check_multiple_exceptions_2() -> None: # This previously failed due to lack of covariance in the TypeVar - a = RaisesGroup(Matcher(ValueError), Matcher(TypeError)) - b = RaisesGroup(Matcher(ValueError), TypeError) + a = RaisesGroup(RaisesExc(ValueError), RaisesExc(TypeError)) + b = RaisesGroup(RaisesExc(ValueError), TypeError) c = RaisesGroup(ValueError, TypeError) d: RaisesGroup[Exception] @@ -203,7 +203,7 @@ def check_raisesgroup_overloads() -> None: # allowed variants RaisesGroup(ValueError, allow_unwrapped=True) RaisesGroup(ValueError, allow_unwrapped=True, flatten_subgroups=True) - RaisesGroup(Matcher(ValueError), allow_unwrapped=True) + RaisesGroup(RaisesExc(ValueError), allow_unwrapped=True) # flatten_subgroups=True does not allow nested RaisesGroup RaisesGroup(RaisesGroup(ValueError), flatten_subgroups=True) # type: ignore @@ -212,7 +212,7 @@ def check_raisesgroup_overloads() -> None: RaisesGroup(ValueError, match="foo", flatten_subgroups=True) RaisesGroup(ValueError, check=bool, flatten_subgroups=True) RaisesGroup(ValueError, flatten_subgroups=True) - RaisesGroup(Matcher(ValueError), flatten_subgroups=True) + RaisesGroup(RaisesExc(ValueError), flatten_subgroups=True) # if they're both false we can of course specify nested raisesgroup RaisesGroup(RaisesGroup(ValueError)) From e73c4111d201f5614b8a5b8e064efc5e46b0acf2 Mon Sep 17 00:00:00 2001 From: jakkdl <11260241+jakkdl@users.noreply.github.com> Date: Thu, 6 Feb 2025 15:52:56 +0100 Subject: [PATCH 4/5] fix test on py<311 --- testing/python/raises_group.py | 2 ++ tox.ini | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/testing/python/raises_group.py b/testing/python/raises_group.py index d0d443cc0cc..f3f3bcb5316 100644 --- a/testing/python/raises_group.py +++ b/testing/python/raises_group.py @@ -1145,6 +1145,8 @@ def test_xfail_raisesgroup(pytester: Pytester) -> None: pytester.makepyfile( """ import pytest + if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup @pytest.mark.xfail(raises=pytest.RaisesGroup(ValueError)) def test_foo() -> None: raise ExceptionGroup("foo", [ValueError()]) diff --git a/tox.ini b/tox.ini index 80fae513142..645a28f0126 100644 --- a/tox.ini +++ b/tox.ini @@ -107,8 +107,8 @@ allowlist_externals = git commands = # Retrieve possibly missing commits: - -git fetch --unshallow - -git fetch --tags + #-git fetch --unshallow + #-git fetch --tags sphinx-build \ -j auto \ From c011e9b6f3d1d177947190428df14ee54fbc3cf1 Mon Sep 17 00:00:00 2001 From: jakkdl <11260241+jakkdl@users.noreply.github.com> Date: Thu, 6 Feb 2025 17:12:56 +0100 Subject: [PATCH 5/5] fix test, fix references in docstrings --- doc/en/reference/reference.rst | 5 +++ src/_pytest/_raises_group.py | 62 ++++++++++++++++++---------------- testing/python/raises_group.py | 1 + tox.ini | 4 +-- 4 files changed, 41 insertions(+), 31 deletions(-) diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index d7cf09100b7..2f1c2206596 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -1020,12 +1020,17 @@ RaisesExc .. autoclass:: pytest.RaisesExc() :members: + .. autoattribute:: fail_reason + RaisesGroup ~~~~~~~~~~~ +**Tutorial**: :ref:`assert-matching-exception-groups` .. autoclass:: pytest.RaisesGroup() :members: + .. autoattribute:: fail_reason + TerminalReporter ~~~~~~~~~~~~~~~~ diff --git a/src/_pytest/_raises_group.py b/src/_pytest/_raises_group.py index db4e51fc211..92884409694 100644 --- a/src/_pytest/_raises_group.py +++ b/src/_pytest/_raises_group.py @@ -126,9 +126,9 @@ def __init__( @property def fail_reason(self) -> str | None: - """Set after a call to `matches` to give a human-readable reason for why the match failed. - When used as a context manager the string will be given as the text of an - `Failed`""" + """Set after a call to :meth:`matches` to give a human-readable reason for why the match failed. + When used as a context manager the string will be printed as the reason for the + test failing.""" return self._fail_reason def _check_check( @@ -170,17 +170,20 @@ def matches( self: AbstractRaises[BaseExcT_1], exc_val: BaseException ) -> TypeGuard[BaseExcT_1]: """Check if an exception matches the requirements of this AbstractRaises. - If it fails, `AbstractRaises.fail_reason` should be set. + If it fails, :meth:`AbstractRaises.fail_reason` should be set. """ @final class RaisesExc(AbstractRaises[BaseExcT_co_default]): """Helper class to be used together with RaisesGroups when you want to specify requirements on sub-exceptions. - Only specifying the type is redundant, and it's also unnecessary when the type is a - nested `RaisesGroup` since it supports the same arguments. - The type is checked with `isinstance`, and does not need to be an exact match. + + You don't need this if you only want to specify the type, since :class:`RaisesGroup` + accepts ``type[BaseException]``. + + The type is checked with :func:`isinstance`, and does not need to be an exact match. If that is wanted you can use the ``check`` parameter. + :meth:`RaisesExc.matches` can also be used standalone to check individual exceptions. Examples:: @@ -193,7 +196,7 @@ class RaisesExc(AbstractRaises[BaseExcT_co_default]): ... Tip: if you install ``hypothesis`` and import it in ``conftest.py`` you will get - readable ``repr``s of ``check`` callables in the output. + readable ``repr``'s of ``check`` callables in the output. """ # Trio bundled hypothesis monkeypatching, we will probably instead assume that @@ -241,8 +244,8 @@ def matches( self, exception: BaseException, ) -> TypeGuard[BaseExcT_co_default]: - """Check if an exception matches the requirements of this RaisesExc. - If it fails, `RaisesExc.fail_reason` will be set. + """Check if an exception matches the requirements of this :class:`RaisesExc`. + If it fails, :attr:`RaisesExc.fail_reason` will be set. Examples:: @@ -287,33 +290,34 @@ def _check_type(self, exception: BaseException) -> TypeGuard[BaseExcT_co_default @final class RaisesGroup(AbstractRaises[BaseExceptionGroup[BaseExcT_co]]): - """Contextmanager for checking for an expected `ExceptionGroup`. - This works similar to ``pytest.raises``, but allows for specifying the structure of an `ExceptionGroup`. - `ExceptionInfo.group_contains` also tries to handle exception groups, - but it is very bad at checking that you *didn't* get exceptions you didn't expect. + """Contextmanager for checking for an expected :exc:`ExceptionGroup`. + This works similar to :func:`pytest.raises`, but allows for specifying the structure of an :exc:`ExceptionGroup`. + :meth:`ExceptionInfo.group_contains` also tries to handle exception groups, + but it is very bad at checking that you *didn't* get unexpected exceptions. - The catching behaviour differs from :ref:`except* ` in multiple - different ways, being much stricter by default. + The catching behaviour differs from :ref:`except* `, being much + stricter about the structure by default. By using ``allow_unwrapped=True`` and ``flatten_subgroups=True`` you can match - ``except*`` fully when expecting a single exception. + :ref:`except* ` fully when expecting a single exception. #. All specified exceptions must be present, *and no others*. - * If you expect a variable number of exceptions you need to use ``pytest.raises(ExceptionGroup)`` and manually - check the contained exceptions. Consider making use of :func:`RaisesExc.matches`. + * If you expect a variable number of exceptions you need to use + :func:`pytest.raises(ExceptionGroup) ` and manually check + the contained exceptions. Consider making use of :meth:`RaisesExc.matches`. #. It will only catch exceptions wrapped in an exceptiongroup by default. - * With ``allow_unwrapped=True`` you can specify a single expected exception or `RaisesExc` and it will match - the exception even if it is not inside an `ExceptionGroup`. - If you expect one of several different exception types you need to use a `RaisesExc` object. + * With ``allow_unwrapped=True`` you can specify a single expected exception (or `RaisesExc`) and it will match + the exception even if it is not inside an :exc:`ExceptionGroup`. + If you expect one of several different exception types you need to use a :class:`RaisesExc` object. - #. By default it cares about the full structure with nested `ExceptionGroup`'s. You can specify nested - `ExceptionGroup`'s by passing `RaisesGroup` objects as expected exceptions. + #. By default it cares about the full structure with nested :exc:`ExceptionGroup`'s. You can specify nested + :exc:`ExceptionGroup`'s by passing :class:`RaisesGroup` objects as expected exceptions. - * With ``flatten_subgroups=True`` it will "flatten" the raised `ExceptionGroup`, - extracting all exceptions inside any nested :class:`ExceptionGroup`, before matching. + * With ``flatten_subgroups=True`` it will "flatten" the raised :exc:`ExceptionGroup`, + extracting all exceptions inside any nested :exc:`ExceptionGroup`, before matching. It does not care about the order of the exceptions, so ``RaisesGroups(ValueError, TypeError)`` @@ -346,7 +350,7 @@ class RaisesGroup(AbstractRaises[BaseExceptionGroup[BaseExcT_co]]): raise ValueError - `RaisesGroup.matches` can also be used directly to check a standalone exception group. + :meth:`RaisesGroup.matches` can also be used directly to check a standalone exception group. The matching algorithm is greedy, which means cases such as this may fail:: @@ -355,10 +359,10 @@ class RaisesGroup(AbstractRaises[BaseExceptionGroup[BaseExcT_co]]): raise ExceptionGroup("", (ValueError("hello"), ValueError("goodbye"))) even though it generally does not care about the order of the exceptions in the group. - To avoid the above you should specify the first ValueError with a RaisesExc as well. + To avoid the above you should specify the first :exc:`ValueError` with a :class:`RaisesExc` as well. Tip: if you install ``hypothesis`` and import it in ``conftest.py`` you will get - readable ``repr``s of ``check`` callables in the output. + readable ``repr``'s of ``check`` callables in the output. """ # allow_unwrapped=True requires: singular exception, exception not being diff --git a/testing/python/raises_group.py b/testing/python/raises_group.py index f3f3bcb5316..4ac6f8a7ced 100644 --- a/testing/python/raises_group.py +++ b/testing/python/raises_group.py @@ -1144,6 +1144,7 @@ def test_assert_matches() -> None: def test_xfail_raisesgroup(pytester: Pytester) -> None: pytester.makepyfile( """ + import sys import pytest if sys.version_info < (3, 11): from exceptiongroup import ExceptionGroup diff --git a/tox.ini b/tox.ini index 645a28f0126..80fae513142 100644 --- a/tox.ini +++ b/tox.ini @@ -107,8 +107,8 @@ allowlist_externals = git commands = # Retrieve possibly missing commits: - #-git fetch --unshallow - #-git fetch --tags + -git fetch --unshallow + -git fetch --tags sphinx-build \ -j auto \