[Python-checkins] gh-89770: Implement PEP-678 - Exception notes (GH-31317)

iritkatriel webhook-mailer at python.org
Sat Apr 16 15:00:13 EDT 2022


https://github.com/python/cpython/commit/d4c4a76ed1427c947fcbbe692625b3f644cf3aaf
commit: d4c4a76ed1427c947fcbbe692625b3f644cf3aaf
branch: main
author: Irit Katriel <1055913+iritkatriel at users.noreply.github.com>
committer: iritkatriel <1055913+iritkatriel at users.noreply.github.com>
date: 2022-04-16T19:59:52+01:00
summary:

gh-89770: Implement PEP-678 - Exception notes (GH-31317)

files:
A Misc/NEWS.d/next/Core and Builtins/2022-04-12-11-56-23.gh-issue-91479.-dyGJX.rst
M Doc/library/exceptions.rst
M Doc/whatsnew/3.11.rst
M Include/cpython/pyerrors.h
M Include/internal/pycore_global_strings.h
M Include/internal/pycore_runtime_init.h
M Lib/test/test_exception_group.py
M Lib/test/test_exceptions.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 137566e079d20..2eccbd17c482c 100644
--- a/Doc/library/exceptions.rst
+++ b/Doc/library/exceptions.rst
@@ -126,13 +126,20 @@ The following exceptions are used mostly as base classes for other exceptions.
              tb = sys.exc_info()[2]
              raise OtherException(...).with_traceback(tb)
 
