diff --git a/AUTHORS b/AUTHORS
index f507fea75e9..f2b59b7c4db 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 00000000000..3c6748c3d3f
--- /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 42c5fa8bd4d..b73c8bbb3da 100644
--- a/src/_pytest/_code/code.py
+++ b/src/_pytest/_code/code.py
@@ -704,7 +704,12 @@ def match(self, regexp: Union[str, Pattern[str]]) -> "Literal[True]":
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 200b2b3aab6..a045da2206b 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 e5c030c4d66..90f81123e01 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 @@ def entry():
)
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 @@ def entry():
)
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 @@ def f():
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 @@ def f():
@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():
],
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