]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
bpo-45607: Make it possible to enrich exception displays via setting their __note__...
authorIrit Katriel <1055913+iritkatriel@users.noreply.github.com>
Fri, 3 Dec 2021 22:01:15 +0000 (22:01 +0000)
committerGitHub <noreply@github.com>
Fri, 3 Dec 2021 22:01:15 +0000 (22:01 +0000)
Doc/library/exceptions.rst
Doc/whatsnew/3.11.rst
Include/cpython/pyerrors.h
Lib/test/test_exceptions.py
Lib/test/test_sys.py
Lib/test/test_traceback.py
Lib/traceback.py
Misc/NEWS.d/next/Core and Builtins/2021-12-01-15-38-04.bpo-45607.JhuF8b.rst [new file with mode: 0644]
Objects/exceptions.c
Python/pythonrun.c

index 8fa82a98a199d6917a281ac6c30e20257017267f..12d7d8abb26504a2a18acda55de30eff70f8dba3 100644 (file)
@@ -127,6 +127,14 @@ The following exceptions are used mostly as base classes for other exceptions.
              tb = sys.exc_info()[2]
              raise OtherException(...).with_traceback(tb)
 
+   .. attribute:: __note__
+
+      A mutable field which is :const:`None` by default and can be set to a string.
+      If it is not :const:`None`, it is included in the traceback. This field can
+      be used to enrich exceptions after they have been caught.
+
+   .. versionadded:: 3.11
+
 
 .. exception:: Exception
 
index 1ec629d8229cbe2974fcc18cc4397c7c9873c3e6..c498225591a74c1d5315b5e63f81a637699e2578 100644 (file)
@@ -146,6 +146,12 @@ The :option:`-X` ``no_debug_ranges`` option and the environment variable
 See :pep:`657` for more details. (Contributed by Pablo Galindo, Batuhan Taskaya
 and Ammar Askar in :issue:`43950`.)
 
+Exceptions can be enriched with a string ``__note__``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The ``__note__`` field was added to :exc:`BaseException`. It is ``None``
+by default but can be set to a string which is added to the exception's
+traceback. (Contributed by Irit Katriel in :issue:`45607`.)
 
 Other Language Changes
 ======================
index a07018abae0cf3aed69c798a95cc9c0b64ec0ca2..5281fde1f1a54c056ec582e84b86d10325a096fe 100644 (file)
@@ -6,7 +6,7 @@
 
 /* PyException_HEAD defines the initial segment of every exception class. */
 #define PyException_HEAD PyObject_HEAD PyObject *dict;\
-             PyObject *args; PyObject *traceback;\
+             PyObject *args; PyObject *note; PyObject *traceback;\
              PyObject *context; PyObject *cause;\
              char suppress_context;
 
index c6660043c805f2473934eed4e7db89db78654d8a..e4b7b8f0a6406ff912d18f199c0594fb6dae1e6f 100644 (file)
@@ -516,6 +516,27 @@ class ExceptionTests(unittest.TestCase):
                                              'pickled "%r", attribute "%s' %
                                              (e, checkArgName))
 
+    def test_note(self):
+        for e in [BaseException(1), Exception(2), ValueError(3)]:
+            with self.subTest(e=e):
+                self.assertIsNone(e.__note__)
+                e.__note__ = "My Note"
+                self.assertEqual(e.__note__, "My Note")
+
+                with self.assertRaises(TypeError):
+                    e.__note__ = 42
+                self.assertEqual(e.__note__, "My Note")
+
+                e.__note__ = "Your Note"
+                self.assertEqual(e.__note__, "Your Note")
+
+                with self.assertRaises(TypeError):
+                    del e.__note__
+                self.assertEqual(e.__note__, "Your Note")
+
+                e.__note__ = None
+                self.assertIsNone(e.__note__)
+
     def testWithTraceback(self):
         try:
             raise IndexError(4)
index db8d0082085cb111d9587447f9b7580e755c443a..2b1ba2457f50d2b7cd9d3b5efb6d681fd9e8d263 100644 (file)
@@ -1298,13 +1298,13 @@ class SizeofTest(unittest.TestCase):
         class C(object): pass
         check(C.__dict__, size('P'))
         # BaseException
-        check(BaseException(), size('5Pb'))
+        check(BaseException(), size('6Pb'))
         # UnicodeEncodeError
-        check(UnicodeEncodeError("", "", 0, 0, ""), size('5Pb 2P2nP'))
+        check(UnicodeEncodeError("", "", 0, 0, ""), size('6Pb 2P2nP'))
         # UnicodeDecodeError
-        check(UnicodeDecodeError("", b"", 0, 0, ""), size('5Pb 2P2nP'))
+        check(UnicodeDecodeError("", b"", 0, 0, ""), size('6Pb 2P2nP'))
         # UnicodeTranslateError
-        check(UnicodeTranslateError("", 0, 1, ""), size('5Pb 2P2nP'))
+        check(UnicodeTranslateError("", 0, 1, ""), size('6Pb 2P2nP'))
         # ellipses
         check(Ellipsis, size(''))
         # EncodingMap
