PEP 785 – Neue Methoden für einfacheres Behandeln von ExceptionGroups
- Autor:
- Zac Hatfield-Dodds <zac at zhd.dev>
- Sponsor:
- Gregory P. Smith <greg at krypto.org>
- Discussions-To:
- Discourse thread
- Status:
- Entwurf
- Typ:
- Standards Track
- Erstellt:
- 08-Apr-2025
- Python-Version:
- 3.14
- Post-History:
- 13-Apr-2025
Zusammenfassung
Da PEP 654 ExceptionGroup in der Python-Community weit verbreitet ist, haben sich einige gängige, aber umständliche Muster herausgebildet. Wir schlagen daher vor, zwei neue Methoden zu Ausnahmeobjekten hinzuzufügen:
BaseExceptionGroup.leaf_exceptions(), die die "Leaf"-Ausnahmen als Liste zurückgibt, wobei jeder Traceback aus beliebigen Zwischengruppen zusammengesetzt ist.BaseException.preserve_context(), ein Kontextmanager, der das Attributself.__context__vonselfspeichert und wiederherstellt, sodass das erneute Auslösen der Ausnahme innerhalb eines anderen Handlers den vorhandenen Kontext nicht überschreibt.
Wir erwarten, dass dies in vielen Fällen mittlerer Komplexität eine prägnantere Formulierung der Fehlerbehandlungslogik ermöglicht. Ohne diese würden Exception-Group-Handler weiterhin Zwischen-Tracebacks verwerfen und __context__-Ausnahmen falsch behandeln, was für jeden, der asynchronen Code debuggt, nachteilig ist.
Motivation
Da Exception Groups weit verbreitet sind, schreiben Bibliotheksautoren und Endbenutzer oft Code, um einzelne Leaf-Ausnahmen zu verarbeiten oder darauf zu reagieren, z. B. bei der Implementierung von Middleware, Fehlerprotokollierung oder Antwort-Handlern in einem Web-Framework.
GitHub-Suche ergab vier Implementierungen von leaf_exceptions() unter verschiedenen Namen in den ersten sechzig Treffern, von denen keine Tracebacks handhabt.[1] Dieselbe Suche ergab dreizehn Fälle, in denen .leaf_exceptions() verwendet werden könnte. Wir glauben daher, dass die Bereitstellung einer Methode für den BaseException-Typ mit korrekter Traceback-Bewahrung die Fehlerbehandlung und das Debugging im gesamten Ökosystem verbessern wird.
Der Aufstieg von Exception Groups hat auch das erneute Auslösen von Ausnahmen, die von einem früheren Handler abgefangen wurden, viel häufiger gemacht: Beispielsweise könnte Web-Server-Middleware HTTPException entpacken, wenn dies die einzige Leaf-Ausnahme einer Gruppe ist.
except* HTTPException as group:
first, *rest = group.leaf_exceptions() # get the whole traceback :-)
if not rest:
raise first
raise
Dieser harmlos erscheinende Code hat jedoch ein Problem: raise first wird als Nebeneffekt first.__context__ = group ausführen. Dies verwirft den ursprünglichen Kontext des Fehlers, der entscheidende Informationen darüber enthalten kann, warum die Ausnahme ausgelöst wurde. In vielen Produktionsanwendungen führen Tracebacks auch dazu, dass sie von Hunderten von Zeilen zu Dutzenden oder sogar Hunderttausenden von Zeilen anwachsen – ein Umfang, der das Verständnis von Fehlern erheblich erschwert.
Eine neue Methode BaseException.preserve_context() wäre eine auffindbare, lesbare und einfach zu bedienende Lösung für diese Fälle.
Spezifikation
Eine neue Methode leaf_exceptions() wird zu BaseExceptionGroup hinzugefügt, mit der folgenden Signatur
def leaf_exceptions(self, *, fix_tracebacks=True) -> list[BaseException]:
"""
Return a flat list of all 'leaf' exceptions in the group.
If fix_tracebacks is True, each leaf will have the traceback replaced
with a composite so that frames attached to intermediate groups are
still visible when debugging. Pass fix_tracebacks=False to disable
this modification, e.g. if you expect to raise the group unchanged.
"""
Eine neue Methode preserve_context() wird zu BaseException hinzugefügt, mit der folgenden Signatur
def preserve_context(self) -> contextlib.AbstractContextManager[Self]:
"""
Context manager that preserves the exception's __context__ attribute.
When entering the context, the current values of __context__ is saved.
When exiting, the saved value is restored, which allows raising an
exception inside an except block without changing its context chain.
"""
Anwendungsbeispiel
# We're an async web framework, where user code can raise an HTTPException
# to return a particular HTTP error code to the client. However, it may
# (or may not) be raised inside a TaskGroup, so we need to use `except*`;
# and if there are *multiple* such exceptions we'll treat that as a bug.
try:
user_code_here()
except* HTTPException as group:
first, *rest = group.leaf_exceptions()
if rest:
raise # handled by internal-server-error middleware
... # logging, cache updates, etc.
with first.preserve_context():
raise first
Ohne .preserve_context() müsste dieser Code entweder
- arrangieren, dass die Ausnahme *nach* dem
except*-Block ausgelöst wird, was den Code in nicht trivialen Fällen schwer nachvollziehbar macht, oder - den vorhandenen
__context__derfirst-Ausnahme verwerfen und ihn durch eineExceptionGroupersetzen, die nur ein Implementierungsdetail ist, oder try/exceptanstelle vonexcept*verwenden und die Möglichkeit behandeln, dass die Gruppe überhaupt keineHTTPExceptionenthält,[2] oder- die Semantik von
.preserve_context()inline implementieren; obwohl dies nicht *buchstäblich noch nie dagewesen* ist, ist es immer noch sehr selten.
Abwärtskompatibilität
Das Hinzufügen neuer Methoden zu integrierten Klassen, insbesondere zu so weit verbreiteten wie BaseException, kann erhebliche Auswirkungen haben. Die GitHub-Suche zeigt jedoch keine Kollisionen für diese Methodennamen (null Treffer[3] und drei irrelevante Treffer). Wenn benutzerdefinierte Methoden mit diesen Namen im privaten Code existieren, werden sie die in der PEP vorgeschlagenen überschatten, ohne das Laufzeitverhalten zu ändern.
Wie man das lehrt
Die Arbeit mit Exception Groups ist ein Thema für Fortgeschrittene und Experten, das für Anfänger wahrscheinlich nicht relevant ist. Wir schlagen daher vor, dieses Thema über die Dokumentation und über "Just-in-Time"-Feedback von statischen Analysewerkzeugen zu vermitteln. In fortgeschrittenen Kursen empfehlen wir, .leaf_exceptions() zusammen mit den Methoden .split() und .subgroup() zu lehren und .preserve_context() als fortgeschrittene Option zur Lösung spezifischer Probleme zu erwähnen.
Sowohl die API-Referenz als auch das bestehende Tutorial zu ExceptionGroup sollten aktualisiert werden, um die neuen Methoden zu demonstrieren und zu erklären. Das Tutorial sollte Beispiele für gängige Muster enthalten, bei denen .leaf_exceptions() und .preserve_context() die Fehlerbehandlungslogik vereinfachen. Nachgelagerte Bibliotheken, die häufig Exception Groups verwenden, könnten ähnliche Dokumentationen enthalten.
Wir haben auch Lint-Regeln für die Aufnahme in flake8-async entwickelt, die die Verwendung von .leaf_exceptions() beim Iterieren über group.exceptions oder beim erneuten Auslösen einer Leaf-Ausnahme vorschlagen und die Verwendung von .preserve_context() beim erneuten Auslösen einer Leaf-Ausnahme innerhalb eines except*-Blocks vorschlagen, wenn dadurch ein vorhandener Kontext überschrieben würde.
Referenzimplementierung
Obwohl die Methoden für integrierte Ausnahmen in C implementiert werden, wenn diese PEP angenommen wird, hoffen wir, dass die folgende Python-Implementierung auf älteren Python-Versionen nützlich sein wird und die beabsichtigte Semantik demonstrieren kann.
Wir haben diese Hilfsfunktionen bei der Arbeit mit ExceptionGroups in einer großen Produktionscodebasis als sehr nützlich empfunden.
Eine Hilfsfunktion leaf_exceptions()
import copy
import types
from types import TracebackType
def leaf_exceptions(
self: BaseExceptionGroup, *, fix_traceback: bool = True
) -> list[BaseException]:
"""
Return a flat list of all 'leaf' exceptions.
If fix_tracebacks is True, each leaf will have the traceback replaced
with a composite so that frames attached to intermediate groups are
still visible when debugging. Pass fix_tracebacks=False to disable
this modification, e.g. if you expect to raise the group unchanged.
"""
def _flatten(group: BaseExceptionGroup, parent_tb: TracebackType | None = None):
group_tb = group.__traceback__
combined_tb = _combine_tracebacks(parent_tb, group_tb)
result = []
for exc in group.exceptions:
if isinstance(exc, BaseExceptionGroup):
result.extend(_flatten(exc, combined_tb))
elif fix_tracebacks:
tb = _combine_tracebacks(combined_tb, exc.__traceback__)
result.append(exc.with_traceback(tb))
else:
result.append(exc)
return result
return _flatten(self)
def _combine_tracebacks(
tb1: TracebackType | None,
tb2: TracebackType | None,
) -> TracebackType | None:
"""
Combine two tracebacks, putting tb1 frames before tb2 frames.
If either is None, return the other.
"""
if tb1 is None:
return tb2
if tb2 is None:
return tb1
# Convert tb1 to a list of frames
frames = []
current = tb1
while current is not None:
frames.append((current.tb_frame, current.tb_lasti, current.tb_lineno))
current = current.tb_next
# Create a new traceback starting with tb2
new_tb = tb2
# Add frames from tb1 to the beginning (in reverse order)
for frame, lasti, lineno in reversed(frames):
new_tb = types.TracebackType(
tb_next=new_tb, tb_frame=frame, tb_lasti=lasti, tb_lineno=lineno
)
return new_tb
Ein Kontextmanager preserve_context()
class preserve_context:
def __init__(self, exc: BaseException):
self.__exc = exc
self.__context = exc.__context__
def __enter__(self):
return self.__exc
def __exit__(self, exc_type, exc_value, traceback):
assert exc_value is self.__exc, f"did not raise the expected exception {self.__exc!r}"
exc_value.__context__ = self.__context
del self.__context # break gc cycle
Abgelehnte Ideen
Dienstprogrammfunktionen anstelle von Methoden hinzufügen
Anstatt Methoden zu Ausnahmen hinzuzufügen, könnten wir Dienstprogrammfunktionen wie die obigen Referenzimplementierungen bereitstellen. Es gibt jedoch mehrere Gründe, Methoden zu bevorzugen: Es gibt keinen offensichtlichen Ort, an dem Hilfsfunktionen leben sollten, sie nehmen genau ein Argument entgegen, das eine Instanz von BaseException sein muss, und Methoden sind sowohl bequemer als auch auffindbarer.
Hinzufügen von BaseException.as_group() (oder Gruppenmethoden)
Unsere Untersuchung des ExceptionGroup-bezogenen Fehlerbehandlungscodes beobachtete auch viele Fälle duplizierter Logik, um sowohl eine reine Ausnahme als auch dieselbe Art von Ausnahme innerhalb einer Gruppe zu behandeln (oft falsch, was .leaf_exceptions() motiviert).
Wir haben kurzzeitig vorgeschlagen, das Hinzufügen von .split(...) und .subgroup(...) Methoden zu allen Ausnahmen, bevor wir feststellten, dass .leaf_exceptions() uns dies zu umständlich erscheinen ließ. Als sauberere Alternative skizzierten wir eine .as_group() Methode.
def as_group(self):
if not isinstance(self, BaseExceptionGroup):
return BaseExceptionGroup("", [self])
return self
Die Anwendung dieser Methode zur Refaktorierung bestehenden Codes war jedoch eine vernachlässigbare Verbesserung gegenüber dem Schreiben der trivialen Inline-Version. Wir hoffen auch, dass viele aktuelle Anwendungsfälle für eine solche Methode durch except* abgedeckt werden, wenn ältere Python-Versionen das Ende ihres Lebenszyklus erreichen.
Wir empfehlen, ein Rezept zum "Konvertieren in eine Gruppe" für deduplizierte Fehlerbehandlung zu dokumentieren, anstatt gruppenbezogene Methoden zu BaseException hinzuzufügen.
Hinzufügen von e.raise_with_preserved_context() anstelle eines Kontextmanagers
Wir bevorzugen die Kontextmanager-Form, da sie raise ... from ... ermöglicht, wenn der Benutzer den __cause__ neu festlegen möchte, und insgesamt etwas weniger magisch und weniger verlockend ist, in Fällen verwendet zu werden, in denen dies unangemessen wäre. Wir können uns jedoch überzeugen lassen, wenn andere diese Form bevorzugen.
Zusätzliche Attribute beibehalten
Wir haben uns gegen die Beibehaltung der Attribute __cause__ und __suppress_context__ entschieden, da diese durch das erneute Auslösen der Ausnahme nicht verändert werden, und wir bevorzugen die Unterstützung von raise exc from None oder raise exc from cause_exc in Verbindung mit with exc.preserve_context():.
Ebenso haben wir die Beibehaltung des Attributs __traceback__ in Erwägung gezogen und uns dagegen entschieden, da die zusätzliche raise ...-Anweisung ein wichtiger Hinweis beim Verständnis einiger Fehler sein kann. Wenn Endbenutzer einen Frame aus dem Traceback entfernen möchten, können sie dies mit einem separaten Kontextmanager tun.
Fußnoten
Urheberrecht
Dieses Dokument wird in die Public Domain oder unter die CC0-1.0-Universal-Lizenz gestellt, je nachdem, welche Lizenz permissiver ist.
Quelle: https://github.com/python/peps/blob/main/peps/pep-0785.rst
Zuletzt geändert: 2025-04-17 17:42:59 GMT