-   .. attribute:: __note__
+   .. method:: add_note(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.
+      Add the string ``note`` to the exception's notes which appear in the standard
+      traceback after the exception string. A :exc:`TypeError` is raised if ``note``
+      is not a string.
 
-   .. versionadded:: 3.11
+      .. versionadded:: 3.11
+
+   .. attribute:: __notes__
+
+      A list of the notes of this exception, which were added with :meth:`add_note`.
+      This attribute is created when :meth:`add_note` is called.
+
+      .. versionadded:: 3.11
 
 
 .. exception:: Exception
@@ -907,7 +914,7 @@ their subgroups based on the types of the contained exceptions.
 
       The nesting structure of the current exception is preserved in the result,
       as are the values of its :attr:`message`, :attr:`__traceback__`,
-      :attr:`__cause__`, :attr:`__context__` and :attr:`__note__` fields.
+      :attr:`__cause__`, :attr:`__context__` and :attr:`__notes__` fields.
       Empty nested groups are omitted from the result.
 
       The condition is checked for all exceptions in the nested exception group,
@@ -924,7 +931,7 @@ their subgroups based on the types of the contained exceptions.
 
       Returns an exception group with the same :attr:`message`,
       :attr:`__traceback__`, :attr:`__cause__`, :attr:`__context__`
-      and :attr:`__note__` but which wraps the exceptions in ``excs``.
+      and :attr:`__notes__` but which wraps the exceptions in ``excs``.
 
       This method is used by :meth:`subgroup` and :meth:`split`. A
       subclass needs to override it in order to make :meth:`subgroup`
diff --git a/Doc/whatsnew/3.11.rst b/Doc/whatsnew/3.11.rst
index 19e3d2f92fb94..cd452fc537157 100644
--- a/Doc/whatsnew/3.11.rst
+++ b/Doc/whatsnew/3.11.rst
@@ -157,12 +157,15 @@ 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__``
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Exceptions can be enriched with notes (PEP 678)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The :meth:`add_note` method was added to :exc:`BaseException`. It can be
+used to enrich exceptions with context information which is not available
+at the time when the exception is raised. The notes added appear in the
+default traceback. See :pep:`678` for more details. (Contributed by
+Irit Katriel in :issue:`45607`.)
 
-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 08630cce8ac90..47d80e3242302 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 *note; PyObject *traceback;\
+             PyObject *args; PyObject *notes; PyObject *traceback;\
              PyObject *context; PyObject *cause;\
              char suppress_context;
 
diff --git a/Include/internal/pycore_global_strings.h b/Include/internal/pycore_global_strings.h
index 833ff2710a787..77f96cd5ed2f0 100644
--- a/Include/internal/pycore_global_strings.h
+++ b/Include/internal/pycore_global_strings.h
@@ -155,7 +155,7 @@ struct _Py_global_strings {
         STRUCT_FOR_ID(__newobj__)
         STRUCT_FOR_ID(__newobj_ex__)
         STRUCT_FOR_ID(__next__)
-        STRUCT_FOR_ID(__note__)
+        STRUCT_FOR_ID(__notes__)
         STRUCT_FOR_ID(__or__)
         STRUCT_FOR_ID(__orig_class__)
         STRUCT_FOR_ID(__origin__)
diff --git a/Include/internal/pycore_runtime_init.h b/Include/internal/pycore_runtime_init.h
index fd925b3e060df..371f2d23ad8ec 100644
--- a/Include/internal/pycore_runtime_init.h
+++ b/Include/internal/pycore_runtime_init.h
@@ -778,7 +778,7 @@ extern "C" {
                 INIT_ID(__newobj__), \
                 INIT_ID(__newobj_ex__), \
                 INIT_ID(__next__), \
-                INIT_ID(__note__), \
+                INIT_ID(__notes__), \
                 INIT_ID(__or__), \
                 INIT_ID(__orig_class__), \
                 INIT_ID(__origin__), \
diff --git a/Lib/test/test_exception_group.py b/Lib/test/test_exception_group.py
index 793e8d20de7e3..2cfd8738304d1 100644
--- a/Lib/test/test_exception_group.py
+++ b/Lib/test/test_exception_group.py
@@ -567,7 +567,9 @@ def leaves(exc):
                 self.assertIs(eg.__cause__, part.__cause__)
                 self.assertIs(eg.__context__, part.__context__)
                 self.assertIs(eg.__traceback__, part.__traceback__)
-                self.assertIs(eg.__note__, part.__note__)
+                self.assertEqual(
+                    getattr(eg, '__notes__', None),
+                    getattr(part, '__notes__', None))
 
         def tbs_for_leaf(leaf, eg):
             for e, tbs in leaf_generator(eg):
@@ -632,7 +634,7 @@ def level3(i):
         try:
             nested_group()
         except ExceptionGroup as e:
-            e.__note__ = f"the note: {id(e)}"
+            e.add_note(f"the note: {id(e)}")
             eg = e
 
         eg_template = [
@@ -728,6 +730,35 @@ def exc(ex):
         self.assertMatchesTemplate(
             rest, ExceptionGroup, [ValueError(1)])
 
+    def test_split_copies_notes(self):
+        # make sure each exception group after a split has its own __notes__ list
+        eg = ExceptionGroup("eg", [ValueError(1), TypeError(2)])
+        eg.add_note("note1")
+        eg.add_note("note2")
+        orig_notes = list(eg.__notes__)
+        match, rest = eg.split(TypeError)
+        self.assertEqual(eg.__notes__, orig_notes)
+        self.assertEqual(match.__notes__, orig_notes)
+        self.assertEqual(rest.__notes__, orig_notes)
+        self.assertIsNot(eg.__notes__, match.__notes__)
+        self.assertIsNot(eg.__notes__, rest.__notes__)
+        self.assertIsNot(match.__notes__, rest.__notes__)
+        eg.add_note("eg")
+        match.add_note("match")
+        rest.add_note("rest")
+        self.assertEqual(eg.__notes__, orig_notes + ["eg"])
+        self.assertEqual(match.__notes__, orig_notes + ["match"])
+        self.assertEqual(rest.__notes__, orig_notes + ["rest"])
+
+    def test_split_does_not_copy_non_sequence_notes(self):
+        # __notes__ should be a sequence, which is shallow copied.
+        # If it is not a sequence, the split parts don't get any notes.
+        eg = ExceptionGroup("eg", [ValueError(1), TypeError(2)])
+        eg.__notes__ = 123
+        match, rest = eg.split(TypeError)
+        self.assertFalse(hasattr(match, '__notes__'))
+        self.assertFalse(hasattr(rest, '__notes__'))
+
 
 class NestedExceptionGroupSubclassSplitTest(ExceptionGroupSplitTestBase):
 
diff --git a/Lib/test/test_exceptions.py b/Lib/test/test_exceptions.py
index 6dca79efef180..2b5b51934562a 100644
--- a/Lib/test/test_exceptions.py
+++ b/Lib/test/test_exceptions.py
@@ -547,26 +547,32 @@ def testAttributes(self):
                                              'pickled "%r", attribute "%s' %
                                              (e, checkArgName))
 
-    def test_note(self):
+    def test_notes(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")
+                self.assertFalse(hasattr(e, '__notes__'))
+                e.add_note("My Note")
+                self.assertEqual(e.__notes__, ["My Note"])
 
                 with self.assertRaises(TypeError):
-                    e.__note__ = 42
-                self.assertEqual(e.__note__, "My Note")
+                    e.add_note(42)
+                self.assertEqual(e.__notes__, ["My Note"])
 
-                e.__note__ = "Your Note"
-                self.assertEqual(e.__note__, "Your Note")
+                e.add_note("Your Note")
+                self.assertEqual(e.__notes__, ["My Note", "Your Note"])
 
-                with self.assertRaises(TypeError):
-                    del e.__note__
-                self.assertEqual(e.__note__, "Your Note")
+                del e.__notes__
+                self.assertFalse(hasattr(e, '__notes__'))
+
+                e.add_note("Our Note")
+                self.assertEqual(e.__notes__, ["Our Note"])
 
-                e.__note__ = None
-                self.assertIsNone(e.__note__)
+                e.__notes__ = 42
+                self.assertEqual(e.__notes__, 42)
+
+                with self.assertRaises(TypeError):
+                    e.add_note("will not work")
+                self.assertEqual(e.__notes__, 42)
 
     def testWithTraceback(self):
         try:
diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py
index 75d668df64d4c..962322c89ff66 100644
--- a/Lib/test/test_traceback.py
+++ b/Lib/test/test_traceback.py
@@ -1323,21 +1323,80 @@ def test_syntax_error_various_offsets(self):
                 self.assertEqual(exp, err)
 
     def test_exception_with_note(self):
-        e = ValueError(42)
+        e = ValueError(123)
         vanilla = self.get_report(e)
 
-        e.__note__ = 'My Note'
+        e.add_note('My Note')
         self.assertEqual(self.get_report(e), vanilla + 'My Note\n')
 
-        e.__note__ = ''
+        del e.__notes__
+        e.add_note('')
         self.assertEqual(self.get_report(e), vanilla + '\n')
 
-        e.__note__ = 'Your Note'
+        del e.__notes__
+        e.add_note('Your Note')
         self.assertEqual(self.get_report(e), vanilla + 'Your Note\n')
 
-        e.__note__ = None
+        del e.__notes__
         self.assertEqual(self.get_report(e), vanilla)
 
+    def test_exception_with_invalid_notes(self):
+        e = ValueError(123)
+        vanilla = self.get_report(e)
+
+        # non-sequence __notes__
+        class BadThing:
+            def __str__(self):
+                return 'bad str'
+
+            def __repr__(self):
+                return 'bad repr'
+
+        # unprintable, non-sequence __notes__
+        class Unprintable:
+            def __repr__(self):
+                raise ValueError('bad value')
+
+        e.__notes__ = BadThing()
+        notes_repr = 'bad repr'
+        self.assertEqual(self.get_report(e), vanilla + notes_repr)
+
+        e.__notes__ = Unprintable()
+        err_msg = '<__notes__ repr() failed>'
+        self.assertEqual(self.get_report(e), vanilla + err_msg)
+
+        # non-string item in the __notes__ sequence
+        e.__notes__  = [BadThing(), 'Final Note']
+        bad_note = 'bad str'
+        self.assertEqual(self.get_report(e), vanilla + bad_note + '\nFinal Note\n')
+
+        # unprintable, non-string item in the __notes__ sequence
+        e.__notes__  = [Unprintable(), 'Final Note']
+        err_msg = '<note str() failed>'
+        self.assertEqual(self.get_report(e), vanilla + err_msg + '\nFinal Note\n')
+
+    def test_exception_with_note_with_multiple_notes(self):
+        e = ValueError(42)
+        vanilla = self.get_report(e)
+
+        e.add_note('Note 1')
+        e.add_note('Note 2')
+        e.add_note('Note 3')
+
+        self.assertEqual(
+            self.get_report(e),
+            vanilla + 'Note 1\n' + 'Note 2\n' + 'Note 3\n')
+
+        del e.__notes__
+        e.add_note('Note 4')
+        del e.__notes__
+        e.add_note('Note 5')
+        e.add_note('Note 6')
+
+        self.assertEqual(
+            self.get_report(e),
+            vanilla + 'Note 5\n' + 'Note 6\n')
+
     def test_exception_qualname(self):
         class A:
             class B:
@@ -1688,16 +1747,16 @@ def exc():
                     try:
                         raise ValueError(msg)
                     except ValueError as e:
-                        e.__note__ = f'the {msg}'
+                        e.add_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)')
+                e.add_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'
@@ -1733,6 +1792,64 @@ def exc():
         report = self.get_report(exc)
         self.assertEqual(report, expected)
 
+    def test_exception_group_with_multiple_notes(self):
+        def exc():
+            try:
+                excs = []
+                for msg in ['bad value', 'terrible value']:
+                    try:
+                        raise ValueError(msg)
+                    except ValueError as e:
+                        e.add_note(f'the {msg}')
+                        e.add_note(f'Goodbye {msg}')
+                        excs.append(e)
+                raise ExceptionGroup("nested", excs)
+            except ExceptionGroup as e:
+                e.add_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)'))
+                e.add_note('Goodbye!')
+                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 + 10}, in exc\n'
+                    f'  |     raise ExceptionGroup("nested", excs)\n'
+                    f'  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n'
+                    f'  | ExceptionGroup: nested (2 sub-exceptions)\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'  | Goodbye!\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'    | Goodbye 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'    | Goodbye terrible value\n'
+                    f'    +------------------------------------\n')
+
+        report = self.get_report(exc)
+        self.assertEqual(report, expected)
+
 
 class PyExcReportingTests(BaseExceptionReportingTests, unittest.TestCase):
     #
