Notice: While JavaScript is not essential for this website, your interaction with the content will be limited. Please turn JavaScript on for the full experience.

PEP 678 -- Enriching Exceptions with Notes

PEP:678
Title:Enriching Exceptions with Notes
Author:Zac Hatfield-Dodds <zac at zhd.dev>
Sponsor:Irit Katriel
Status:Draft
Type:Standards Track
Requires:654
Created:20-Dec-2021
Python-Version:3.11
Post-History:

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 a .__note__ attribute and update the builtin traceback formatting code to include it 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 (e.g. friendly-traceback).

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 mutable field __note__ to BaseException, which can be assigned a string - and if assigned, is automatically displayed in formatted tracebacks.

Example usage

>>> try:
...     raise TypeError('bad type')
... except Exception as e:
...     e.__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

__note__ 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 choosing a convention for an attribute like e.g. err._parse_errors = ... on the error or ExceptionGroup [1] [2]

As a rule of thumb, prefer exception chaining when the error is going to be re-raised or handled as an individual error, and prefer __note__ when you are collecting multiple exception objects to handle together or later. [3]

Specification

BaseException gains a new mutable attribute __note__, which defaults to None and may have a string assigned. When an exception with a note is displayed, the note is displayed immediately after the exception.

Assigning a new string value overrides an existing note; if concatenation is desired users are responsible for implementing it with e.g.:

e.__note__ = msg if e.__note__ is None else e.__note__ + "\n" + msg

It is an error to assign a non-string-or-None value to __note__, or to attempt to delete the attribute.

BaseExceptionGroup.subgroup and BaseExceptionGroup.split copy the __note__ of the original exception group to the parts.

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

We are also unaware of any code which would be broken by adding __note__; assigning to a .__note__ attribute already works on current versions of Python - the note just won't be displayed with the traceback and exception message.

How to Teach This

The __note__ attribute will be documented as part of the language standard, and explained as part of the tutorial "Errors and Exceptions" [5].

Reference Implementation

BaseException.__note__ was implemented in [6] and released in CPython 3.11.0a3, following discussions related to PEP 654. [7] [8] [9]

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 __note__ attached to the exception object, like the traceback, 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 [10], 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.__note__:

class Explanation(Exception):
    def __str__(self):
        return "\n" + str(self)

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 the following exception:    # 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 __note__.

Subclass Exception and add __note__ downstream

Traceback printing is built into the C code, and reimplemented in pure Python in traceback.py. To get err.__note__ 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 we prefer to implement this once, upstream.

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

Store notes in ExceptionGroups

Initial discussions proposed making a more focussed change by thinking about how to associate messages with the nested exceptions in ExceptionGroup s, such as a list of notes or mapping of exceptions to notes. However, this would force a remarkably awkward API and retains a lesser form of the cross-referencing problem discussed under "use print()" above; if this PEP is rejected we prefer the status quo. Finally, of course, __note__ is not only useful with ExceptionGroup s!

Possible Future Enhancements

In addition to rejected alternatives, there have been a range of suggestions which we believe should be deferred to a future version, when we have more experience with the uses (and perhaps misuses) of __note__.

Allow any object, and cast to string for display

We have not identified any scenario where libraries would want to do anything but either concatenate or replace notes, and so the additional complexity and interoperability challenges do not seem justified.

Permitting any object would also force any future structured API to change the behaviour of already-legal code, whereas expanding the permitted contents of __note__ from strings to include other objects is fully backwards-compatible. In the absence of any proposed use-case (see also Non-goals), we prefer to begin with a restrictive API that can be relaxed later.

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 are open to this idea, but do not see it as a core part of the proposal of this PEP as it can be added as an enhancement later.

@contextlib.contextmanager
def add_exc_note(note: str):
    try:
        yield
    except Exception as err:
        if err.__note__ is None:
            err.__note__ = note
        else:
            err.__note__ = err.__note__ + "\n\n" + 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.

Source: https://github.com/python/peps/blob/master/pep-0678.rst