diff --git a/AUTHORS b/AUTHORS index f507fea75..f2b59b7c4 100644 --- a/AUTHORS +++ b/AUTHORS @@ -168,6 +168,7 @@ Ian Bicking Ian Lesperance Ilya Konstantinov Ionuț Turturică +Isaac Virshup Itxaso Aizpurua Iwan Briquemont Jaap Broekhuizen diff --git a/changelog/11227.improvement.rst b/changelog/11227.improvement.rst new file mode 100644 index 000000000..3c6748c3d --- /dev/null +++ b/changelog/11227.improvement.rst @@ -0,0 +1 @@ +Allow :func:`pytest.raises` ``match`` argument to match against `PEP-678 ` ``__notes__``. diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 42c5fa8bd..b73c8bbb3 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -704,7 +704,12 @@ class ExceptionInfo(Generic[E]): If it matches `True` is returned, otherwise an `AssertionError` is raised. """ __tracebackhide__ = True - value = str(self.value) + value = "\n".join( + [ + str(self.value), + *getattr(self.value, "__notes__", []), + ] + ) msg = f"Regex pattern did not match.\n Regex: {regexp!r}\n Input: {value!r}" if regexp == value: msg += "\n Did you mean to `re.escape()` the regex?" diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 200b2b3aa..a045da220 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -843,6 +843,14 @@ def raises( # noqa: F811 >>> 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:: diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index e5c030c4d..90f81123e 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1,15 +1,15 @@ +from __future__ import annotations + import importlib import io import operator import queue +import re import sys import textwrap from pathlib import Path from typing import Any -from typing import Dict -from typing import Tuple from typing import TYPE_CHECKING -from typing import Union import _pytest._code import pytest @@ -801,7 +801,7 @@ raise ValueError() ) excinfo = pytest.raises(ValueError, mod.entry) - styles: Tuple[_TracebackStyle, ...] = ("long", "short") + styles: tuple[_TracebackStyle, ...] = ("long", "short") for style in styles: p = FormattedExcinfo(style=style) reprtb = p.repr_traceback(excinfo) @@ -928,7 +928,7 @@ raise ValueError() ) excinfo = pytest.raises(ValueError, mod.entry) - styles: Tuple[_TracebackStyle, ...] = ("short", "long", "no") + styles: tuple[_TracebackStyle, ...] = ("short", "long", "no") for style in styles: for showlocals in (True, False): repr = excinfo.getrepr(style=style, showlocals=showlocals) @@ -1090,7 +1090,7 @@ raise ValueError() for funcargs in (True, False) ], ) - def test_format_excinfo(self, reproptions: Dict[str, Any]) -> None: + def test_format_excinfo(self, reproptions: dict[str, Any]) -> None: def bar(): assert False, "some error" @@ -1398,7 +1398,7 @@ raise ValueError() @pytest.mark.parametrize("encoding", [None, "utf8", "utf16"]) def test_repr_traceback_with_unicode(style, encoding): if encoding is None: - msg: Union[str, bytes] = "☹" + msg: str | bytes = "☹" else: msg = "☹".encode(encoding) try: @@ -1648,3 +1648,51 @@ def test_hidden_entries_of_chained_exceptions_are_not_shown(pytester: Pytester) ], consecutive=True, ) + + +def add_note(err: BaseException, msg: str) -> None: + """Adds a note to an exception inplace.""" + if sys.version_info < (3, 11): + err.__notes__ = getattr(err, "__notes__", []) + [msg] # type: ignore[attr-defined] + else: + err.add_note(msg) + + +@pytest.mark.parametrize( + "error,notes,match", + [ + (Exception("test"), [], "test"), + (AssertionError("foo"), ["bar"], "bar"), + (AssertionError("foo"), ["bar", "baz"], "bar"), + (AssertionError("foo"), ["bar", "baz"], "baz"), + (ValueError("foo"), ["bar", "baz"], re.compile(r"bar\nbaz", re.MULTILINE)), + (ValueError("foo"), ["bar", "baz"], re.compile(r"BAZ", re.IGNORECASE)), + ], +) +def test_check_error_notes_success( + error: Exception, notes: list[str], match: str +) -> None: + for note in notes: + add_note(error, note) + + with pytest.raises(Exception, match=match): + raise error + + +@pytest.mark.parametrize( + "error, notes, match", + [ + (Exception("test"), [], "foo"), + (AssertionError("foo"), ["bar"], "baz"), + (AssertionError("foo"), ["bar"], "foo\nbaz"), + ], +) +def test_check_error_notes_failure( + error: Exception, notes: list[str], match: str +) -> None: + for note in notes: + add_note(error, note) + + with pytest.raises(AssertionError): + with pytest.raises(type(error), match=match): + raise error