@@ -2077,32 +2194,32 @@ def some_inner():
             [f'{__file__}:{some_inner.__code__.co_firstlineno + 1}'])
 
     def test_dropping_frames(self):
-         def f():
-             1/0
-
-         def g():
-             try:
-                 f()
-             except:
-                 return sys.exc_info()
-
-         exc_info = g()
-
-         class Skip_G(traceback.StackSummary):
-             def format_frame_summary(self, frame_summary):
-                 if frame_summary.name == 'g':
-                     return None
-                 return super().format_frame_summary(frame_summary)
-
-         stack = Skip_G.extract(
-             traceback.walk_tb(exc_info[2])).format()
-
-         self.assertEqual(len(stack), 1)
-         lno = f.__code__.co_firstlineno + 1
-         self.assertEqual(
-             stack[0],
-             f'  File "{__file__}", line {lno}, in f\n    1/0\n'
-         )
+        def f():
+            1/0
+
+        def g():
+            try:
+                f()
+            except:
+                return sys.exc_info()
+
+        exc_info = g()
+
+        class Skip_G(traceback.StackSummary):
+            def format_frame_summary(self, frame_summary):
+                if frame_summary.name == 'g':
+                    return None
+                return super().format_frame_summary(frame_summary)
+
+        stack = Skip_G.extract(
+            traceback.walk_tb(exc_info[2])).format()
+
+        self.assertEqual(len(stack), 1)
+        lno = f.__code__.co_firstlineno + 1
+        self.assertEqual(
+            stack[0],
+            f'  File "{__file__}", line {lno}, in f\n    1/0\n'
+        )
 
 
 class TestTracebackException(unittest.TestCase):