index cde35f5dacb2d3e813cda78fd699a7f68edd01a7..a458b21b094660f7771beb88bb44f7e2a3c0097b 100644 (file)
@@ -1224,6 +1224,22 @@ class BaseExceptionReportingTests:
                 exp = "\n".join(expected)
                 self.assertEqual(exp, err)
 
+    def test_exception_with_note(self):
+        e = ValueError(42)
+        vanilla = self.get_report(e)
+
+        e.__note__ = 'My Note'
+        self.assertEqual(self.get_report(e), vanilla + 'My Note\n')
+
+        e.__note__ = ''
+        self.assertEqual(self.get_report(e), vanilla + '\n')
+
+        e.__note__ = 'Your Note'
+        self.assertEqual(self.get_report(e), vanilla + 'Your Note\n')
+
+        e.__note__ = None
+        self.assertEqual(self.get_report(e), vanilla)
+
     def test_exception_qualname(self):
         class A:
             class B:
@@ -1566,6 +1582,59 @@ class BaseExceptionReportingTests:
         report = self.get_report(exc)
         self.assertEqual(report, expected)
 
+    def test_exception_group_with_notes(self):
+        def exc():
+            try:
+                excs = []
+                for msg in ['bad value', 'terrible value']:
+                    try:
+                        raise ValueError(msg)
+                    except ValueError as e:
+                        e.__note__ = f'the {msg}'
+                        excs.append(e)
+                raise ExceptionGroup("nested", excs)
+            except ExceptionGroup as e:
+                e.__note__ = ('>> Multi line note\n'
+                              '>> Because I am such\n'
+                              '>> an important exception.\n'
+                              '>> empty lines work too\n'
+                              '\n'
+                              '(that was an empty line)')
+                raise
+
+        expected = (f'  + Exception Group Traceback (most recent call last):\n'
+                    f'  |   File "{__file__}", line {self.callable_line}, in get_exception\n'
+                    f'  |     exception_or_callable()\n'
+                    f'  |     ^^^^^^^^^^^^^^^^^^^^^^^\n'
+                    f'  |   File "{__file__}", line {exc.__code__.co_firstlineno + 9}, in exc\n'
+                    f'  |     raise ExceptionGroup("nested", excs)\n'
+                    f'  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n'
+                    f'  | ExceptionGroup: nested\n'
+                    f'  | >> Multi line note\n'
+                    f'  | >> Because I am such\n'
+                    f'  | >> an important exception.\n'
+                    f'  | >> empty lines work too\n'
+                    f'  | \n'
+                    f'  | (that was an empty line)\n'
+                    f'  +-+---------------- 1 ----------------\n'
+                    f'    | Traceback (most recent call last):\n'
+                    f'    |   File "{__file__}", line {exc.__code__.co_firstlineno + 5}, in exc\n'
+                    f'    |     raise ValueError(msg)\n'
+                    f'    |     ^^^^^^^^^^^^^^^^^^^^^\n'
+                    f'    | ValueError: bad value\n'
+                    f'    | the bad value\n'
+                    f'    +---------------- 2 ----------------\n'
+                    f'    | Traceback (most recent call last):\n'
+                    f'    |   File "{__file__}", line {exc.__code__.co_firstlineno + 5}, in exc\n'
+                    f'    |     raise ValueError(msg)\n'
+                    f'    |     ^^^^^^^^^^^^^^^^^^^^^\n'
+                    f'    | ValueError: terrible value\n'
+                    f'    | the terrible value\n'
+                    f'    +------------------------------------\n')
+
+        report = self.get_report(exc)
+        self.assertEqual(report, expected)
+
 
 class PyExcReportingTests(BaseExceptionReportingTests, unittest.TestCase):
     #
index 77f8590719eb82b5879d92d6d2a8ee548e802172..b244750fd016eacb8c69b160ed0364a09f01b98b 100644 (file)
@@ -685,6 +685,8 @@ class TracebackException:
         # Capture now to permit freeing resources: only complication is in the
         # unofficial API _format_final_exc_line
         self._str = _some_str(exc_value)
+        self.__note__ = exc_value.__note__ if exc_value else None
+
         if exc_type and issubclass(exc_type, SyntaxError):
             # Handle SyntaxError's specially
             self.filename = exc_value.filename
@@ -816,6 +818,8 @@ class TracebackException:
             yield _format_final_exc_line(stype, self._str)
         else:
             yield from self._format_syntax_error(stype)
+        if self.__note__ is not None:
+            yield from [l + '\n' for l in self.__note__.split('\n')]
 
     def _format_syntax_error(self, stype):
         """Format SyntaxError exceptions (internal helper)."""
