From 0dcb4b9fec1d6eec84425f6c7410fd1ada81641b Mon Sep 17 00:00:00 2001 From: Isaac Virshup Date: Mon, 17 Jul 2023 18:25:02 +0200 Subject: [PATCH 01/10] Proof of concept --- src/_pytest/_code/code.py | 7 ++++++- testing/code/test_excinfo.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) 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/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index e5c030c4d66..079e270d422 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1648,3 +1648,38 @@ def test(): ], consecutive=True, ) + + +@pytest.mark.skip("sys.version_info < (3,11)") +@pytest.mark.parametrize( + "error,notes,match", + [ + (Exception("test"), [], "test"), + (AssertionError("foo"), ["bar"], "bar"), + (AssertionError("foo"), ["bar", "baz"], "bar"), + (AssertionError("foo"), ["bar", "baz"], "baz"), + ], +) +def test_check_error_notes_success(error, notes, match): + for note in notes: + error.add_note(note) + + with pytest.raises(Exception, match=match): + raise error + + +@pytest.mark.skip("sys.version_info < (3,11)") +@pytest.mark.parametrize( + "error, notes, match", + [ + (Exception("test"), [], "foo"), + (AssertionError("foo"), ["bar"], "baz"), + ], +) +def test_check_error_notes_failure(error, notes, match): + for note in notes: + error.add_note(note) + + with pytest.raises(AssertionError): + with pytest.raises(type(error), match=match): + raise error From a74243a02814e2c0aa8bb4ed3171cd55715ea90a Mon Sep 17 00:00:00 2001 From: Isaac Virshup Date: Mon, 17 Jul 2023 19:51:15 +0200 Subject: [PATCH 02/10] Suppport older versions of python --- testing/code/test_excinfo.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 079e270d422..f58dd9e23b2 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1650,7 +1650,14 @@ def test(): ) -@pytest.mark.skip("sys.version_info < (3,11)") +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", [ @@ -1662,23 +1669,23 @@ def test(): ) def test_check_error_notes_success(error, notes, match): for note in notes: - error.add_note(note) + add_note(error, note) with pytest.raises(Exception, match=match): raise error -@pytest.mark.skip("sys.version_info < (3,11)") @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, notes, match): for note in notes: - error.add_note(note) + add_note(error, note) with pytest.raises(AssertionError): with pytest.raises(type(error), match=match): From 6e510636c129a63645a165ba0917b550d721a1e9 Mon Sep 17 00:00:00 2001 From: Isaac Virshup Date: Mon, 17 Jul 2023 20:06:27 +0200 Subject: [PATCH 03/10] Docs --- src/_pytest/python_api.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 200b2b3aab6..ac0ec5e5ebd 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -843,6 +843,13 @@ 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 ``__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:: From 3c0ea827eb42ba1f89797f22f7043e64b82a2548 Mon Sep 17 00:00:00 2001 From: Isaac Virshup Date: Tue, 18 Jul 2023 11:32:15 +0200 Subject: [PATCH 04/10] Add link to PEP-678 --- src/_pytest/python_api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index ac0ec5e5ebd..a045da2206b 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -843,7 +843,8 @@ 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 ``__notes__``: + 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") From c0245c7613f23d3e52e8b58fdf292432b19ba0bd Mon Sep 17 00:00:00 2001 From: Isaac Virshup Date: Tue, 18 Jul 2023 11:35:07 +0200 Subject: [PATCH 05/10] type tests (apply suggestions from review) Co-authored-by: Bruno Oliveira --- testing/code/test_excinfo.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index f58dd9e23b2..6663f2d73e4 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1667,7 +1667,7 @@ def add_note(err: BaseException, msg: str) -> None: (AssertionError("foo"), ["bar", "baz"], "baz"), ], ) -def test_check_error_notes_success(error, notes, match): +def test_check_error_notes_success(error: Exception, notes: list[str], match: str) -> None: for note in notes: add_note(error, note) @@ -1683,7 +1683,7 @@ def test_check_error_notes_success(error, notes, match): (AssertionError("foo"), ["bar"], "foo\nbaz"), ], ) -def test_check_error_notes_failure(error, notes, match): +def test_check_error_notes_failure(error: Exception, notes: list[str], match: str) -> None: for note in notes: add_note(error, note) From 4511468e9c8cf135ae1af9746cea1cc78b2edf65 Mon Sep 17 00:00:00 2001 From: Isaac Virshup Date: Tue, 18 Jul 2023 11:36:21 +0200 Subject: [PATCH 06/10] Add self to authors --- AUTHORS | 1 + 1 file changed, 1 insertion(+) 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 From 25163925821e7f45fb3a5ecda6dbbb79a5616255 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 18 Jul 2023 09:37:48 +0000 Subject: [PATCH 07/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/code/test_excinfo.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 6663f2d73e4..bd72c9ccf95 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1667,7 +1667,9 @@ def add_note(err: BaseException, msg: str) -> None: (AssertionError("foo"), ["bar", "baz"], "baz"), ], ) -def test_check_error_notes_success(error: Exception, notes: list[str], match: str) -> None: +def test_check_error_notes_success( + error: Exception, notes: list[str], match: str +) -> None: for note in notes: add_note(error, note) @@ -1683,7 +1685,9 @@ def test_check_error_notes_success(error: Exception, notes: list[str], match: st (AssertionError("foo"), ["bar"], "foo\nbaz"), ], ) -def test_check_error_notes_failure(error: Exception, notes: list[str], match: str) -> None: +def test_check_error_notes_failure( + error: Exception, notes: list[str], match: str +) -> None: for note in notes: add_note(error, note) From 9c3ee7d25525d424c095bb3d9f166202810877fb Mon Sep 17 00:00:00 2001 From: Isaac Virshup Date: Tue, 18 Jul 2023 11:39:40 +0200 Subject: [PATCH 08/10] Add changelog entry --- changelog/11227.improvement.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/11227.improvement.rst 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__``. From d4c62de2dd353ac53b1c2ae7a5bee343016af4d9 Mon Sep 17 00:00:00 2001 From: Isaac Virshup Date: Tue, 18 Jul 2023 11:49:29 +0200 Subject: [PATCH 09/10] Add test cases for passing compiled regex --- testing/code/test_excinfo.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index bd72c9ccf95..d3d6124c1db 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -2,6 +2,7 @@ import io import operator import queue +import re import sys import textwrap from pathlib import Path @@ -1665,6 +1666,8 @@ def add_note(err: BaseException, msg: str) -> None: (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( From 5c29a82738b0302362a305adae05198c1b75e667 Mon Sep 17 00:00:00 2001 From: Isaac Virshup Date: Tue, 18 Jul 2023 11:59:20 +0200 Subject: [PATCH 10/10] Type annotation fix + subsequent pyupgrade, autoflake --- testing/code/test_excinfo.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index d3d6124c1db..90f81123e01 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import importlib import io import operator @@ -7,10 +9,7 @@ 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 @@ -802,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) @@ -929,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) @@ -1091,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" @@ -1399,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: