diff --git a/changelog/13192.feature.1.rst b/changelog/13192.feature.1.rst new file mode 100644 index 00000000000..71fb06f7d70 --- /dev/null +++ b/changelog/13192.feature.1.rst @@ -0,0 +1 @@ +:func:`pytest.raises` will now print a helpful string diff if matching fails and the match parameter has ``^`` and ``$`` and is otherwise escaped. diff --git a/changelog/13192.feature.2.rst b/changelog/13192.feature.2.rst new file mode 100644 index 00000000000..0ffa0e1496a --- /dev/null +++ b/changelog/13192.feature.2.rst @@ -0,0 +1 @@ +You can now pass :func:`with pytest.raises(check=fn): `, where ``fn`` is a function which takes a raised exception and returns a boolean. The ``raises`` fails if no exception was raised (as usual), passes if an exception is raised and ``fn`` returns ``True`` (as well as ``match`` and the type matching, if specified, which are checked before), and propagates the exception if ``fn`` returns ``False`` (which likely also fails the test). diff --git a/changelog/13192.feature.rst b/changelog/13192.feature.rst new file mode 100644 index 00000000000..97f31ce233c --- /dev/null +++ b/changelog/13192.feature.rst @@ -0,0 +1 @@ +:func:`pytest.raises` will now raise a warning when passing an empty string to ``match``, as this will match against any value. Use ``match="^$"`` if you want to check that an exception has no message. diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 81fed875ca0..7a49b1a9b0c 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -28,7 +28,7 @@ from _pytest.deprecated import check_ispytest from _pytest.deprecated import MARKED_FIXTURE from _pytest.outcomes import fail -from _pytest.raises_group import AbstractRaises +from _pytest.raises import AbstractRaises from _pytest.scope import _ScopeName from _pytest.warning_types import PytestUnknownMarkWarning diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 74ddd73005b..af078e25256 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -1,7 +1,6 @@ # mypy: allow-untyped-defs from __future__ import annotations -from collections.abc import Callable from collections.abc import Collection from collections.abc import Mapping from collections.abc import Sequence @@ -10,23 +9,14 @@ import math from numbers import Complex import pprint -import re import sys from typing import Any -from typing import overload from typing import TYPE_CHECKING -from typing import TypeVar - -from _pytest._code import ExceptionInfo -from _pytest.outcomes import fail -from _pytest.raises_group import RaisesExc if TYPE_CHECKING: from numpy import ndarray - E = TypeVar("E", bound=BaseException, default=BaseException) - def _compare_approx( full_object: object, @@ -778,230 +768,3 @@ def _as_numpy_array(obj: object) -> ndarray | None: elif hasattr(obj, "__array__") or hasattr("obj", "__array_interface__"): return np.asarray(obj) return None - - -# builtin pytest.raises helper -# FIXME: This should probably me moved to 'src/_pytest.raises_group.py' -# (and rename the file to 'raises.py') -# since it's much more closely tied to those than to the other stuff in this file. - - -@overload -def raises( - expected_exception: type[E] | tuple[type[E], ...], - *, - match: str | re.Pattern[str] | None = ..., - check: Callable[[E], bool] = ..., -) -> RaisesExc[E]: ... - - -@overload -def raises( - *, - match: str | re.Pattern[str], - # If exception_type is not provided, check() must do any typechecks itself. - check: Callable[[BaseException], bool] = ..., -) -> RaisesExc[BaseException]: ... - - -@overload -def raises(*, check: Callable[[BaseException], bool]) -> RaisesExc[BaseException]: ... - - -@overload -def raises( - expected_exception: type[E] | tuple[type[E], ...], - func: Callable[..., Any], - *args: Any, - **kwargs: Any, -) -> ExceptionInfo[E]: ... - - -def raises( - expected_exception: type[E] | tuple[type[E], ...] | None = None, - *args: Any, - **kwargs: Any, -) -> RaisesExc[BaseException] | ExceptionInfo[E]: - r"""Assert that a code block/function call raises an exception type, or one of its subclasses. - - :param expected_exception: - The expected exception type, or a tuple if one of multiple possible - exception types are expected. Note that subclasses of the passed exceptions - will also match. - - :kwparam str | re.Pattern[str] | None match: - If specified, a string containing a regular expression, - or a regular expression object, that is tested against the string - representation of the exception and its :pep:`678` `__notes__` - using :func:`re.search`. - - To match a literal string that may contain :ref:`special characters - `, the pattern can first be escaped with :func:`re.escape`. - - (This is only used when ``pytest.raises`` is used as a context manager, - and passed through to the function otherwise. - When using ``pytest.raises`` as a function, you can use: - ``pytest.raises(Exc, func, match="passed on").match("my pattern")``.) - - Use ``pytest.raises`` as a context manager, which will capture the exception of the given - type, or any of its subclasses:: - - >>> import pytest - >>> with pytest.raises(ZeroDivisionError): - ... 1/0 - - If the code block does not raise the expected exception (:class:`ZeroDivisionError` in the example - above), or no exception at all, the check will fail instead. - - You can also use the keyword argument ``match`` to assert that the - exception matches a text or regex:: - - >>> with pytest.raises(ValueError, match='must be 0 or None'): - ... raise ValueError("value must be 0 or None") - - >>> with pytest.raises(ValueError, match=r'must be \d+$'): - ... raise ValueError("value must be 42") - - The ``match`` argument searches the formatted exception string, which includes any - `PEP-678 `__ ``__notes__``: - - >>> with pytest.raises(ValueError, match=r"had a note added"): # doctest: +SKIP - ... e = ValueError("value must be 42") - ... e.add_note("had a note added") - ... raise e - - The context manager produces an :class:`ExceptionInfo` object which can be used to inspect the - details of the captured exception:: - - >>> with pytest.raises(ValueError) as exc_info: - ... raise ValueError("value must be 42") - >>> assert exc_info.type is ValueError - >>> assert exc_info.value.args[0] == "value must be 42" - - .. warning:: - - Given that ``pytest.raises`` matches subclasses, be wary of using it to match :class:`Exception` like this:: - - # Careful, this will catch ANY exception raised. - with pytest.raises(Exception): - some_function() - - Because :class:`Exception` is the base class of almost all exceptions, it is easy for this to hide - real bugs, where the user wrote this expecting a specific exception, but some other exception is being - raised due to a bug introduced during a refactoring. - - Avoid using ``pytest.raises`` to catch :class:`Exception` unless certain that you really want to catch - **any** exception raised. - - .. note:: - - When using ``pytest.raises`` as a context manager, it's worthwhile to - note that normal context manager rules apply and that the exception - raised *must* be the final line in the scope of the context manager. - Lines of code after that, within the scope of the context manager will - not be executed. For example:: - - >>> value = 15 - >>> with pytest.raises(ValueError) as exc_info: - ... if value > 10: - ... raise ValueError("value must be <= 10") - ... assert exc_info.type is ValueError # This will not execute. - - Instead, the following approach must be taken (note the difference in - scope):: - - >>> with pytest.raises(ValueError) as exc_info: - ... if value > 10: - ... raise ValueError("value must be <= 10") - ... - >>> 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` - it is possible to parametrize tests such that - some runs raise an exception and others do not. - - See :ref:`parametrizing_conditional_raising` for an example. - - .. seealso:: - - :ref:`assertraises` for more examples and detailed discussion. - - **Legacy form** - - It is possible to specify a callable by passing a to-be-called lambda:: - - >>> raises(ZeroDivisionError, lambda: 1/0) - - - or you can specify an arbitrary callable with arguments:: - - >>> def f(x): return 1/x - ... - >>> raises(ZeroDivisionError, f, 0) - - >>> raises(ZeroDivisionError, f, x=0) - - - The form above is fully supported but discouraged for new code because the - context manager form is regarded as more readable and less error-prone. - - .. note:: - Similar to caught exception objects in Python, explicitly clearing - local references to returned ``ExceptionInfo`` objects can - help the Python interpreter speed up its garbage collection. - - Clearing those references breaks a reference cycle - (``ExceptionInfo`` --> caught exception --> frame stack raising - the exception --> current frame stack --> local variables --> - ``ExceptionInfo``) which makes Python keep all objects referenced - from that cycle (including all local variables in the current - frame) alive until the next cyclic garbage collection run. - More detailed information can be found in the official Python - documentation for :ref:`the try statement `. - """ - __tracebackhide__ = True - - if not args: - if set(kwargs) - {"match", "check", "expected_exception"}: - msg = "Unexpected keyword arguments passed to pytest.raises: " - msg += ", ".join(sorted(kwargs)) - msg += "\nUse context-manager form instead?" - raise TypeError(msg) - - if expected_exception is None: - return RaisesExc(**kwargs) - return RaisesExc(expected_exception, **kwargs) - - if not expected_exception: - raise ValueError( - f"Expected an exception type or a tuple of exception types, but got `{expected_exception!r}`. " - f"Raising exceptions is already understood as failing the test, so you don't need " - f"any special code to say 'this should never raise an exception'." - ) - func = args[0] - if not callable(func): - raise TypeError(f"{func!r} object (type: {type(func)}) must be callable") - with RaisesExc(expected_exception) as excinfo: - func(*args[1:], **kwargs) - try: - return excinfo - finally: - del excinfo - - -# note: RaisesExc/RaisesGroup uses fail() internally, so this alias -# indicates (to [internal] plugins?) that `pytest.raises` will -# raise `_pytest.outcomes.Failed`, where -# `outcomes.Failed is outcomes.fail.Exception is raises.Exception` -# note: this is *not* the same as `_pytest.main.Failed` -# note: mypy does not recognize this attribute, and it's not possible -# to use a protocol/decorator like the others in outcomes due to -# https://github.com/python/mypy/issues/18715 -raises.Exception = fail.Exception # type: ignore[attr-defined] diff --git a/src/_pytest/raises_group.py b/src/_pytest/raises.py similarity index 75% rename from src/_pytest/raises_group.py rename to src/_pytest/raises.py index 010b8b9f629..2eba53bf10b 100644 --- a/src/_pytest/raises_group.py +++ b/src/_pytest/raises.py @@ -43,6 +43,9 @@ default=BaseException, covariant=True, ) + + # Use short name because it shows up in docs. + E = TypeVar("E", bound=BaseException, default=BaseException) else: from typing import TypeVar @@ -66,6 +69,248 @@ _REGEX_NO_FLAGS = re.compile(r"").flags +# pytest.raises helper +@overload +def raises( + expected_exception: type[E] | tuple[type[E], ...], + *, + match: str | re.Pattern[str] | None = ..., + check: Callable[[E], bool] = ..., +) -> RaisesExc[E]: ... + + +@overload +def raises( + *, + match: str | re.Pattern[str], + # If exception_type is not provided, check() must do any typechecks itself. + check: Callable[[BaseException], bool] = ..., +) -> RaisesExc[BaseException]: ... + + +@overload +def raises(*, check: Callable[[BaseException], bool]) -> RaisesExc[BaseException]: ... + + +@overload +def raises( + expected_exception: type[E] | tuple[type[E], ...], + func: Callable[..., Any], + *args: Any, + **kwargs: Any, +) -> ExceptionInfo[E]: ... + + +def raises( + expected_exception: type[E] | tuple[type[E], ...] | None = None, + *args: Any, + **kwargs: Any, +) -> RaisesExc[BaseException] | ExceptionInfo[E]: + r"""Assert that a code block/function call raises an exception type, or one of its subclasses. + + :param expected_exception: + The expected exception type, or a tuple if one of multiple possible + exception types are expected. Note that subclasses of the passed exceptions + will also match. + + This is not a required parameter, you may opt to only use ``match`` and/or + ``check`` for verifying the raised exception. + + :kwparam str | re.Pattern[str] | None match: + If specified, a string containing a regular expression, + or a regular expression object, that is tested against the string + representation of the exception and its :pep:`678` `__notes__` + using :func:`re.search`. + + To match a literal string that may contain :ref:`special characters + `, the pattern can first be escaped with :func:`re.escape`. + + (This is only used when ``pytest.raises`` is used as a context manager, + and passed through to the function otherwise. + When using ``pytest.raises`` as a function, you can use: + ``pytest.raises(Exc, func, match="passed on").match("my pattern")``.) + + :kwparam Callable[[BaseException], bool] check: + + .. versionadded:: 8.4 + + If specified, a callable that will be called with the exception as a parameter + after checking the type and the match regex if specified. + If it returns ``True`` it will be considered a match, if not it will + be considered a failed match. + + + Use ``pytest.raises`` as a context manager, which will capture the exception of the given + type, or any of its subclasses:: + + >>> import pytest + >>> with pytest.raises(ZeroDivisionError): + ... 1/0 + + If the code block does not raise the expected exception (:class:`ZeroDivisionError` in the example + above), or no exception at all, the check will fail instead. + + You can also use the keyword argument ``match`` to assert that the + exception matches a text or regex:: + + >>> with pytest.raises(ValueError, match='must be 0 or None'): + ... raise ValueError("value must be 0 or None") + + >>> with pytest.raises(ValueError, match=r'must be \d+$'): + ... raise ValueError("value must be 42") + + The ``match`` argument searches the formatted exception string, which includes any + `PEP-678 `__ ``__notes__``: + + >>> with pytest.raises(ValueError, match=r"had a note added"): # doctest: +SKIP + ... e = ValueError("value must be 42") + ... e.add_note("had a note added") + ... raise e + + The ``check`` argument, if provided, must return True when passed the raised exception + for the match to be successful, otherwise an :exc:`AssertionError` is raised. + + >>> import errno + >>> with pytest.raises(OSError, check=lambda e: e.errno == errno.EACCES): + ... raise OSError(errno.EACCES, "no permission to view") + + The context manager produces an :class:`ExceptionInfo` object which can be used to inspect the + details of the captured exception:: + + >>> with pytest.raises(ValueError) as exc_info: + ... raise ValueError("value must be 42") + >>> assert exc_info.type is ValueError + >>> assert exc_info.value.args[0] == "value must be 42" + + .. warning:: + + Given that ``pytest.raises`` matches subclasses, be wary of using it to match :class:`Exception` like this:: + + # Careful, this will catch ANY exception raised. + with pytest.raises(Exception): + some_function() + + Because :class:`Exception` is the base class of almost all exceptions, it is easy for this to hide + real bugs, where the user wrote this expecting a specific exception, but some other exception is being + raised due to a bug introduced during a refactoring. + + Avoid using ``pytest.raises`` to catch :class:`Exception` unless certain that you really want to catch + **any** exception raised. + + .. note:: + + When using ``pytest.raises`` as a context manager, it's worthwhile to + note that normal context manager rules apply and that the exception + raised *must* be the final line in the scope of the context manager. + Lines of code after that, within the scope of the context manager will + not be executed. For example:: + + >>> value = 15 + >>> with pytest.raises(ValueError) as exc_info: + ... if value > 10: + ... raise ValueError("value must be <= 10") + ... assert exc_info.type is ValueError # This will not execute. + + Instead, the following approach must be taken (note the difference in + scope):: + + >>> with pytest.raises(ValueError) as exc_info: + ... if value > 10: + ... raise ValueError("value must be <= 10") + ... + >>> 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` + it is possible to parametrize tests such that + some runs raise an exception and others do not. + + See :ref:`parametrizing_conditional_raising` for an example. + + .. seealso:: + + :ref:`assertraises` for more examples and detailed discussion. + + **Legacy form** + + It is possible to specify a callable by passing a to-be-called lambda:: + + >>> raises(ZeroDivisionError, lambda: 1/0) + + + or you can specify an arbitrary callable with arguments:: + + >>> def f(x): return 1/x + ... + >>> raises(ZeroDivisionError, f, 0) + + >>> raises(ZeroDivisionError, f, x=0) + + + The form above is fully supported but discouraged for new code because the + context manager form is regarded as more readable and less error-prone. + + .. note:: + Similar to caught exception objects in Python, explicitly clearing + local references to returned ``ExceptionInfo`` objects can + help the Python interpreter speed up its garbage collection. + + Clearing those references breaks a reference cycle + (``ExceptionInfo`` --> caught exception --> frame stack raising + the exception --> current frame stack --> local variables --> + ``ExceptionInfo``) which makes Python keep all objects referenced + from that cycle (including all local variables in the current + frame) alive until the next cyclic garbage collection run. + More detailed information can be found in the official Python + documentation for :ref:`the try statement `. + """ + __tracebackhide__ = True + + if not args: + if set(kwargs) - {"match", "check", "expected_exception"}: + msg = "Unexpected keyword arguments passed to pytest.raises: " + msg += ", ".join(sorted(kwargs)) + msg += "\nUse context-manager form instead?" + raise TypeError(msg) + + if expected_exception is None: + return RaisesExc(**kwargs) + return RaisesExc(expected_exception, **kwargs) + + if not expected_exception: + raise ValueError( + f"Expected an exception type or a tuple of exception types, but got `{expected_exception!r}`. " + f"Raising exceptions is already understood as failing the test, so you don't need " + f"any special code to say 'this should never raise an exception'." + ) + func = args[0] + if not callable(func): + raise TypeError(f"{func!r} object (type: {type(func)}) must be callable") + with RaisesExc(expected_exception) as excinfo: + func(*args[1:], **kwargs) + try: + return excinfo + finally: + del excinfo + + +# note: RaisesExc/RaisesGroup uses fail() internally, so this alias +# indicates (to [internal] plugins?) that `pytest.raises` will +# raise `_pytest.outcomes.Failed`, where +# `outcomes.Failed is outcomes.fail.Exception is raises.Exception` +# note: this is *not* the same as `_pytest.main.Failed` +# note: mypy does not recognize this attribute, and it's not possible +# to use a protocol/decorator like the others in outcomes due to +# https://github.com/python/mypy/issues/18715 +raises.Exception = fail.Exception # type: ignore[attr-defined] + + 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 @@ -139,6 +384,7 @@ class AbstractRaises(ABC, Generic[BaseExcT_co]): def __init__( self, + *, match: str | Pattern[str] | None, check: Callable[[BaseExcT_co], bool] | None, ) -> None: @@ -217,9 +463,13 @@ def _parse_exc( f"generic argument specific nested exceptions has to be checked " f"with `RaisesGroup`." ) - not_a = exc.__name__ if isinstance(exc, type) else type(exc).__name__ - msg = f"expected exception must be {expected}, not {not_a}" - raise TypeError(msg) + # unclear if the Type/ValueError distinction is even helpful here + msg = f"expected exception must be {expected}, not " + if isinstance(exc, type): + raise ValueError(msg + f"{exc.__name__!r}") + if isinstance(exc, BaseException): + raise TypeError(msg + f"an exception instance ({type(exc).__name__})") + raise TypeError(msg + repr(type(exc).__name__)) @property def fail_reason(self) -> str | None: @@ -294,13 +544,29 @@ class RaisesExc(AbstractRaises[BaseExcT_co_default]): """ .. versionadded:: 8.4 - Helper class to be used together with RaisesGroup when you want to specify requirements on sub-exceptions. + + This is the class constructed when calling :func:`pytest.raises`, but may be used + directly as a helper class with :class:`RaisesGroup` when you want to specify + requirements on sub-exceptions. 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. + :param type[BaseException] | tuple[type[BaseException]] | None expected_exception: + The expected type, or one of several possible types. + May be ``None`` in order to only make use of ``match`` and/or ``check`` + + 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. + + :kwparam str | Pattern[str] match + A regex to match. + + :kwparam Callable[[BaseException], bool] check: + If specified, a callable that will be called with the exception as a parameter + after checking the type and the match regex if specified. + If it returns ``True`` it will be considered a match, if not it will + be considered a failed match. :meth:`RaisesExc.matches` can also be used standalone to check individual exceptions. @@ -326,6 +592,8 @@ def __init__( expected_exception: ( type[BaseExcT_co_default] | tuple[type[BaseExcT_co_default], ...] ), + /, + *, match: str | Pattern[str] | None = ..., check: Callable[[BaseExcT_co_default], bool] | None = ..., ) -> None: ... @@ -333,6 +601,7 @@ def __init__( @overload def __init__( self: RaisesExc[BaseException], # Give E a value. + /, *, match: str | Pattern[str] | None, # If exception_type is not provided, check() must do any typechecks itself. @@ -340,17 +609,19 @@ def __init__( ) -> None: ... @overload - def __init__(self, *, check: Callable[[BaseException], bool]) -> None: ... + def __init__(self, /, *, check: Callable[[BaseException], bool]) -> None: ... def __init__( self, expected_exception: ( type[BaseExcT_co_default] | tuple[type[BaseExcT_co_default], ...] | None ) = None, + /, + *, match: str | Pattern[str] | None = None, check: Callable[[BaseExcT_co_default], bool] | None = None, ): - super().__init__(match, check) + super().__init__(match=match, check=check) if isinstance(expected_exception, tuple): expected_exceptions = expected_exception elif expected_exception is None: @@ -366,6 +637,8 @@ def __init__( for e in expected_exceptions ) + self._just_propagate = False + def matches( self, exception: BaseException | None, @@ -388,10 +661,12 @@ def matches( assert re.search("foo", str(excinfo.value.__cause__) """ + self._just_propagate = False if exception is None: self._fail_reason = "exception is None" return False if not self._check_type(exception): + self._just_propagate = True return False if not self._check_match(exception): @@ -441,6 +716,8 @@ def __exit__( ) if not self.matches(exc_val): + if self._just_propagate: + return False raise AssertionError(self._fail_reason) # Cast to narrow the exception type now that it's verified.... @@ -463,49 +740,77 @@ class RaisesGroup(AbstractRaises[BaseExceptionGroup[BaseExcT_co]]): :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* `, being much stricter about the structure by default. By using ``allow_unwrapped=True`` and ``flatten_subgroups=True`` you can match :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 - :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 :class:`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 :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 :exc:`ExceptionGroup`, - extracting all exceptions inside any nested :exc:`ExceptionGroup`, before matching. - - It does not care about the order of the exceptions, so - ``RaisesGroup(ValueError, TypeError)`` - is equivalent to - ``RaisesGroup(TypeError, ValueError)``. + :param args: + Any number of exception types, :class:`RaisesGroup` or :class:`RaisesExc` + to specify the exceptions contained in this exception. + All specified exceptions must be present in the raised group, *and no others*. + + 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 does not care about the order of the exceptions, so + ``RaisesGroup(ValueError, TypeError)`` + is equivalent to + ``RaisesGroup(TypeError, ValueError)``. + :kwparam str | re.Pattern[str] | None match: + If specified, a string containing a regular expression, + or a regular expression object, that is tested against the string + representation of the exception group and its :pep:`678` `__notes__` + using :func:`re.search`. + + To match a literal string that may contain :ref:`special characters + `, the pattern can first be escaped with :func:`re.escape`. + + Note that " (5 subgroups)" will be stripped from the ``repr`` before matching. + :kwparam Callable[[E], bool] check: + If specified, a callable that will be called with the group as a parameter + after successfully matching the expected exceptions. If it returns ``True`` + it will be considered a match, if not it will be considered a failed match. + :kwparam bool allow_unwrapped: + If expecting a single exception or :class:`RaisesExc` it will match even + if the exception is not inside an exceptiongroup. + + Using this together with ``match``, ``check`` or expecting multiple exceptions + will raise an error. + :kwparam bool flatten_subgroups: + "flatten" any groups inside the raised exception group, extracting all exceptions + inside any nested groups, before matching. Without this it expects you to + fully specify the nesting structure by passing :class:`RaisesGroup` as expected + parameter. Examples:: with RaisesGroup(ValueError): raise ExceptionGroup("", (ValueError(),)) + # match with RaisesGroup( - ValueError, ValueError, RaisesExc(TypeError, match="expected int") + ValueError, + ValueError, + RaisesExc(TypeError, match="^expected int$"), + match="^my group$", ): - ... + raise ExceptionGroup( + "my group", + [ + ValueError(), + TypeError("expected int"), + ValueError(), + ], + ) + # check with RaisesGroup( KeyboardInterrupt, - match="hello", - check=lambda x: type(x) is BaseExceptionGroup, + match="^hello$", + check=lambda x: isinstance(x.__cause__, ValueError), ): - ... + raise BaseExceptionGroup("hello", [KeyboardInterrupt()]) from ValueError + # nested groups with RaisesGroup(RaisesGroup(ValueError)): raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)) @@ -544,6 +849,7 @@ class RaisesGroup(AbstractRaises[BaseExceptionGroup[BaseExcT_co]]): def __init__( self, expected_exception: type[BaseExcT_co] | RaisesExc[BaseExcT_co], + /, *, allow_unwrapped: Literal[True], flatten_subgroups: bool = False, @@ -554,6 +860,7 @@ def __init__( def __init__( self, expected_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, @@ -569,6 +876,7 @@ def __init__( def __init__( self: RaisesGroup[ExcT_1], expected_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, @@ -578,6 +886,7 @@ def __init__( def __init__( self: RaisesGroup[ExceptionGroup[ExcT_2]], expected_exception: RaisesGroup[ExcT_2], + /, *other_exceptions: RaisesGroup[ExcT_2], match: str | Pattern[str] | None = None, check: Callable[[ExceptionGroup[ExceptionGroup[ExcT_2]]], bool] | None = None, @@ -587,6 +896,7 @@ def __init__( def __init__( self: RaisesGroup[ExcT_1 | ExceptionGroup[ExcT_2]], expected_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: ( @@ -599,6 +909,7 @@ def __init__( def __init__( self: RaisesGroup[BaseExcT_1], expected_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, @@ -608,6 +919,7 @@ def __init__( def __init__( self: RaisesGroup[BaseExceptionGroup[BaseExcT_2]], expected_exception: RaisesGroup[BaseExcT_2], + /, *other_exceptions: RaisesGroup[BaseExcT_2], match: str | Pattern[str] | None = None, check: ( @@ -621,6 +933,7 @@ def __init__( expected_exception: type[BaseExcT_1] | RaisesExc[BaseExcT_1] | RaisesGroup[BaseExcT_2], + /, *other_exceptions: type[BaseExcT_1] | RaisesExc[BaseExcT_1] | RaisesGroup[BaseExcT_2], @@ -639,6 +952,7 @@ def __init__( expected_exception: type[BaseExcT_1] | RaisesExc[BaseExcT_1] | RaisesGroup[BaseExcT_2], + /, *other_exceptions: type[BaseExcT_1] | RaisesExc[BaseExcT_1] | RaisesGroup[BaseExcT_2], @@ -660,7 +974,7 @@ def __init__( "], bool]", check, ) - super().__init__(match, check) + super().__init__(match=match, check=check) self.allow_unwrapped = allow_unwrapped self.flatten_subgroups: bool = flatten_subgroups self.is_baseexception = False @@ -724,8 +1038,14 @@ def _parse_excgroup( self.is_baseexception |= exc.is_baseexception exc._nested = True return exc + elif isinstance(exc, tuple): + raise TypeError( + f"expected exception must be {expected}, not {type(exc).__name__!r}.\n" + "RaisesGroup does not support tuples of exception types when expecting one of " + "several possible exception types like RaisesExc.\n" + "If you meant to expect a group with multiple exceptions, list them as separate arguments." + ) else: - # validate_exc transforms GenericAlias ExceptionGroup[Exception] -> type[ExceptionGroup] return super()._parse_exc(exc, expected) @overload diff --git a/src/_pytest/skipping.py b/src/_pytest/skipping.py index 20efefb84df..96281faec97 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -20,7 +20,7 @@ from _pytest.outcomes import fail from _pytest.outcomes import skip from _pytest.outcomes import xfail -from _pytest.raises_group import AbstractRaises +from _pytest.raises import AbstractRaises from _pytest.reports import BaseReport from _pytest.reports import TestReport from _pytest.runner import CallInfo diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index e5098fe6e61..e36d3e704c1 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -60,9 +60,9 @@ from _pytest.python import Module from _pytest.python import Package from _pytest.python_api import approx -from _pytest.python_api import raises -from _pytest.raises_group import RaisesExc -from _pytest.raises_group import RaisesGroup +from _pytest.raises import raises +from _pytest.raises import RaisesExc +from _pytest.raises import RaisesGroup from _pytest.recwarn import deprecated_call from _pytest.recwarn import WarningsRecorder from _pytest.recwarn import warns diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 438a5259f20..89088576980 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -481,9 +481,8 @@ def test_raises_exception_escapes_generic_group() -> None: try: with pytest.raises(ExceptionGroup[Exception]): raise ValueError("my value error") - except AssertionError as e: - assert str(e) == "`ValueError()` is not an instance of `ExceptionGroup`" - assert str(e.__context__) == "my value error" + except ValueError as e: + assert str(e) == "my value error" else: pytest.fail("Expected ValueError to be raised") diff --git a/testing/python/raises.py b/testing/python/raises.py index 333e273db6a..3da260d1837 100644 --- a/testing/python/raises.py +++ b/testing/python/raises.py @@ -108,7 +108,7 @@ def test_noraise(): int() def test_raise_wrong_exception_passes_by(): - with pytest.raises(AssertionError): + with pytest.raises(ZeroDivisionError): with pytest.raises(ValueError): 1/0 """ @@ -310,8 +310,8 @@ def test_raises_match_wrong_type(self): really relevant if we got a different exception. """ with pytest.raises( - AssertionError, - match=wrap_escape("`ValueError()` is not an instance of `IndexError`"), + ValueError, + match=wrap_escape("invalid literal for int() with base 10: 'asdf'"), ): with pytest.raises(IndexError, match="nomatch"): int("asdf") @@ -361,23 +361,35 @@ def test_raises_context_manager_with_kwargs(self): pass def test_expected_exception_is_not_a_baseexception(self) -> None: - with pytest.raises(TypeError) as excinfo: + with pytest.raises( + TypeError, + match=wrap_escape( + "expected exception must be a BaseException type, not 'str'" + ), + ): with pytest.raises("hello"): # type: ignore[call-overload] pass # pragma: no cover - assert "must be a BaseException type, not str" in str(excinfo.value) class NotAnException: pass - with pytest.raises(TypeError) as excinfo: + with pytest.raises( + ValueError, + match=wrap_escape( + "expected exception must be a BaseException type, not 'NotAnException'" + ), + ): with pytest.raises(NotAnException): # type: ignore[type-var] pass # pragma: no cover - assert "must be a BaseException type, not NotAnException" in str(excinfo.value) - with pytest.raises(TypeError) as excinfo: + with pytest.raises( + TypeError, + match=wrap_escape( + "expected exception must be a BaseException type, not 'str'" + ), + ): with pytest.raises(("hello", NotAnException)): # type: ignore[arg-type] pass # pragma: no cover - assert "must be a BaseException type, not str" in str(excinfo.value) def test_issue_11872(self) -> None: """Regression test for #11872. diff --git a/testing/python/raises_group.py b/testing/python/raises_group.py index 0dc2a58a1da..04979c32e98 100644 --- a/testing/python/raises_group.py +++ b/testing/python/raises_group.py @@ -10,9 +10,9 @@ from _pytest._code import ExceptionInfo from _pytest.outcomes import Failed from _pytest.pytester import Pytester -from _pytest.raises_group import RaisesExc -from _pytest.raises_group import RaisesGroup -from _pytest.raises_group import repr_callable +from _pytest.raises import RaisesExc +from _pytest.raises import RaisesGroup +from _pytest.raises import repr_callable import pytest @@ -34,11 +34,21 @@ def fails_raises_group(msg: str, add_prefix: bool = True) -> RaisesExc[Failed]: def test_raises_group() -> None: + with pytest.raises( + TypeError, + match=wrap_escape("expected exception must be a BaseException type, not 'int'"), + ): + RaisesExc(5) # type: ignore[call-overload] + with pytest.raises( + ValueError, + match=wrap_escape("expected exception must be a BaseException type, not 'int'"), + ): + RaisesExc(int) # type: ignore[type-var] with pytest.raises( TypeError, # TODO: bad sentence structure match=wrap_escape( - "expected exception must be a BaseException type, RaisesExc, or RaisesGroup, not ValueError", + "expected exception must be a BaseException type, RaisesExc, or RaisesGroup, not an exception instance (ValueError)", ), ): RaisesGroup(ValueError()) # type: ignore[call-overload] @@ -499,7 +509,7 @@ def check_message( # RaisesExc check_message( "ExceptionGroup(RaisesExc(ValueError, match='my_str'))", - RaisesGroup(RaisesExc(ValueError, "my_str")), + RaisesGroup(RaisesExc(ValueError, match="my_str")), ) check_message( "ExceptionGroup(RaisesExc(match='my_str'))", @@ -1067,9 +1077,9 @@ def test_raisesexc() -> None: ): RaisesExc() # type: ignore[call-overload] with pytest.raises( - TypeError, + ValueError, match=wrap_escape( - "expected exception must be a BaseException type, not object" + "expected exception must be a BaseException type, not 'object'" ), ): RaisesExc(object) # type: ignore[type-var] @@ -1110,14 +1120,14 @@ def test_raisesexc() -> None: # currently RaisesGroup says "Raised exception did not match" but RaisesExc doesn't... with pytest.raises( AssertionError, - match=wrap_escape("`TypeError()` is not an instance of `ValueError`"), + match=wrap_escape("Regex pattern did not match.\n Regex: 'foo'\n Input: 'bar'"), ): - with RaisesExc(ValueError): - raise TypeError + with RaisesExc(TypeError, match="foo"): + raise TypeError("bar") def test_raisesexc_match() -> None: - with RaisesGroup(RaisesExc(ValueError, "foo")): + with RaisesGroup(RaisesExc(ValueError, match="foo")): raise ExceptionGroup("", (ValueError("foo"),)) with ( fails_raises_group( @@ -1125,7 +1135,7 @@ def test_raisesexc_match() -> None: " Regex: 'foo'\n" " Input: 'bar'" ), - RaisesGroup(RaisesExc(ValueError, "foo")), + RaisesGroup(RaisesExc(ValueError, match="foo")), ): raise ExceptionGroup("", (ValueError("bar"),)) @@ -1328,6 +1338,11 @@ def test_tuples() -> None: # RaisesGroup(ValueError, TypeError), and the former might be interpreted as the latter. with pytest.raises( TypeError, - match="expected exception must be a BaseException type, RaisesExc, or RaisesGroup, not tuple", + match=wrap_escape( + "expected exception must be a BaseException type, RaisesExc, or RaisesGroup, not 'tuple'.\n" + "RaisesGroup does not support tuples of exception types when expecting one of " + "several possible exception types like RaisesExc.\n" + "If you meant to expect a group with multiple exceptions, list them as separate arguments." + ), ): RaisesGroup((ValueError, IndexError)) # type: ignore[call-overload] diff --git a/testing/test_warning_types.py b/testing/test_warning_types.py index 19fe0f8a272..7cbc4703c26 100644 --- a/testing/test_warning_types.py +++ b/testing/test_warning_types.py @@ -43,7 +43,7 @@ def test(): @pytest.mark.filterwarnings("error") def test_warn_explicit_for_annotates_errors_with_location(): - with pytest.raises(Warning, match="(?m)test\n at .*python_api.py:\\d+"): + with pytest.raises(Warning, match="(?m)test\n at .*raises.py:\\d+"): warning_types.warn_explicit_for( pytest.raises, # type: ignore[arg-type] warning_types.PytestWarning("test"), diff --git a/testing/typing_raises_group.py b/testing/typing_raises_group.py index f27943e3a58..c7dd16991ac 100644 --- a/testing/typing_raises_group.py +++ b/testing/typing_raises_group.py @@ -70,9 +70,8 @@ def check_exc(exc: BaseException) -> bool: # At least 1 arg must be provided. RaisesExc() # type: ignore RaisesExc(ValueError) - RaisesExc(ValueError, "regex") - RaisesExc(ValueError, "regex", check_exc) - RaisesExc(expected_exception=ValueError) + RaisesExc(ValueError, match="regex") + RaisesExc(ValueError, match="regex", check=check_exc) RaisesExc(match="regex") RaisesExc(check=check_exc) RaisesExc(ValueError, match="regex") @@ -87,6 +86,11 @@ def check_filenotfound(exc: FileNotFoundError) -> bool: RaisesExc(check=check_filenotfound) # type: ignore RaisesExc(FileNotFoundError, match="regex", check=check_filenotfound) + # exceptions are pos-only + RaisesExc(expected_exception=ValueError) # type: ignore + # match and check are kw-only + RaisesExc(ValueError, "regex") # type: ignore + def raisesgroup_check_type_narrowing() -> None: """Check type narrowing on the `check` argument to `RaisesGroup`.