diff --git a/Lib/traceback.py b/Lib/traceback.py
index 05f1fffef0d3b..3afe49d1d8a0e 100644
--- a/Lib/traceback.py
+++ b/Lib/traceback.py
@@ -1,6 +1,6 @@
 """Extract, format and print information about Python stack traces."""
 
-import collections
+import collections.abc
 import itertools
 import linecache
 import sys
@@ -163,18 +163,18 @@ def format_exception_only(exc, /, value=_sentinel):
 # -- not official API but folk probably use these two functions.
 
 def _format_final_exc_line(etype, value):
-    valuestr = _some_str(value)
+    valuestr = _safe_string(value, 'exception')
     if value is None or not valuestr:
         line = "%s\n" % etype
     else:
         line = "%s: %s\n" % (etype, valuestr)
     return line
 
-def _some_str(value):
+def _safe_string(value, what, func=str):
     try:
-        return str(value)
+        return func(value)
     except:
-        return '<exception str() failed>'
+        return f'<{what} {func.__name__}() failed>'
 
 # --
 
@@ -688,8 +688,8 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None,
         self.exc_type = exc_type
         # 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
+        self._str = _safe_string(exc_value, 'exception')
+        self.__notes__ = getattr(exc_value, '__notes__', None)
 
         if exc_type and issubclass(exc_type, SyntaxError):
             # Handle SyntaxError's specially
