Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

PEP 785 – 异常组 (ExceptionGroup) 更易处理的新方法

作者:
Zac Hatfield-Dodds <zac at zhd.dev>
发起人:
Gregory P. Smith <greg at krypto.org>
讨论至:
Discourse 帖子
状态:
草案
类型:
标准跟踪
创建日期:
2025年4月8日
Python 版本:
3.14
发布历史:
2025年4月13日

目录

摘要

随着 PEP 654 ExceptionGroup 在 Python 社区中得到广泛应用,一些常见但笨拙的模式也随之出现。因此,我们提议为异常对象添加两个新方法:

  • BaseExceptionGroup.leaf_exceptions(),返回“叶”异常列表,每个追踪链都由任何中间组复合而成。
  • BaseException.preserve_context(),一个上下文管理器,用于保存和恢复 selfself.__context__ 属性,以便在另一个处理程序中重新抛出异常时不会覆盖现有上下文。

我们期望这能在许多中等复杂度的场景中实现更简洁的错误处理逻辑。没有它们,异常组处理程序将继续丢弃中间追踪链并错误处理 __context__ 异常,从而损害任何调试异步代码的人。

动机

随着异常组的广泛使用,库作者和最终用户经常编写代码来处理或响应单个叶异常,例如在实现 Web 框架中的中间件、错误日志记录或响应处理程序时。

搜索 GitHub 在前六十个结果中找到了四种以不同名称实现的 leaf_exceptions(),其中没有一个处理追踪链。[1] 相同的搜索发现了十三种可以使用 .leaf_exceptions() 的情况。因此,我们相信在 BaseException 类型上提供一个具有适当追踪链保存功能的方法将改善整个生态系统的错误处理和调试体验。

异常组的兴起也使得重新抛出由早期处理程序捕获的异常变得更加普遍:例如,如果 HTTPException 是组中唯一的叶子,Web 服务器中间件可能会解包它

except* HTTPException as group:
    first, *rest = group.leaf_exceptions()  # get the whole traceback :-)
    if not rest:
        raise first
    raise

然而,这段看似无害的代码存在一个问题:raise first 会将 first.__context__ = group 作为副作用执行。这会丢弃错误的原始上下文,其中可能包含理解异常为何被抛出的关键信息。在许多生产应用程序中,这还会导致追踪链从数百行膨胀到数万甚至 数十万行——这个数量使得理解错误比应有的困难得多。

新的 BaseException.preserve_context() 方法将是解决这些情况的可发现、可读且易于使用的解决方案。

规范

一个新的方法 leaf_exceptions() 将被添加到 BaseExceptionGroup,具有以下签名:

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

一个新的方法 preserve_context() 将被添加到 BaseException,具有以下签名:

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

使用示例

# 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

如果没有 .preserve_context(),这段代码将不得不:

  • 安排在 except* 块*之后*抛出异常,这使得在非平凡情况下代码难以理解,或者
  • 丢弃 first 异常的现有 __context__,用一个仅仅是实现细节的 ExceptionGroup 替换它,或者
  • 使用 try/except 而不是 except*,处理组根本不包含 HTTPException 的可能性,[2] 或者
  • 内联实现 .preserve_context() 的语义;虽然这并非*闻所未闻*,但仍然非常罕见。

向后兼容性

向内置类添加新方法,尤其是像 BaseException 这样广泛使用的类,可能会产生重大影响。然而,GitHub 搜索显示这些方法名称没有冲突(分别为 零命中[3]三个不相关的命中)。如果私有代码中存在具有这些名称的用户定义方法,它们将遮蔽 PEP 中提议的方法,而不会改变运行时行为。

如何教授此内容

使用异常组是一个中级到高级的话题,不太可能出现在初级程序员的身上。因此,我们建议通过文档以及静态分析工具的即时反馈来教授这个主题。在中级课程中,我们建议将 .leaf_exceptions().split().subgroup() 方法一起教授,并将 .preserve_context() 作为一个高级选项来解决特定的痛点。

API 参考和现有 ExceptionGroup 教程 都应更新以演示和解释新方法。教程应包含常见模式的示例,其中 .leaf_exceptions().preserve_context() 有助于简化错误处理逻辑。下游库(经常使用异常组)可以包含类似的文档。

我们还设计了包含在 flake8-async 中的 lint 规则,当迭代 group.exceptions 或重新抛出叶异常时,它会建议使用 .leaf_exceptions(),并建议在 except* 块内部重新抛出叶异常会覆盖任何现有上下文时使用 .preserve_context()

参考实现

如果此 PEP 被接受,内置异常的方法将用 C 语言实现,但我们希望以下 Python 实现对旧版本的 Python 有用,并且可以演示预期的语义。

我们发现在处理大型生产代码库中的 ExceptionGroup 时,这些辅助函数非常有用。

一个 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

一个 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

被拒绝的想法

添加实用函数而非方法

与其向异常添加方法,不如提供像上面参考实现那样的实用函数。然而,有几个理由更倾向于方法:辅助函数没有明显的放置位置,它们只接受一个必须是 BaseException 实例的参数,而且方法既方便又更易发现。

添加 BaseException.as_group()(或组方法)

我们对 ExceptionGroup 相关错误处理代码的调查还发现许多重复的逻辑,用于同时处理裸异常和组内部的同类异常(通常不正确,这促使了 .leaf_exceptions() 的出现)。

我们曾简要提议为所有异常添加 .split(...).subgroup(...) 方法,但后来考虑 .leaf_exceptions() 让我们觉得这太笨拙了。作为一种更简洁的替代方案,我们勾勒了一个 .as_group() 方法

def as_group(self):
    if not isinstance(self, BaseExceptionGroup):
        return BaseExceptionGroup("", [self])
    return self

然而,将此方法应用于重构现有代码,其改进与编写简单的内联版本相比微不足道。我们也希望,随着旧版 Python 生命周期结束,except* 将解决许多目前需要此类方法的情况。

我们建议文档中包含一个“转换为组”的配方,用于去重错误处理,而不是向 BaseException 添加与组相关的方法。

添加 e.raise_with_preserved_context() 而非上下文管理器

我们更喜欢上下文管理器形式,因为它允许用户在希望(重新)设置 __cause__ 时使用 raise ... from ...,并且总体上它不那么神奇,也不太容易在不适用的情况下使用。不过,如果其他人更喜欢这种形式,我们也可以重新考虑。

保留附加属性

我们决定不保留 __cause____suppress_context__ 属性,因为它们在重新抛出异常时不会改变,并且我们更倾向于支持 raise exc from Noneraise exc from cause_excwith exc.preserve_context(): 一起使用。

同样,我们曾考虑保留 __traceback__ 属性,但后来决定不保留,因为额外的 raise ... 语句在理解某些错误时可能是一个重要线索。如果最终用户希望从追踪中弹出帧,他们可以通过单独的上下文管理器实现。

脚注


来源:https://github.com/python/peps/blob/main/peps/pep-0785.rst

最后修改:2025-04-17 17:42:59 GMT