PEP: 678
Title: Enriching Exceptions with Notes
Author: Zac Hatfield-Dodds <zac@zhd.dev>
Sponsor: Irit Katriel
Discussions-To: https://discuss.python.org/t/pep-678-enriching-exceptions-with-notes/13374
Status: Final
Type: Standards Track
Content-Type: text/x-rst
Requires: 654
Created: 20-Dec-2021
Python-Version: 3.11
Post-History: `27-Jan-2022 <https://discuss.python.org/t/pep-678-enriching-exceptions-with-notes/13374>`__
Resolution: https://discuss.python.org/t/pep-678-enriching-exceptions-with-notes/13374/100

.. canonical-doc:: :meth:`python:BaseException.add_note` and :attr:`python:BaseException.__notes__`

   See :ref:`python:tut-exception-notes` for a user-focused tutorial.

Abstract
========
Exception objects are typically initialized with a message that describes the
error which has occurred.  Because further information may be available when
the exception is caught and re-raised, or included in an ``ExceptionGroup``,
this PEP proposes to add ``BaseException.add_note(note)``, a
``.__notes__`` attribute holding a list of notes so added, and to
update the builtin traceback formatting code to include notes in the formatted
traceback following the exception string.

This is particularly useful in relation to :pep:`654` ``ExceptionGroup``\ s,
which make previous workarounds ineffective or confusing.  Use cases have been
identified in the standard library, Hypothesis and ``cattrs`` packages, and
common code patterns with retries.


Motivation
==========
When an exception is created in order to be raised, it is usually initialized
with information that describes the error that has occurred. There are cases
where it is useful to add information after the exception was caught. For
example,

- testing libraries may wish to show the values involved in a failing
  assertion, or the steps to reproduce a failure (e.g. ``pytest`` and
  ``hypothesis``; example below).
- code which retries an operation on error may wish to associate an iteration,
  timestamp, or other explanation with each of several errors - especially if
  re-raising them in an ``ExceptionGroup``.
- programming environments for novices can provide more detailed descriptions
  of various errors, and tips for resolving them.

Existing approaches must pass this additional information around while keeping
it in sync with the state of raised, and potentially caught or chained,
exceptions. This is already error-prone, and made more difficult by :pep:`654`
``ExceptionGroup``\ s, so the time is right for a built-in solution.  We
therefore propose to add:

- a new method ``BaseException.add_note(note: str)``,
- ``BaseException.__notes__``, a list of note strings added using
  ``.add_note()``, and
- support in the builtin traceback formatting code such that notes are
  displayed in the formatted traceback following the exception string.


Example usage
-------------
::

   >>> try:
   ...     raise TypeError('bad type')
   ... except Exception as e:
   ...     e.add_note('Add some information')
   ...     raise
   ...
   Traceback (most recent call last):
     File "<stdin>", line 2, in <module>
   TypeError: bad type
   Add some information
   >>>

When collecting exceptions into an exception group, we may want to add context
information for the individual errors. In the following example with
`Hypothesis' proposed support for ExceptionGroup
<https://github.com/HypothesisWorks/hypothesis/pull/3191>`__, each exception
includes a note of the minimal failing example::

    from hypothesis import given, strategies as st, target

    @given(st.integers())
    def test(x):
        assert x < 0
        assert x > 0


    + Exception Group Traceback (most recent call last):
    |   File "test.py", line 4, in test
    |     def test(x):
    |
    |   File "hypothesis/core.py", line 1202, in wrapped_test
    |     raise the_error_hypothesis_found
    |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    | ExceptionGroup: Hypothesis found 2 distinct failures.
    +-+---------------- 1 ----------------
        | Traceback (most recent call last):
        |   File "test.py", line 6, in test
        |     assert x > 0
        |     ^^^^^^^^^^^^
        | AssertionError: assert -1 > 0
        |
        | Falsifying example: test(
        |     x=-1,
        | )
        +---------------- 2 ----------------
        | Traceback (most recent call last):
        |   File "test.py", line 5, in test
        |     assert x < 0
        |     ^^^^^^^^^^^^
        | AssertionError: assert 0 < 0
        |
        | Falsifying example: test(
        |     x=0,
        | )
        +------------------------------------


Non-goals
---------
Tracking multiple notes as a list, rather than by concatenating strings when
notes are added, is intended to maintain the distinction between the
individual notes. This might be required in specialized use cases, such
as translation of the notes by packages like ``friendly-traceback``.

However, ``__notes__`` is *not* intended to carry structured data.  If your
note is for use by a program rather than display to a human, `we recommend
<https://discuss.python.org/t/accepting-pep-654-exception-groups-and-except/10813/26>`__
instead (or additionally) choosing a convention for an attribute, e.g.
``err._parse_errors = ...`` on the error or ``ExceptionGroup``.

