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
Resolution:
https://discuss.python.org/t/pep-678-enriching-exceptions-with-notes/13374/100

python:BaseException.add_note and python:BaseException.__notes__

See 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 ExceptionGroups,
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 ExceptionGroups, 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, 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 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
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. 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 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.

Reference Implementation

Following discussions related to PEP 654[2], an early version of this
proposal was implemented in and released in CPython 3.11.0a3, with a
mutable string-or-none __note__ attribute.

CPython PR #31317 implements .add_note() and __notes__.

Rejected Ideas

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, 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():

    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

    $ 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 Exceptions, just store them in ExceptionGroups

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, 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 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:

    @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

Copyright

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

[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.