Skip to content

Commit d4c4a76

Browse files
authored
gh-89770: Implement PEP-678 - Exception notes (GH-31317)
1 parent 7fa3a5a commit d4c4a76

File tree

12 files changed

+384
-145
lines changed

12 files changed

+384
-145
lines changed

Doc/library/exceptions.rst

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,13 +126,20 @@ The following exceptions are used mostly as base classes for other exceptions.
126126
tb = sys.exc_info()[2]
127127
raise OtherException(...).with_traceback(tb)
128128

129-
.. attribute:: __note__
129+
.. method:: add_note(note)
130130

131-
A mutable field which is :const:`None` by default and can be set to a string.
132-
If it is not :const:`None`, it is included in the traceback. This field can
133-
be used to enrich exceptions after they have been caught.
131+
Add the string ``note`` to the exception's notes which appear in the standard
132+
traceback after the exception string. A :exc:`TypeError` is raised if ``note``
133+
is not a string.
134134

135-
.. versionadded:: 3.11
135+
.. versionadded:: 3.11
136+
137+
.. attribute:: __notes__
138+
139+
A list of the notes of this exception, which were added with :meth:`add_note`.
140+
This attribute is created when :meth:`add_note` is called.
141+
142+
.. versionadded:: 3.11
136143

137144

138145
.. exception:: Exception
@@ -907,7 +914,7 @@ their subgroups based on the types of the contained exceptions.
907914

908915
The nesting structure of the current exception is preserved in the result,
909916
as are the values of its :attr:`message`, :attr:`__traceback__`,
910-
:attr:`__cause__`, :attr:`__context__` and :attr:`__note__` fields.
917+
:attr:`__cause__`, :attr:`__context__` and :attr:`__notes__` fields.
911918
Empty nested groups are omitted from the result.
912919

913920
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.
924931

925932
Returns an exception group with the same :attr:`message`,
926933
:attr:`__traceback__`, :attr:`__cause__`, :attr:`__context__`
927-
and :attr:`__note__` but which wraps the exceptions in ``excs``.
934+
and :attr:`__notes__` but which wraps the exceptions in ``excs``.
928935

929936
This method is used by :meth:`subgroup` and :meth:`split`. A
930937
subclass needs to override it in order to make :meth:`subgroup`

Doc/whatsnew/3.11.rst

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -157,12 +157,15 @@ The :option:`-X` ``no_debug_ranges`` option and the environment variable
157157
See :pep:`657` for more details. (Contributed by Pablo Galindo, Batuhan Taskaya
158158
and Ammar Askar in :issue:`43950`.)
159159

160-
Exceptions can be enriched with a string ``__note__``
161-
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
160+
Exceptions can be enriched with notes (PEP 678)
161+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
162+
163+
The :meth:`add_note` method was added to :exc:`BaseException`. It can be
164+
used to enrich exceptions with context information which is not available
165+
at the time when the exception is raised. The notes added appear in the
166+
default traceback. See :pep:`678` for more details. (Contributed by
167+
Irit Katriel in :issue:`45607`.)
162168

163-
The ``__note__`` field was added to :exc:`BaseException`. It is ``None``
164-
by default but can be set to a string which is added to the exception's
165-
traceback. (Contributed by Irit Katriel in :issue:`45607`.)
166169

167170
Other Language Changes
168171
======================

Include/cpython/pyerrors.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
/* PyException_HEAD defines the initial segment of every exception class. */
88
#define PyException_HEAD PyObject_HEAD PyObject *dict;\
9-
PyObject *args; PyObject *note; PyObject *traceback;\
9+
PyObject *args; PyObject *notes; PyObject *traceback;\
1010
PyObject *context; PyObject *cause;\
1111
char suppress_context;
1212