As a rule of thumb, we suggest that you should prefer `exception chaining
<https://docs.python.org/3/tutorial/errors.html#exception-chaining>`__ when the
error is going to be re-raised or handled as an individual error, and prefer
``.add_note()`` when you want to avoid changing the exception type or
are collecting multiple exception objects to handle together. [1]_


Specification
=============

``BaseException`` gains a new method ``.add_note(note: str)``. If ``note`` is
a string, ``.add_note(note)`` appends it to the ``__notes__`` list, creating
the attribute if it does not already exist.  If ``note`` is not a string,
``.add_note()`` raises ``TypeError``.

Libraries may clear existing notes by modifying or deleting the ``__notes__``
list, if it has been created, including clearing all notes with
``del err.__notes__``.  This allows full control over the attached notes,
without overly complicating the API or adding multiple names to
``BaseException.__dict__``.

When an exception is displayed by the interpreter's builtin traceback-rendering code,
its notes (if there are any) appear immediately after the exception message, in the order
in which they were added, with each note starting on a new line.

If ``__notes__`` has been created, ``BaseExceptionGroup.subgroup`` and
``BaseExceptionGroup.split`` create a new list for each new instance, containing
the same contents as the original exception group's ``__notes__``.

We *do not* specify the expected behaviour when users have assigned a non-list
value to ``__notes__``, or a list which contains non-string elements.
Implementations might choose to emit warnings, discard or ignore bad values,
convert them to strings, raise an exception, or do something else entirely.


Backwards Compatibility
=======================

System-defined or "dunder" names (following the pattern ``__*__``) are part of
the language specification, with `unassigned names reserved for future use and
subject to breakage without warning
<https://docs.python.org/3/reference/lexical_analysis.html#reserved-classes-of-identifiers>`__.
We are also unaware of any code which *would* be broken by adding ``__notes__``.

We were also unable to find any code which would be broken by the addition of
``BaseException.add_note()``: while searching Google and `GitHub finds several
definitions <https://grep.app/search?q=.add_note%28&filter[lang][0]=Python>`__
of an ``.add_note()`` method, none of them are on a subclass of
``BaseException``.


How to Teach This
=================

The ``add_note()`` method and ``__notes__`` attribute will be documented as
part of the language standard, and explained as part of `the "Errors and
Exceptions" tutorial <https://github.com/python/cpython/pull/30441>`__.


Reference Implementation
========================

Following discussions related to :pep:`654` [2]_, an early version of this
proposal was `implemented in <https://github.com/python/cpython/pull/29880>`__
and released in CPython 3.11.0a3, with a mutable string-or-none ``__note__``
attribute.

`CPython PR #31317 <https://github.com/python/cpython/pull/31317>`__
implements ``.add_note()`` and ``__notes__``.


Rejected Ideas
==============

.. _print_idea:

Use ``print()`` (or ``logging``, etc.)
--------------------------------------
Reporting explanatory or contextual information about an error by printing or
logging has historically been an acceptable workaround.  However, we dislike
the way this separates the content from the exception object it refers to -
which can lead to "orphan" reports if the error was caught and handled later,
or merely significant difficulties working out which explanation corresponds to
which error. The new ``ExceptionGroup`` type intensifies these existing
challenges.

Keeping the ``__notes__`` attached to the exception object, in the same way as
the ``__traceback__`` attribute, eliminates these problems.


``raise Wrapper(explanation) from err``
---------------------------------------
An alternative pattern is to use exception chaining: by raising a 'wrapper'
exception containing the context or explanation ``from`` the current exception,
we avoid the separation challenges from ``print()``.  However, this has two key
problems.

First, it changes the type of the exception, which is often a breaking change
for downstream code.  We consider *always* raising a ``Wrapper`` exception
unacceptably inelegant; but because custom exception types might have any
number of required arguments we can't always create an instance of the *same*
type with our explanation. In cases where the exact exception type is known
this can work, such as the standard library ``http.client`` `code
<https://github.com/python/cpython/blob/69ef1b59983065ddb0b712dac3b04107c5059735/Lib/http/client.py#L596-L597>`__,
but not for libraries which call user code.

Second, exception chaining reports several lines of additional detail, which
are distracting for experienced users and can be very confusing for beginners.
For example, six of the eleven lines reported for this simple example relate to
exception chaining, and are unnecessary with ``BaseException.add_note()``:

.. code-block:: python

    class Explanation(Exception):
        def __str__(self):
            return "\n" + str(self.args[0])

    try:
        raise AssertionError("Failed!")
    except Exception as e:
        raise Explanation("You can reproduce this error by ...") from e

