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.

Discussion:
coveralls: ## Pull Request Test Coverage Report for [Build 1782929038](https://coveralls.io/builds/46156514)

* **0** of **0**   changed or added relevant lines in **0** files are covered.
* **167** unchanged lines in **7** files lost coverage.
* Overall coverage decreased (**-4.7%**) to **76.791%**

---


|  Files with Coverage Reduction | New Missed Lines | % |
| :-----|--------------|--: |
| [/opt/hostedtoolcache/PyPy/3.8.12/x64/lib/pypy3.8/site-packages/exceptiongroup/_exceptions.py](https://coveralls.io/builds/46156514/source?filename=%2Fopt%2Fhostedtoolcache%2FPyPy%2F3.8.12%2Fx64%2Flib%2Fpypy3.8%2Fsite-packages%2Fexceptiongroup%2F_exceptions.py#L92) | 7 | 93.97% |
| [/opt/hostedtoolcache/Python/3.10.2/x64/lib/python3.10/site-packages/exceptiongroup/_formatting.py](https://coveralls.io/builds/46156514/source?filename=%2Fopt%2Fhostedtoolcache%2FPython%2F3.10.2%2Fx64%2Flib%2Fpython3.10%2Fsite-packages%2Fexceptiongroup%2F_formatting.py#L117) | 10 | 89.92% |
| [/opt/hostedtoolcache/Python/3.7.12/x64/lib/python3.7/site-packages/exceptiongroup/_formatting.py](https://coveralls.io/builds/46156514/source?filename=%2Fopt%2Fhostedtoolcache%2FPython%2F3.7.12%2Fx64%2Flib%2Fpython3.7%2Fsite-packages%2Fexceptiongroup%2F_formatting.py#L117) | 10 | 89.08% |
| [/opt/hostedtoolcache/Python/3.8.12/x64/lib/python3.8/site-packages/exceptiongroup/_formatting.py](https://coveralls.io/builds/46156514/source?filename=%2Fopt%2Fhostedtoolcache%2FPython%2F3.8.12%2Fx64%2Flib%2Fpython3.8%2Fsite-packages%2Fexceptiongroup%2F_formatting.py#L117) | 10 | 89.08% |
| [/opt/hostedtoolcache/Python/3.9.10/x64/lib/python3.9/site-packages/exceptiongroup/_formatting.py](https://coveralls.io/builds/46156514/source?filename=%2Fopt%2Fhostedtoolcache%2FPython%2F3.9.10%2Fx64%2Flib%2Fpython3.9%2Fsite-packages%2Fexceptiongroup%2F_formatting.py#L117) | 10 | 89.08% |
| [/opt/hostedtoolcache/Python/3.11.0-alpha.4/x64/lib/python3.11/site-packages/exceptiongroup/_formatting.py](https://coveralls.io/builds/46156514/source?filename=%2Fopt%2Fhostedtoolcache%2FPython%2F3.11.0-alpha.4%2Fx64%2Flib%2Fpython3.11%2Fsite-packages%2Fexceptiongroup%2F_formatting.py#L54) | 36 | 0% |
| [/opt/hostedtoolcache/PyPy/3.8.12/x64/lib/pypy3.8/site-packages/exceptiongroup/_formatting.py](https://coveralls.io/builds/46156514/source?filename=%2Fopt%2Fhostedtoolcache%2FPyPy%2F3.8.12%2Fx64%2Flib%2Fpypy3.8%2Fsite-packages%2Fexceptiongroup%2F_formatting.py#L38) | 84 | 20.17% |
<!-- | **Total:** | **167** |  | -->

|  Totals | [![Coverage Status](https://coveralls.io/builds/46156514/badge)](https://coveralls.io/builds/46156514) |
| :-- | --: |
| Change from base [Build 1774204149](https://coveralls.io/builds/46095853): |  -4.7% |
| Covered Lines: | 1436 |
| Relevant Lines: | 1870 |

---
##### 💛  - [Coveralls](https://coveralls.io)

agronholm: One more thing – there is a commented out assert [here](https://github.com/agronholm/exceptiongroup/blob/main/tests/test_exceptions.py#L488) that should be uncommented.
agronholm: It seems that you force pushed the change that broke the CI so I don't know what is going wrong.
Zac-HD: Side note, it might be useful to go to `settings / actions / general` and change from `Require approval for first-time contributors` to `Require approval for first-time contributors who are new to GitHub`.
agronholm: > Side note, it might be useful to go to `settings / actions / general` and change from `Require approval for first-time contributors` to `Require approval for first-time contributors who are new to GitHub`.

I've done that now.

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
     +------------------------------------
 """
     )