Include/internal/pycore_global_strings.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ struct _Py_global_strings {
155155
STRUCT_FOR_ID(__newobj__)
156156
STRUCT_FOR_ID(__newobj_ex__)
157157
STRUCT_FOR_ID(__next__)
158-
STRUCT_FOR_ID(__note__)
158+
STRUCT_FOR_ID(__notes__)
159159
STRUCT_FOR_ID(__or__)
160160
STRUCT_FOR_ID(__orig_class__)
161161
STRUCT_FOR_ID(__origin__)

Include/internal/pycore_runtime_init.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -778,7 +778,7 @@ extern "C" {
778778
INIT_ID(__newobj__), \
779779
INIT_ID(__newobj_ex__), \
780780
INIT_ID(__next__), \
781-
INIT_ID(__note__), \
781+
INIT_ID(__notes__), \
782782
INIT_ID(__or__), \
783783
INIT_ID(__orig_class__), \
784784
INIT_ID(__origin__), \

Lib/test/test_exception_group.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -567,7 +567,9 @@ def leaves(exc):
567567
self.assertIs(eg.__cause__, part.__cause__)
568568
self.assertIs(eg.__context__, part.__context__)
569569
self.assertIs(eg.__traceback__, part.__traceback__)
570-
self.assertIs(eg.__note__, part.__note__)
570+
self.assertEqual(
571+
getattr(eg, '__notes__', None),
572+
getattr(part, '__notes__', None))
571573

572574
def tbs_for_leaf(leaf, eg):
573575
for e, tbs in leaf_generator(eg):
@@ -632,7 +634,7 @@ def level3(i):
632634
try:
633635
nested_group()
634636
except ExceptionGroup as e:
635-
e.__note__ = f"the note: {id(e)}"
637+
e.add_note(f"the note: {id(e)}")
636638
eg = e
637639

638640
eg_template = [
@@ -728,6 +730,35 @@ def exc(ex):
728730
self.assertMatchesTemplate(
729731
rest, ExceptionGroup, [ValueError(1)])
730732

733+
def test_split_copies_notes(self):
734+
# make sure each exception group after a split has its own __notes__ list
735+
eg = ExceptionGroup("eg", [ValueError(1), TypeError(2)])
736+
eg.add_note("note1")
737+
eg.add_note("note2")
738+
orig_notes = list(eg.__notes__)
739+
match, rest = eg.split(TypeError)
740+
self.assertEqual(eg.__notes__, orig_notes)
741+
self.assertEqual(match.__notes__, orig_notes)
742+
self.assertEqual(rest.__notes__, orig_notes)
743+
self.assertIsNot(eg.__notes__, match.__notes__)
744+
self.assertIsNot(eg.__notes__, rest.__notes__)
745+
self.assertIsNot(match.__notes__, rest.__notes__)
746+
eg.add_note("eg")
747+
match.add_note("match")
748+
rest.add_note("rest")
749+
self.assertEqual(eg.__notes__, orig_notes + ["eg"])
750+
self.assertEqual(match.__notes__, orig_notes + ["match"])
751+
self.assertEqual(rest.__notes__, orig_notes + ["rest"])
752+
753+
def test_split_does_not_copy_non_sequence_notes(self):
754+
# __notes__ should be a sequence, which is shallow copied.
755+
# If it is not a sequence, the split parts don't get any notes.
756+
eg = ExceptionGroup("eg", [ValueError(1), TypeError(2)])
757+
eg.__notes__ = 123
758+
match, rest = eg.split(TypeError)
759+
self.assertFalse(hasattr(match, '__notes__'))
760+
self.assertFalse(hasattr(rest, '__notes__'))
761+
731762

732763
class NestedExceptionGroupSubclassSplitTest(ExceptionGroupSplitTestBase):
733764

Lib/test/test_exceptions.py

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -547,26 +547,32 @@ def testAttributes(self):
547547
'pickled "%r", attribute "%s' %
548548
(e, checkArgName))
549549

550-
def test_note(self):
550+
def test_notes(self):
551551
for e in [BaseException(1), Exception(2), ValueError(3)]:
552552
with self.subTest(e=e):
553-
self.assertIsNone(e.__note__)
554-
e.__note__ = "My Note"
555-
self.assertEqual(e.__note__, "My Note")
553+
self.assertFalse(hasattr(e, '__notes__'))
554+
e.add_note("My Note")
555+
self.assertEqual(e.__notes__, ["My Note"])
556556

557557
with self.assertRaises(TypeError):
558-
e.__note__ = 42
559-
self.assertEqual(e.__note__, "My Note")
558+
e.add_note(42)
559+
self.assertEqual(e.__notes__, ["My Note"])
560560

561-
e.__note__ = "Your Note"
562-
self.assertEqual(e.__note__, "Your Note")
561+
e.add_note("Your Note")
562+
self.assertEqual(e.__notes__, ["My Note", "Your Note"])
563563

564-
with self.assertRaises(TypeError):
565-
del e.__note__
566-
self.assertEqual(e.__note__, "Your Note")
564+
del e.__notes__
565+
self.assertFalse(hasattr(e, '__notes__'))
566+
567+
e.add_note("Our Note")
568+
self.assertEqual(e.__notes__, ["Our Note"])
567569

568-
e.__note__ = None
569-
self.assertIsNone(e.__note__)
570+
e.__notes__ = 42
571+
self.assertEqual(e.__notes__, 42)
572+
573+
with self.assertRaises(TypeError):
574+
e.add_note("will not work")
575+
self.assertEqual(e.__notes__, 42)
570576

571577
def testWithTraceback(self):
572578
try:

0 commit comments

Comments
 (0)