From 5bb7ef2768be5979b306e4c7552862b1746c251d Mon Sep 17 00:00:00 2001 From: Irit Katriel <1055913+iritkatriel@users.noreply.github.com> Date: Fri, 3 Dec 2021 22:01:15 +0000 Subject: [PATCH] bpo-45607: Make it possible to enrich exception displays via setting their __note__ field (GH-29880) --- Doc/library/exceptions.rst | 8 +++ Doc/whatsnew/3.11.rst | 6 ++ Include/cpython/pyerrors.h | 2 +- Lib/test/test_exceptions.py | 21 ++++++ Lib/test/test_sys.py | 8 +-- Lib/test/test_traceback.py | 69 +++++++++++++++++++ Lib/traceback.py | 4 ++ .../2021-12-01-15-38-04.bpo-45607.JhuF8b.rst | 4 ++ Objects/exceptions.c | 31 +++++++++ Python/pythonrun.c | 35 ++++++++++ 10 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 Misc/NEWS.d/next/Core and Builtins/2021-12-01-15-38-04.bpo-45607.JhuF8b.rst diff --git a/Doc/library/exceptions.rst b/Doc/library/exceptions.rst index 8fa82a98a19..12d7d8abb26 100644 --- a/Doc/library/exceptions.rst +++ b/Doc/library/exceptions.rst @@ -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 diff --git a/Doc/whatsnew/3.11.rst b/Doc/whatsnew/3.11.rst index 1ec629d8229..c498225591a 100644 --- a/Doc/whatsnew/3.11.rst +++ b/Doc/whatsnew/3.11.rst @@ -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 ====================== diff --git a/Include/cpython/pyerrors.h b/Include/cpython/pyerrors.h index a07018abae0..5281fde1f1a 100644 --- a/Include/cpython/pyerrors.h +++ b/Include/cpython/pyerrors.h @@ -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; diff --git a/Lib/test/test_exceptions.py b/Lib/test/test_exceptions.py index c6660043c80..e4b7b8f0a64 100644 --- a/Lib/test/test_exceptions.py +++ b/Lib/test/test_exceptions.py @@ -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) diff --git a/Lib/test/test_sys.py b/Lib/test/test_sys.py index db8d0082085..2b1ba2457f5 100644 --- a/Lib/test/test_sys.py +++ b/Lib/test/test_sys.py @@ -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 diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index cde35f5dacb..a458b21b094 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -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): # diff --git a/Lib/traceback.py b/Lib/traceback.py index 77f8590719e..b244750fd01 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -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 index 00000000000..3e38c3e6f95 --- /dev/null +++ b/Misc/NEWS.d/next/Core and Builtins/2021-12-01-15-38-04.bpo-45607.JhuF8b.rst @@ -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. + diff --git a/Objects/exceptions.c b/Objects/exceptions.c index a5459da89a0..c99f17a30f1 100644 --- a/Objects/exceptions.c +++ b/Objects/exceptions.c @@ -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")}, diff --git a/Python/pythonrun.c b/Python/pythonrun.c index 2f68b214603..5a118b4821e 100644 --- a/Python/pythonrun.c +++ b/Python/pythonrun.c @@ -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.