Repository: agronholm/exceptiongroup

Issue:
Display `__note__` from monkeypatched `TracebackException` type
:wave: [PEP-678](https://www.python.org/dev/peps/pep-0678/) author here - I've been looking at how to port Hypothesis over to use `ExceptionGroup` (https://github.com/HypothesisWorks/hypothesis/pull/3191).  If this backport started displaying the `__note__` string if present, I'd be able and very happy to throw out all* the old print-based code entirely!  I'm happy to implement this, if you'd be willing to take a PR and cut the release?

\*aside from a print-based, single-exception-only fallback for if monkeypatching is disabled on Python <3.11.

Comments:

Patch:
diff --git a/README.rst b/README.rst
index 30fcdf5..c98ba00 100644
--- a/README.rst
+++ b/README.rst
@@ -19,9 +19,6 @@ It contains the following:
 * An exception hook that handles formatting of exception groups through
   ``TracebackException`` (installed on import)
 
-The only difference with the Python 3.11 standard library implementation is that there
-is no ``__note__`` attribute in ``BaseExceptionGroup`` or ``ExceptionGroup``.
-
 If this package is imported on Python 3.11 or later, the built-in implementations of the
 exception group classes are used instead, ``TracebackException`` is not monkey patched
 and the exception hook won't be installed.
diff --git a/src/exceptiongroup/_exceptions.py b/src/exceptiongroup/_exceptions.py
index dda85e0..0d40a6b 100644
--- a/src/exceptiongroup/_exceptions.py
+++ b/src/exceptiongroup/_exceptions.py
@@ -70,6 +70,7 @@ class BaseExceptionGroup(BaseException, Generic[EBase]):
         super().__init__(__message, __exceptions, *args)
         self._message = __message
         self._exceptions = __exceptions
+        self.__note__ = None
 
     @property
     def message(self) -> str:
@@ -149,7 +150,9 @@ class BaseExceptionGroup(BaseException, Generic[EBase]):
         return matching_group, nonmatching_group
 
     def derive(self: T, __excs: Sequence[EBase]) -> T:
-        return BaseExceptionGroup(self.message, __excs)
+        eg = BaseExceptionGroup(self.message, __excs)
+        eg.__note__ = self.__note__
+        return eg
 
     def __str__(self) -> str:
         return self.message
diff --git a/src/exceptiongroup/_formatting.py b/src/exceptiongroup/_formatting.py
index 4bf973c..6c073c4 100644
--- a/src/exceptiongroup/_formatting.py
+++ b/src/exceptiongroup/_formatting.py
@@ -15,11 +15,11 @@ from ._exceptions import BaseExceptionGroup
 max_group_width = 15
 max_group_depth = 10
 _cause_message = (
-    "\nThe above exception was the direct cause " "of the following exception:\n\n"
+    "\nThe above exception was the direct cause of the following exception:\n\n"
 )
 
 _context_message = (
-    "\nDuring handling of the above exception, " "another exception occurred:\n\n"
+    "\nDuring handling of the above exception, another exception occurred:\n\n"
 )
 
 
@@ -51,6 +51,7 @@ def traceback_exception_init(
         _seen=_seen,
         **kwargs,
     )
+    self.__note__ = getattr(exc_value, "__note__", None) if exc_value else None
 
     seen_was_none = _seen is None
 
@@ -134,6 +135,8 @@ def traceback_exception_format(self, *, chain=True, _ctx=None):
                 yield from _ctx.emit("Traceback (most recent call last):\n")
                 yield from _ctx.emit(exc.stack.format())
             yield from _ctx.emit(exc.format_exception_only())
+            if isinstance(exc.__note__, str):
+                yield from _ctx.emit(line + "\n" for line in exc.__note__.split("\n"))
         elif _ctx.exception_group_depth > max_group_depth:
             # exception group, but depth exceeds limit
             yield from _ctx.emit(f"... (max_group_depth is {max_group_depth})\n")
@@ -151,6 +154,8 @@ def traceback_exception_format(self, *, chain=True, _ctx=None):
                 yield from _ctx.emit(exc.stack.format())
 
             yield from _ctx.emit(exc.format_exception_only())
+            if isinstance(exc.__note__, str):
+                yield from _ctx.emit(line + "\n" for line in exc.__note__.split("\n"))
             num_excs = len(exc.exceptions)
             if num_excs <= max_group_width:
                 n = num_excs


Test patch:
diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py
index 86f0540..2a19971 100644
--- a/tests/test_exceptions.py
+++ b/tests/test_exceptions.py
@@ -21,7 +21,9 @@ class TestExceptionGroupTypeHierarchy(unittest.TestCase):
 class BadConstructorArgs(unittest.TestCase):
     def test_bad_EG_construction__too_few_args(self):
         if sys.version_info >= (3, 11):
-            MSG = "function takes exactly 2 arguments"
+            MSG = (
+                r"BaseExceptionGroup.__new__\(\) takes exactly 2 arguments \(1 given\)"
+            )
         else:
             MSG = (
                 r"__new__\(\) missing 1 required positional argument: "
@@ -35,7 +37,9 @@ class BadConstructorArgs(unittest.TestCase):
 
     def test_bad_EG_construction__too_many_args(self):
         if sys.version_info >= (3, 11):
-            MSG = "function takes exactly 2 arguments"
+            MSG = (
+                r"BaseExceptionGroup.__new__\(\) takes exactly 2 arguments \(3 given\)"
+            )
         else:
             MSG = r"__new__\(\) takes 3 positional arguments but 4 were given"
 
@@ -61,7 +65,7 @@ class BadConstructorArgs(unittest.TestCase):
             ExceptionGroup("eg", [])
 
     def test_bad_EG_construction__nested_non_exceptions(self):
-        MSG = r"Item [0-9]+ of second argument \(exceptions\)" " is not an exception"
+        MSG = r"Item [0-9]+ of second argument \(exceptions\) is not an exception"
         with self.assertRaisesRegex(ValueError, MSG):
             ExceptionGroup("expect instance, not type", [OSError])
         with self.assertRaisesRegex(ValueError, MSG):
@@ -175,6 +179,14 @@ class ExceptionGroupFields(unittest.TestCase):
         with self.assertRaises(AttributeError):
             eg.exceptions = [OSError("xyz")]
 
+    def test_note_exists_and_is_string_or_none(self):
+        eg = create_simple_eg()
+
+        note = "This is a happy note for the exception group"
+        self.assertIs(eg.__note__, None)
+        eg.__note__ = note
+        self.assertIs(eg.__note__, note)
+
 
 class ExceptionGroupTestBase(unittest.TestCase):
     def assertMatchesTemplate(self, exc, exc_type, template):
@@ -485,7 +497,7 @@ class ExceptionGroupSplitTestBase(ExceptionGroupTestBase):
                 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.assertIs(eg.__note__, part.__note__)
 
         def tbs_for_leaf(leaf, eg):
             for e, tbs in leaf_generator(eg):
diff --git a/tests/test_formatting.py b/tests/test_formatting.py
index 43060a7..5dc4f05 100644
--- a/tests/test_formatting.py
+++ b/tests/test_formatting.py
@@ -13,11 +13,13 @@ def test_formatting(capsys):
     try:
         raise RuntimeError("bar")
     except RuntimeError as exc:
+        exc.__note__ = "Note from bar handler"
         exceptions.append(exc)
 
     try:
         raise ExceptionGroup("test message", exceptions)
     except ExceptionGroup as exc:
+        exc.__note__ = "Displays notes attached to the group too"
         sys.excepthook(type(exc), exc, exc.__traceback__)
 
     lineno = test_formatting.__code__.co_firstlineno
@@ -34,9 +36,10 @@ def test_formatting(capsys):
     assert output == (
         f"""\
   + Exception Group Traceback (most recent call last):
-  |   File "{__file__}", line {lineno + 13}, in test_formatting
+  |   File "{__file__}", line {lineno + 14}, in test_formatting
   |     raise ExceptionGroup("test message", exceptions){underline1}
   | {module_prefix}ExceptionGroup: test message
+  | Displays notes attached to the group too
   +-+---------------- 1 ----------------
     | Traceback (most recent call last):
     |   File "{__file__}", line {lineno + 3}, in test_formatting
@@ -47,6 +50,7 @@ def test_formatting(capsys):
     |   File "{__file__}", line {lineno + 8}, in test_formatting
     |     raise RuntimeError("bar"){underline3}
     | RuntimeError: bar
+    | Note from bar handler
     +------------------------------------
 """
     )