.. code-block::

    $ python example.py
    Traceback (most recent call last):
    File "example.py", line 6, in <module>
        raise AssertionError(why)
    AssertionError: Failed!
                                                        # These lines are
    The above exception was the direct cause of ...     # confusing for new
                                                        # users, and they
    Traceback (most recent call last):                  # only exist due
    File "example.py", line 8, in <module>              # to implementation
        raise Explanation(msg) from e                   # constraints :-(
    Explanation:                                        # Hence this PEP!
    You can reproduce this error by ...

**In cases where these two problems do not apply, we encourage use of exception
chaining rather than** ``__notes__``.


An assignable ``__note__`` attribute
------------------------------------
The first draft and implementation of this PEP defined a single attribute
``__note__``, which defaulted to ``None`` but could have a string assigned.
This is substantially simpler if, and only if, there is at most one note.

To promote interoperability and support translation of error messages by
libraries such as ``friendly-traceback``, without resorting to dubious parsing
heuristics, we therefore settled on the ``.add_note()``-and-``__notes__`` API.


Subclass Exception and add note support downstream
--------------------------------------------------
Traceback printing is built into the C code, and reimplemented in pure Python
in ``traceback.py``. To get ``err.__notes__`` printed from a downstream
implementation would *also* require writing custom traceback-printing code;
while this could be shared between projects and reuse some pieces of
traceback.py [3]_ we prefer to implement this once, upstream.

Custom exception types could implement their ``__str__`` method to include our
proposed ``__notes__`` semantics, but this would be rarely and inconsistently
applicable.


Don't attach notes to ``Exception``\ s, just store them in ``ExceptionGroup``\ s
--------------------------------------------------------------------------------
The initial motivation for this PEP was to associate a note with each error
in an ``ExceptionGroup``.  At the cost of a remarkably awkward API and the
cross-referencing problem discussed `above <print_idea>`__, this
use-case could be supported by storing notes on the ``ExceptionGroup``
instance instead of on each exception it contains.

We believe that the cleaner interface, and other use-cases described above,
are sufficient to justify the more general feature proposed by this PEP.


Add a helper function ``contextlib.add_exc_note()``
---------------------------------------------------
It `was suggested
<https://www.reddit.com/r/Python/comments/rmrvxv/pep_678_enriching_exceptions_with_notes/hptbul1/>`__
that we add a utility such as the one below to the standard library. We do not
see this idea as core to the proposal of this PEP, and thus leave it for later
or downstream implementation - perhaps based on this example code:

.. code-block:: python

    @contextlib.contextmanager
    def add_exc_note(note: str):
        try:
            yield
        except Exception as err:
            err.add_note(note)
            raise

    with add_exc_note(f"While attempting to frobnicate {item=}"):
        frobnicate_or_raise(item)


Augment the ``raise`` statement
-------------------------------
One discussion proposed ``raise Exception() with "note contents"``, but this
does not address the original motivation of compatibility with
``ExceptionGroup``.

Furthermore, we do not believe that the problem we are solving requires or
justifies new language syntax.


Acknowledgements
================
We wish to thank the many people who have assisted us through conversation,
code review, design advice, and implementation: Adam Turner, Alex Grönholm,
André Roberge, Barry Warsaw, Brett Cannon, CAM Gerlach, Carol Willing, Damian,
Erlend Aasland, Etienne Pot, Gregory Smith, Guido van Rossum, Irit Katriel,
Jelle Zijlstra, Ken Jin, Kumar Aditya, Mark Shannon, Matti Picus, Petr
Viktorin, Will McGugan, and pseudonymous commenters on Discord and Reddit.


References
==========

.. [1] this principle was established in the 2003 mail thread which led to :pep:`3134`,
       and included a proposal for a group-of-exceptions type!
       https://mail.python.org/pipermail/python-dev/2003-January/032492.html
.. [2] particularly those at https://bugs.python.org/issue45607,
       https://discuss.python.org/t/accepting-pep-654-exception-groups-and-except/10813/9,
       https://github.com/python/cpython/pull/28569#discussion_r721768348, and
.. [3] We note that the ``exceptiongroup`` backport package maintains an exception
       hook and monkeypatch for ``TracebackException`` for Pythons older than 3.11,
       and encourage library authors to avoid creating additional and incompatible
       backports.  We also reiterate our preference for builtin support which
       makes such measures unnecessary.


Copyright
=========

This document is placed in the public domain or under the
CC0-1.0-Universal license, whichever is more permissive.


..
    Local Variables:
    mode: indented-text
    indent-tabs-mode: nil
    sentence-end-double-space: t
    fill-column: 70
    coding: utf-8
    End:
