|
| 1 | +PEP: 678 |
| 2 | +Title: Enriching Exceptions with Notes |
| 3 | +Author: Zac Hatfield-Dodds <zac@zhd.dev> |
| 4 | +Sponsor: Irit Katriel |
| 5 | +Status: Draft |
| 6 | +Type: Standards Track |
| 7 | +Content-Type: text/x-rst |
| 8 | +Requires: 654 |
| 9 | +Created: 20-Dec-2021 |
| 10 | +Python-Version: 3.11 |
| 11 | +Post-History: |
| 12 | + |
| 13 | + |
| 14 | +Abstract |
| 15 | +======== |
| 16 | +Exception objects are typically initialized with a message that describes the |
| 17 | +error which has occurred. Because further information may be available when the |
| 18 | +exception is caught and re-raised, this PEP proposes to add a ``.__note__`` |
| 19 | +attribute and update the builtin traceback formatting code to include it in |
| 20 | +the formatted traceback following the exception string. |
| 21 | + |
| 22 | +This is particularly useful in relation to :pep:`654` ``ExceptionGroup`` s, which |
| 23 | +make previous workarounds ineffective or confusing. Use cases have been identified |
| 24 | +in the standard library, Hypothesis package, and common code patterns with retries. |
| 25 | + |
| 26 | + |
| 27 | +Motivation |
| 28 | +========== |
| 29 | +When an exception is created in order to be raised, it is usually initialized |
| 30 | +with information that describes the error that has occurred. There are cases |
| 31 | +where it is useful to add information after the exception was caught. |
| 32 | +For example, |
| 33 | + |
| 34 | +- testing libraries may wish to show the values involved in a failing assertion, |
| 35 | + or the steps to reproduce a failure (e.g. ``pytest`` and ``hypothesis`` ; example below). |
| 36 | +- code with retries may wish to note which iteration or timestamp raised which |
| 37 | + error - especially if re-raising them in an ``ExceptionGroup`` |
| 38 | +- programming environments for novices can provide more detailed descriptions |
| 39 | + of various errors, and tips for resolving them (e.g. ``friendly-traceback`` ). |
| 40 | + |
| 41 | +Existing approaches must pass this additional information around while keeping |
| 42 | +it in sync with the state of raised, and potentially caught or chained, exceptions. |
| 43 | +This is already error-prone, and made more difficult by :pep:`654` ``ExceptionGroup`` s, |
| 44 | +so the time is right for a built-in solution. We therefore propose to add a mutable |
| 45 | +field ``__note__`` to ``BaseException`` , which can be assigned a string - and |
| 46 | +if assigned, is automatically displayed in formatted tracebacks. |
| 47 | + |
| 48 | + |
| 49 | +Example usage |
| 50 | +------------- |
| 51 | + |
| 52 | + >>> try: |
| 53 | + ... raise TypeError('bad type') |
| 54 | + ... except Exception as e: |
| 55 | + ... e.__note__ = 'Add some information' |
| 56 | + ... raise |
| 57 | + ... |
| 58 | + Traceback (most recent call last): |
| 59 | + File "<stdin>", line 2, in <module> |
| 60 | + TypeError: bad type |
| 61 | + Add some information |
| 62 | + >>> |
| 63 | + |
| 64 | +When collecting exceptions into an exception group, we may want |
| 65 | +to add context information for the individual errors. In the following |
| 66 | +example with `Hypothesis' proposed support for ExceptionGroup |
| 67 | +<https://github.com/HypothesisWorks/hypothesis/pull/3191>`__, each |
| 68 | +exception includes a note of the minimal failing example:: |
| 69 | + |
| 70 | + from hypothesis import given, strategies as st, target |
| 71 | + |
| 72 | + @given(st.integers()) |
| 73 | + def test(x): |
| 74 | + assert x < 0 |
| 75 | + assert x > 0 |
| 76 | + |
| 77 | + |
| 78 | + + Exception Group Traceback (most recent call last): |
| 79 | + | File "test.py", line 4, in test |
| 80 | + | def test(x): |
| 81 | + | |
| 82 | + | File "hypothesis/core.py", line 1202, in wrapped_test |
| 83 | + | raise the_error_hypothesis_found |
| 84 | + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ |
| 85 | + | ExceptionGroup: Hypothesis found 2 distinct failures. |
| 86 | + +-+---------------- 1 ---------------- |
| 87 | + | Traceback (most recent call last): |
| 88 | + | File "test.py", line 6, in test |
| 89 | + | assert x > 0 |
| 90 | + | ^^^^^^^^^^^^ |
| 91 | + | AssertionError: assert -1 > 0 |
| 92 | + | |
| 93 | + | Falsifying example: test( |
| 94 | + | x=-1, |
| 95 | + | ) |
| 96 | + +---------------- 2 ---------------- |
| 97 | + | Traceback (most recent call last): |
| 98 | + | File "test.py", line 5, in test |
| 99 | + | assert x < 0 |
| 100 | + | ^^^^^^^^^^^^ |
| 101 | + | AssertionError: assert 0 < 0 |
| 102 | + | |
| 103 | + | Falsifying example: test( |
| 104 | + | x=0, |
| 105 | + | ) |
| 106 | + +------------------------------------ |
| 107 | + |
| 108 | + |
| 109 | +Specification |
| 110 | +============= |
| 111 | + |
| 112 | +``BaseException`` gains a new mutable attribute ``__note__`` , which defaults to |
| 113 | +``None`` and may have a string assigned. When an exception with a note is displayed, |
| 114 | +the note is displayed immediately after the exception. |
| 115 | + |
| 116 | +Assigning a new string value overrides an existing note; if concatenation is desired |
| 117 | +users are responsible for implementing it with e.g.:: |
| 118 | + |
| 119 | + e.__note__ = msg if e.__note__ is None else e.__note__ + "\n" + msg |
| 120 | + |
| 121 | +It is an error to assign a non-string-or-``None`` value to ``__note__`` , |
| 122 | +or to attempt to delete the attribute. |
| 123 | + |
| 124 | +``BaseExceptionGroup.subgroup`` and ``BaseExceptionGroup.split`` |
| 125 | +copy the ``__note__`` of the original exception group to the parts. |
| 126 | + |
| 127 | + |
| 128 | +Backwards Compatibility |
| 129 | +======================= |
| 130 | + |
| 131 | +System-defined or "dunder" names (following the pattern ``__*__`` ) are part of the |
| 132 | +language specification, with unassigned names reserved for future use and subject |
| 133 | +to breakage without warning [1]_. |
| 134 | + |
| 135 | +We are also unaware of any code which *would* be broken by adding ``__note__`` ; |
| 136 | +assigning to a ``.__note__`` attribute already *works* on current versions of |
| 137 | +Python - the note just won't be displayed with the traceback and exception message. |
| 138 | + |
| 139 | + |
| 140 | + |
| 141 | +How to Teach This |
| 142 | +================= |
| 143 | + |
| 144 | +The ``__note__`` attribute will be documented as part of the language standard, |
| 145 | +and explained as part of the tutorial "Errors and Exceptions" [2]_. |
| 146 | + |
| 147 | + |
| 148 | + |
| 149 | +Reference Implementation |
| 150 | +======================== |
| 151 | + |
| 152 | +``BaseException.__note__`` was implemented in [3]_ and released in CPython 3.11.0a3, |
| 153 | +following discussions related to :pep:`654`. [4]_ [5]_ [6]_ |
| 154 | + |
| 155 | + |
| 156 | + |
| 157 | +Rejected Ideas |
| 158 | +============== |
| 159 | + |
| 160 | +Use ``print()`` (or ``logging`` , etc.) |
| 161 | +--------------------------------------- |
| 162 | +Reporting explanatory or contextual information about an error by printing or logging |
| 163 | +has historically been an acceptable workaround. However, we dislike the way this |
| 164 | +separates the content from the exception object it refers to - which can lead to |
| 165 | +"orphan" reports if the error was caught and handled later, or merely significant |
| 166 | +difficulties working out which explanation corresponds to which error. |
| 167 | +The new ``ExceptionGroup`` type intensifies these existing challenges. |
| 168 | + |
| 169 | +Keeping the ``__note__`` attached to the exception object, like the traceback, |
| 170 | +eliminates these problems. |
| 171 | + |
| 172 | + |
| 173 | +``raise Wrapper(explanation) from err`` |
| 174 | +--------------------------------------- |
| 175 | +An alternative pattern is to use exception chaining: by raising a 'wrapper' exception |
| 176 | +containing the context or explanation ``from`` the current exception, we avoid the |
| 177 | +separation challenges from ``print()`` . However, this has two key problems. |
| 178 | + |
| 179 | +First, it changes the type of the exception, which is often a breaking change for |
| 180 | +downstream code. We consider *always* raising a ``Wrapper`` exception unacceptably |
| 181 | +inelegant; but because custom exception types might have any number of required |
| 182 | +arguments we can't always create an instance of the *same* type with our explanation. |
| 183 | +In cases where the exact exception type is known this can work, such as the standard |
| 184 | +library ``http.client`` code [7]_, but not for libraries which call user code. |
| 185 | + |
| 186 | +Second, exception chaining reports several lines of additional detail, which are |
| 187 | +distracting for experienced users and can be very confusing for beginners. |
| 188 | +For example, six of the eleven lines reported for this simple example relate to |
| 189 | +exception chaining, and are unnecessary with ``BaseException.__note__`` : |
| 190 | + |
| 191 | +.. code-block:: python |
| 192 | +
|
| 193 | + class Explanation(Exception): |
| 194 | + def __str__(self): |
| 195 | + return "\n" + str(self) |
| 196 | +
|
| 197 | + try: |
| 198 | + raise AssertionError("Failed!") |
| 199 | + except Exception as e: |
| 200 | + raise Explanation("You can reproduce this error by ...") from e |
| 201 | +
|
| 202 | +.. code-block:: |
| 203 | +
|
| 204 | + $ python example.py |
| 205 | + Traceback (most recent call last): |
| 206 | + File "example.py", line 6, in <module> |
| 207 | + raise AssertionError(why) |
| 208 | + AssertionError: Failed! |
| 209 | + # These lines are |
| 210 | + The above exception was the direct cause of the following exception: # confusing for new |
| 211 | + # users, and they |
| 212 | + Traceback (most recent call last): # only exist due |
| 213 | + File "example.py", line 8, in <module> # to implementation |
| 214 | + raise Explanation(msg) from e # constraints :-( |
| 215 | + Explanation: # Hence this PEP! |
| 216 | + You can reproduce this error by ... |
| 217 | +
|
| 218 | +
|
| 219 | +Subclass Exception and add ``__note__`` downstream |
| 220 | +-------------------------------------------------- |
| 221 | +Traceback printing is built into the C code, and reimplemented in pure Python in |
| 222 | +traceback.py. To get ``err.__note__`` printed from a downstream implementation |
| 223 | +would *also* require writing custom traceback-printing code; while this could |
| 224 | +be shared between projects and reuse some pieces of traceback.py we prefer to |
| 225 | +implement this once, upstream. |
| 226 | + |
| 227 | +Custom exception types could implement their ``__str__`` method to include our |
| 228 | +proposed ``__note__`` semantics, but this would be rarely and inconsistently |
| 229 | +applicable. |
| 230 | + |
| 231 | + |
| 232 | +Store notes in ``ExceptionGroup`` s |
| 233 | +----------------------------------- |
| 234 | +Initial discussions proposed making a more focussed change by thinking about how to |
| 235 | +associate messages with the nested exceptions in ``ExceptionGroup`` s, such as a list |
| 236 | +of notes or mapping of exceptions to notes. However, this would force a remarkably |
| 237 | +awkward API and retains a lesser form of the cross-referencing problem discussed |
| 238 | +under "use ``print()`` " above; if this PEP is rejected we prefer the status quo. |
| 239 | +Finally, of course, ``__note__`` is not only useful with ``ExceptionGroup`` s! |
| 240 | + |
| 241 | + |
| 242 | + |
| 243 | +References |
| 244 | +========== |
| 245 | + |
| 246 | +.. [1] https://docs.python.org/3/reference/lexical_analysis.html#reserved-classes-of-identifiers |
| 247 | +.. [2] https://github.com/python/cpython/pull/30158 |
| 248 | +.. [3] https://github.com/python/cpython/pull/29880 |
| 249 | +.. [4] https://discuss.python.org/t/accepting-pep-654-exception-groups-and-except/10813/9 |
| 250 | +.. [5] https://github.com/python/cpython/pull/28569#discussion_r721768348 |
| 251 | +.. [6] https://bugs.python.org/issue45607 |
| 252 | +.. [7] https://github.com/python/cpython/blob/69ef1b59983065ddb0b712dac3b04107c5059735/Lib/http/client.py#L596-L597 |
| 253 | +
|
| 254 | +
|
| 255 | +
|
| 256 | +Copyright |
| 257 | +========= |
| 258 | + |
| 259 | +This document is placed in the public domain or under the |
| 260 | +CC0-1.0-Universal license, whichever is more permissive. |
| 261 | + |
| 262 | + |
| 263 | +.. |
| 264 | + Local Variables: |
| 265 | + mode: indented-text |
| 266 | + indent-tabs-mode: nil |
| 267 | + sentence-end-double-space: t |
| 268 | + fill-column: 70 |
| 269 | + coding: utf-8 |
| 270 | + End: |
0 commit comments