diff --git a/Misc/NEWS.d/next/Core and Builtins/2021-12-01-15-38-04.bpo-45607.JhuF8b.rst b/Misc/NEWS.d/next/Core and Builtins/2021-12-01-15-38-04.bpo-45607.JhuF8b.rst
new file mode 100644 (file)
index 0000000..3e38c3e
--- /dev/null
@@ -0,0 +1,4 @@
+The ``__note__`` field was added to :exc:`BaseException`. It is ``None``
+by default but can be set to a string which is added to the exception's
+traceback.
+
index a5459da89a073d17acd7cb2add5369b30b3a7718..c99f17a30f16912750a8e5b1e622a0bd91042077 100644 (file)
@@ -46,6 +46,7 @@ BaseException_new(PyTypeObject *type, PyObject *args, PyObject *kwds)
         return NULL;
     /* the dict is created on the fly in PyObject_GenericSetAttr */
     self->dict = NULL;
+    self->note = NULL;
     self->traceback = self->cause = self->context = NULL;
     self->suppress_context = 0;
 
@@ -81,6 +82,7 @@ BaseException_clear(PyBaseExceptionObject *self)
 {
     Py_CLEAR(self->dict);
     Py_CLEAR(self->args);
+    Py_CLEAR(self->note);
     Py_CLEAR(self->traceback);
     Py_CLEAR(self->cause);
     Py_CLEAR(self->context);
@@ -105,6 +107,7 @@ BaseException_traverse(PyBaseExceptionObject *self, visitproc visit, void *arg)
 {
     Py_VISIT(self->dict);
     Py_VISIT(self->args);
+    Py_VISIT(self->note);
     Py_VISIT(self->traceback);
     Py_VISIT(self->cause);
     Py_VISIT(self->context);
@@ -216,6 +219,33 @@ BaseException_set_args(PyBaseExceptionObject *self, PyObject *val, void *Py_UNUS
     return 0;
 }
 
+static PyObject *
+BaseException_get_note(PyBaseExceptionObject *self, void *Py_UNUSED(ignored))
+{
+    if (self->note == NULL) {
+        Py_RETURN_NONE;
+    }
+    return Py_NewRef(self->note);
+}
+
+static int
+BaseException_set_note(PyBaseExceptionObject *self, PyObject *note,
+                       void *Py_UNUSED(ignored))
+{
+    if (note == NULL) {
+        PyErr_SetString(PyExc_TypeError, "__note__ may not be deleted");
+        return -1;
+    }
+    else if (note != Py_None && !PyUnicode_CheckExact(note)) {
+        PyErr_SetString(PyExc_TypeError, "__note__ must be a string or None");
+        return -1;
+    }
+
+    Py_INCREF(note);
+    Py_XSETREF(self->note, note);
+    return 0;
+}
+
 static PyObject *
 BaseException_get_tb(PyBaseExceptionObject *self, void *Py_UNUSED(ignored))
 {
@@ -306,6 +336,7 @@ BaseException_set_cause(PyObject *self, PyObject *arg, void *Py_UNUSED(ignored))
 static PyGetSetDef BaseException_getset[] = {
     {"__dict__", PyObject_GenericGetDict, PyObject_GenericSetDict},
     {"args", (getter)BaseException_get_args, (setter)BaseException_set_args},
+    {"__note__", (getter)BaseException_get_note, (setter)BaseException_set_note},
     {"__traceback__", (getter)BaseException_get_tb, (setter)BaseException_set_tb},
     {"__context__", BaseException_get_context,
      BaseException_set_context, PyDoc_STR("exception context")},
index 2f68b214603e162409b08edb6454dc6acc481967..5a118b4821ec0f13d89fed6d3185c8e03a38c85d 100644 (file)
@@ -1083,6 +1083,41 @@ print_exception(struct exception_print_context *ctx, PyObject *value)
         PyErr_Clear();
     }
     err += PyFile_WriteString("\n", f);
+
+    if (err == 0 && PyExceptionInstance_Check(value)) {
+        _Py_IDENTIFIER(__note__);
+
+        PyObject *note = _PyObject_GetAttrId(value, &PyId___note__);
+        if (note == NULL) {
+            err = -1;
+        }
+        if (err == 0 && PyUnicode_Check(note)) {
+            _Py_static_string(PyId_newline, "\n");
+            PyObject *lines = PyUnicode_Split(
+                note, _PyUnicode_FromId(&PyId_newline), -1);
+            if (lines == NULL) {
+                err = -1;
+            }
+            else {
+                Py_ssize_t n = PyList_GET_SIZE(lines);
+                for (Py_ssize_t i = 0; i < n; i++) {
+                    if (err == 0) {
+                        PyObject *line = PyList_GET_ITEM(lines, i);
+                        assert(PyUnicode_Check(line));
+                        err = write_indented_margin(ctx, f);
+                        if (err == 0) {
+                            err = PyFile_WriteObject(line, f, Py_PRINT_RAW);
+                        }
+                        if (err == 0) {
+                            err = PyFile_WriteString("\n", f);
+                        }
+                    }
+                }
+            }
+            Py_DECREF(lines);
+        }
+        Py_XDECREF(note);
+    }
     Py_XDECREF(tb);
     Py_DECREF(value);
     /* If an error happened here, don't show it.