Mailing List Archive

bpo-45607: Make it possible to enrich exception displays via setting their __note__ field (GH-29880)
https://github.com/python/cpython/commit/5bb7ef2768be5979b306e4c7552862b1746c251d
commit: 5bb7ef2768be5979b306e4c7552862b1746c251d
branch: main
author: Irit Katriel <1055913+iritkatriel@users.noreply.github.com>
committer: iritkatriel <1055913+iritkatriel@users.noreply.github.com>
date: 2021-12-03T22:01:15Z
summary:

bpo-45607: Make it possible to enrich exception displays via setting their __note__ field (GH-29880)

files:
A Misc/NEWS.d/next/Core and Builtins/2021-12-01-15-38-04.bpo-45607.JhuF8b.rst
M Doc/library/exceptions.rst
M Doc/whatsnew/3.11.rst
M Include/cpython/pyerrors.h
M Lib/test/test_exceptions.py
M Lib/test/test_sys.py
M Lib/test/test_traceback.py
M Lib/traceback.py
M Objects/exceptions.c
M Python/pythonrun.c

diff --git a/Doc/library/exceptions.rst b/Doc/library/exceptions.rst
index 8fa82a98a199d..12d7d8abb2650 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 1ec629d8229cb..c498225591a74 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 a07018abae0cf..5281fde1f1a54 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 c6660043c805f..e4b7b8f0a6406 100644
--- a/Lib/test/test_exceptions.py
+++ b/Lib/test/test_exceptions.py
@@ -516,6 +516,27 @@ def testAttributes(self):
'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 db8d0082085cb..2b1ba2457f50d 100644
--- a/Lib/test/test_sys.py
+++ b/Lib/test/test_sys.py
@@ -1298,13 +1298,13 @@ def inner():
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 cde35f5dacb2d..a458b21b09466 100644
--- a/Lib/test/test_traceback.py
+++ b/Lib/test/test_traceback.py
@@ -1224,6 +1224,22 @@ def test_syntax_error_various_offsets(self):
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 @@ def test_exception_group_depth_limit(self):
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 77f8590719eb8..b244750fd016e 100644
--- a/Lib/traceback.py
+++ b/Lib/traceback.py
@@ -685,6 +685,8 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None,
# 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 @@ def format_exception_only(self):
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 0000000000000..3e38c3e6f95e8
--- /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 a5459da89a073..c99f17a30f169 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 2f68b214603e1..5a118b4821ec0 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.

_______________________________________________
Python-checkins mailing list
Python-checkins@python.org
https://mail.python.org/mailman/listinfo/python-checkins