@@ -822,8 +822,12 @@ 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')]
+        if isinstance(self.__notes__, collections.abc.Sequence):
+            for note in self.__notes__:
+                note = _safe_string(note, 'note')
+                yield from [l + '\n' for l in note.split('\n')]
+        elif self.__notes__ is not None:
+            yield _safe_string(self.__notes__, '__notes__', func=repr)
 
     def _format_syntax_error(self, stype):
         """Format SyntaxError exceptions (internal helper)."""
@@ -913,7 +917,7 @@ def format(self, *, chain=True, _ctx=None):
                 # format exception group
                 is_toplevel = (_ctx.exception_group_depth == 0)
                 if is_toplevel:
-                     _ctx.exception_group_depth += 1
+                    _ctx.exception_group_depth += 1
 
                 if exc.stack:
                     yield from _ctx.emit(
diff --git a/Misc/NEWS.d/next/Core and Builtins/2022-04-12-11-56-23.gh-issue-91479.-dyGJX.rst b/Misc/NEWS.d/next/Core and Builtins/2022-04-12-11-56-23.gh-issue-91479.-dyGJX.rst
new file mode 100644
index 0000000000000..e131e91e75312
--- /dev/null
+++ b/Misc/NEWS.d/next/Core and Builtins/2022-04-12-11-56-23.gh-issue-91479.-dyGJX.rst	
@@ -0,0 +1 @@
+Replaced the ``__note__`` field of :exc:`BaseException` (added in an earlier version of 3.11) with the final design of :pep:`678`. Namely, :exc:`BaseException` gets an :meth:`add_note` method, and its ``__notes__`` field is created when necessary.
diff --git a/Objects/exceptions.c b/Objects/exceptions.c
index df10a3c2416e3..b26a0e93af484 100644
--- a/Objects/exceptions.c
+++ b/Objects/exceptions.c
@@ -47,7 +47,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->notes = NULL;
     self->traceback = self->cause = self->context = NULL;
     self->suppress_context = 0;
 
@@ -83,7 +83,7 @@ BaseException_clear(PyBaseExceptionObject *self)
 {
     Py_CLEAR(self->dict);
     Py_CLEAR(self->args);
-    Py_CLEAR(self->note);
+    Py_CLEAR(self->notes);
     Py_CLEAR(self->traceback);
     Py_CLEAR(self->cause);
     Py_CLEAR(self->context);
@@ -108,7 +108,7 @@ BaseException_traverse(PyBaseExceptionObject *self, visitproc visit, void *arg)
 {
     Py_VISIT(self->dict);
     Py_VISIT(self->args);
-    Py_VISIT(self->note);
+    Py_VISIT(self->notes);
     Py_VISIT(self->traceback);
     Py_VISIT(self->cause);
     Py_VISIT(self->context);
@@ -186,12 +186,62 @@ PyDoc_STRVAR(with_traceback_doc,
 "Exception.with_traceback(tb) --\n\
     set self.__traceback__ to tb and return self.");
 
+static inline PyBaseExceptionObject*
+_PyBaseExceptionObject_cast(PyObject *exc)
+{
+    assert(PyExceptionInstance_Check(exc));
+    return (PyBaseExceptionObject *)exc;
+}
+
+static PyObject *
+BaseException_add_note(PyObject *self, PyObject *note)
+{
+    if (!PyUnicode_Check(note)) {
+        PyErr_Format(PyExc_TypeError,
+                     "note must be a str, not '%s'",
+                     Py_TYPE(note)->tp_name);
+        return NULL;
+    }
+
+    if (!PyObject_HasAttr(self, &_Py_ID(__notes__))) {
+        PyObject *new_notes = PyList_New(0);
+        if (new_notes == NULL) {
+            return NULL;
+        }
+        if (PyObject_SetAttr(self, &_Py_ID(__notes__), new_notes) < 0) {
+            Py_DECREF(new_notes);
+            return NULL;
+        }
+        Py_DECREF(new_notes);
+    }
+    PyObject *notes = PyObject_GetAttr(self, &_Py_ID(__notes__));
+    if (notes == NULL) {
+        return NULL;
+    }
+    if (!PyList_Check(notes)) {
+        Py_DECREF(notes);
+        PyErr_SetString(PyExc_TypeError, "Cannot add note: __notes__ is not a list");
+        return NULL;
+    }
+    if (PyList_Append(notes, note) < 0) {
+        Py_DECREF(notes);
+        return NULL;
+    }
+    Py_DECREF(notes);
+    Py_RETURN_NONE;
+}
+
+PyDoc_STRVAR(add_note_doc,
+"Exception.add_note(note) --\n\
+    add a note to the exception");
 
 static PyMethodDef BaseException_methods[] = {
    {"__reduce__", (PyCFunction)BaseException_reduce, METH_NOARGS },
    {"__setstate__", (PyCFunction)BaseException_setstate, METH_O },
    {"with_traceback", (PyCFunction)BaseException_with_traceback, METH_O,
     with_traceback_doc},
+   {"add_note", (PyCFunction)BaseException_add_note, METH_O,
+    add_note_doc},
    {NULL, NULL, 0, NULL},
 };
 
@@ -220,33 +270,6 @@ 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))
 {
@@ -337,7 +360,6 @@ 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")},
@@ -347,14 +369,6 @@ static PyGetSetDef BaseException_getset[] = {
 };
 
 
-static inline PyBaseExceptionObject*
-_PyBaseExceptionObject_cast(PyObject *exc)
-{
-    assert(PyExceptionInstance_Check(exc));
-    return (PyBaseExceptionObject *)exc;
-}
-
-
 PyObject *
 PyException_GetTraceback(PyObject *self)
 {
@@ -910,9 +924,32 @@ exceptiongroup_subset(
     PyException_SetContext(eg, PyException_GetContext(orig));
     PyException_SetCause(eg, PyException_GetCause(orig));
 
-    PyObject *note = _PyBaseExceptionObject_cast(orig)->note;
-    Py_XINCREF(note);
-    _PyBaseExceptionObject_cast(eg)->note = note;
+    if (PyObject_HasAttr(orig, &_Py_ID(__notes__))) {
+        PyObject *notes = PyObject_GetAttr(orig, &_Py_ID(__notes__));
+        if (notes == NULL) {
+            goto error;
+        }
+        if (PySequence_Check(notes)) {
+            /* Make a copy so the parts have independent notes lists. */
+            PyObject *notes_copy = PySequence_List(notes);
+            Py_DECREF(notes);
+            if (notes_copy == NULL) {
+                goto error;
+            }
+            int res = PyObject_SetAttr(eg, &_Py_ID(__notes__), notes_copy);
+            Py_DECREF(notes_copy);
+            if (res < 0) {
+                goto error;
+            }
+        }
+        else {
+            /* __notes__ is supposed to be a list, and split() is not a
+             * good place to report earlier user errors, so we just ignore
+             * notes of non-sequence type.
+             */
+            Py_DECREF(notes);
+        }
+    }
 
     *result = eg;
     return 0;
@@ -1262,7 +1299,7 @@ is_same_exception_metadata(PyObject *exc1, PyObject *exc2)
     PyBaseExceptionObject *e1 = (PyBaseExceptionObject *)exc1;
     PyBaseExceptionObject *e2 = (PyBaseExceptionObject *)exc2;
 
-    return (e1->note == e2->note &&
+    return (e1->notes == e2->notes &&
             e1->traceback == e2->traceback &&
             e1->cause == e2->cause &&
             e1->context == e2->context);
diff --git a/Python/pythonrun.c b/Python/pythonrun.c
index e086f0f345c22..769c34ea161e0 100644
--- a/Python/pythonrun.c
+++ b/Python/pythonrun.c
@@ -1129,7 +1129,7 @@ print_exception_suggestions(struct exception_print_context *ctx,
 }
 
 static int
-print_exception_note(struct exception_print_context *ctx, PyObject *value)
+print_exception_notes(struct exception_print_context *ctx, PyObject *value)
 {
     PyObject *f = ctx->file;
 
@@ -1137,41 +1137,74 @@ print_exception_note(struct exception_print_context *ctx, PyObject *value)
         return 0;
     }
 
-    PyObject *note = PyObject_GetAttr(value, &_Py_ID(__note__));
-    if (note == NULL) {
+    if (!PyObject_HasAttr(value, &_Py_ID(__notes__))) {
+        return 0;
+    }
+    PyObject *notes = PyObject_GetAttr(value, &_Py_ID(__notes__));
+    if (notes == NULL) {
         return -1;
     }
-    if (!PyUnicode_Check(note)) {
-        Py_DECREF(note);
-        return 0;
+    if (!PySequence_Check(notes)) {
+        int res = 0;
+        if (write_indented_margin(ctx, f) < 0) {
+            res = -1;
+        }
+        PyObject *s = PyObject_Repr(notes);
+        if (s == NULL) {
+            PyErr_Clear();
+            res = PyFile_WriteString("<__notes__ repr() failed>", f);
+        }
+        else {
+            res = PyFile_WriteObject(s, f, Py_PRINT_RAW);
+            Py_DECREF(s);
+        }
+        Py_DECREF(notes);
+        return res;
     }
+    Py_ssize_t num_notes = PySequence_Length(notes);
+    PyObject *lines = NULL;
+    for (Py_ssize_t ni = 0; ni < num_notes; ni++) {
+        PyObject *note = PySequence_GetItem(notes, ni);
+        PyObject *note_str = PyObject_Str(note);
+        Py_DECREF(note);
 
-    PyObject *lines = PyUnicode_Splitlines(note, 1);
-    Py_DECREF(note);
+        if (note_str == NULL) {
+            PyErr_Clear();
+            if (PyFile_WriteString("<note str() failed>", f) < 0) {
+                goto error;
+            }
+        }
+        else {
+            lines = PyUnicode_Splitlines(note_str, 1);
+            Py_DECREF(note_str);
 
-    if (lines == NULL) {
-        return -1;
-    }
+            if (lines == NULL) {
+                goto error;
+            }
 
-    Py_ssize_t n = PyList_GET_SIZE(lines);
-    for (Py_ssize_t i = 0; i < n; i++) {
-        PyObject *line = PyList_GET_ITEM(lines, i);
-        assert(PyUnicode_Check(line));
-        if (write_indented_margin(ctx, f) < 0) {
-            goto error;
+            Py_ssize_t n = PyList_GET_SIZE(lines);
+            for (Py_ssize_t i = 0; i < n; i++) {
+                PyObject *line = PyList_GET_ITEM(lines, i);
+                assert(PyUnicode_Check(line));
+                if (write_indented_margin(ctx, f) < 0) {
+                    goto error;
+                }
+                if (PyFile_WriteObject(line, f, Py_PRINT_RAW) < 0) {
+                    goto error;
+                }
+            }
+            Py_CLEAR(lines);
         }
-        if (PyFile_WriteObject(line, f, Py_PRINT_RAW) < 0) {
+        if (PyFile_WriteString("\n", f) < 0) {
             goto error;
         }
     }
-    if (PyFile_WriteString("\n", f) < 0) {
-        goto error;
-    }
 
-    Py_DECREF(lines);
+    Py_DECREF(notes);
     return 0;
 error:
-    Py_DECREF(lines);
+    Py_XDECREF(lines);
+    Py_DECREF(notes);
     return -1;
 }
 
@@ -1206,7 +1239,7 @@ print_exception(struct exception_print_context *ctx, PyObject *value)
     if (PyFile_WriteString("\n", f) < 0) {
         goto error;
     }
-    if (print_exception_note(ctx, value) < 0) {
+    if (print_exception_notes(ctx, value) < 0) {
         goto error;
     }
 



More information about the Python-checkins mailing list