385 lines
15 KiB
ReStructuredText
385 lines
15 KiB
ReStructuredText
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: Draft
|
|
Type: Standards Track
|
|
Content-Type: text/x-rst
|
|
Requires: 654
|
|
Created: 20-Dec-2021
|
|
Python-Version: 3.11
|
|
Post-History: 27-Jan-2022
|
|
|
|
|
|
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 new lists on each new instance containing
|
|
the same contents as the original exception group.
|
|
|
|
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)
|
|
|
|
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:
|