Skip to content

Have pytest.raises match against exception __notes__ #11227

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jul 18, 2023
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ Ian Bicking
Ian Lesperance
Ilya Konstantinov
Ionuț Turturică
Isaac Virshup
Itxaso Aizpurua
Iwan Briquemont
Jaap Broekhuizen
Expand Down
1 change: 1 addition & 0 deletions changelog/11227.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Allow :func:`pytest.raises` ``match`` argument to match against `PEP-678 <https://peps.python.org/pep-0678/>` ``__notes__``.
7 changes: 6 additions & 1 deletion src/_pytest/_code/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might want to work with @agronholm to ensure note helpers in the backport as well as shared formatting as follow up

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests here don't seem to involve exception groups though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@agronholm my impression is that exception notes are closely related and my understanding is that integration with the backport may be helpful as per agronholm/exceptiongroup#31 and since formatting may also be affected my impression is that the most benefit for library users would come from having one integration place instead of multiple

Copy link
Contributor Author

@ivirshup ivirshup Jul 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

About formatting: I don't think there is a function in traceback that provides the exact rendering pytest expects. I think all the methods that traceback provides will include the exception type.

[
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?"
Expand Down
8 changes: 8 additions & 0 deletions src/_pytest/python_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://peps.python.org/pep-0678/>` ``__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::

Expand Down
62 changes: 55 additions & 7 deletions testing/code/test_excinfo.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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