如何在Python 3中从回溯信息中省略函数包装器?

20

问题

魅影危机

假设我写了一个函数装饰器,它接收一个函数作为参数,并像下面这样用另一个函数包装它:

# File example-1.py
from functools import wraps

def decorator(func):
    # Do something
    @wraps(func)
    def wrapper(*args, **kwargs):
        # Do something
        return func(*args, **kwargs)
        # Do something
    # Do something
    return wrapper

现在假设我要装饰的函数引发了一个异常:

@decorator
def foo():
    raise Exception('test')

运行 foo() 的结果将打印出以下回溯信息(在任何 Python 版本中):

Traceback (most recent call last):
  File "./example-1.py", line 20, in <module>
    foo()
  File "./example-1.py", line 11, in wrapper
    return func(*args, **kwargs)
  File "./example-1.py", line 18, in foo
    raise Exception('test')
Exception: test

克隆人的攻击

现在我看到我的追踪信息,发现它经过了wrapper函数。如果我多次包装这个函数(可能使用稍微复杂一点的装饰器对象,在其构造函数中接收参数),会怎样呢?如果我在我的代码中经常使用这个装饰器(用于日志记录、分析性能等),又该怎么办呢?

Traceback (most recent call last):
  File "./example-1.py", line 20, in <module>
    foo()
  File "./example-1.py", line 11, in wrapper
    return func(*args, **kwargs)
  File "./example-1.py", line 11, in wrapper
    return func(*args, **kwargs)
  File "./example-1.py", line 11, in wrapper
    return func(*args, **kwargs)
  File "./example-1.py", line 11, in wrapper
    return func(*args, **kwargs)
  File "./example-1.py", line 18, in foo
    raise Exception('test')
Exception: test

当我从函数定义中知道包装器存在时,我不希望它“污染”我的回溯,当代码片段显示为无用的return func(*args, **kwargs)时,我也不希望它出现多次。

Python 2

星球大战三:西斯复兴

在Python-2中,正如这个问题的答案所指出的那样,以下技巧可以做到:

# In file example-2.py

def decorator(func):
    # Do something
    @wraps(func)
    def wrapper(*args, **kwargs):
        # Do something
        info = None
        try:
            return func(*args, **kwargs)
        except:
            info = sys.exc_info()
            raise info[0], info[1], info[2].tb_next
        finally:
            # Break the cyclical reference created by the traceback object
            del info
        # Do something
    # Do something
    return wrapper

通过在代码块中直接使用这个惯用语将对被包装函数的调用进行包装,我可以从堆栈跟踪中删除想要隐藏的函数,并让异常继续传播。每次堆栈展开经过该函数时,它都会从堆栈跟踪中删除自己,因此这个解决方案非常完美:

Traceback (most recent call last):
  File "./example-2.py", line 28, in <module>
    foo()
  File "./example-2.py", line 26, in foo
    raise Exception('test')
Exception: test

(请注意,您无法将此习语封装在另一个函数中,因为一旦堆栈从该函数回溯回到wrapper中,它仍将添加到回溯中)

Python 3

新希望

现在我们已经讲完了这个,让我们继续学习 Python-3。Python-3 引入了这个新语法:

raise_stmt ::=  "raise" [expression ["from" expression]]

使用新异常的__cause__属性允许链接异常。这个特性对我们来说不太有趣,因为它修改了异常而不是traceback。我们的目标是尽可能透明地包装异常,所以这样做行不通。

或者,我们可以尝试以下语法,承诺可以实现我们想要的效果(代码示例摘自Python文档):

raise Exception("foo occurred").with_traceback(tracebackobj)

使用这种语法,我们可以尝试类似于这样的内容:

# In file example-3
def decorator(func):
    # Do something
    @wraps(func)
    def wrapper(*args, **kwargs):
        # Do something
        info = None
        try:
            return func(*args, **kwargs)
        except:
            info = sys.exc_info()
            raise info[1].with_traceback(info[2].tb_next)
        finally:
            # Break the cyclical reference created by the traceback object
            del info
        # Do something
    # Do something
    return wrapper

帝国反击战

但是,不幸的是,这并不是我们想要的:

Traceback (most recent call last):
  File "./example-3.py", line 29, in <module>
    foo()
  File "./example-3.py", line 17, in wrapper
    raise info[1].with_traceback(info[2].tb_next)
  File "./example-3.py", line 27, in foo
    raise Exception('test')
Exception: test

如你所见,执行raise语句的那行代码会出现在回溯信息中。这似乎源于Python-2语法将raise的第三个参数作为函数被展开时所设置的回溯信息,并且因此不会添加到回溯链中(在数据模型文档中有解释),而另一方面,Python-3语法改变了函数上下文中Exception对象的回溯信息,然后将其传递给raise语句,该语句会将新代码位置添加到回溯链中(Python-3中的解释非常相似)。

一个可行的解决方法是避免使用"raise" [ expression ]形式的语句,而是使用干净的raise语句让异常像平常一样传播,但手动修改异常对象的__traceback__属性:

# File example-4
def decorator(func):
    # Do something
    @wraps(func)
    def wrapper(*args, **kwargs):
        # Do something
        info = None
        try:
            return func(*args, **kwargs)
        except:
            info = sys.exc_info()
            info[1].__traceback__ = info[2].tb_next
            raise
        finally:
            # Break the cyclical reference created by the traceback object
            del info
        # Do something
    # Do something
    return wrapper

但是这根本不起作用!

Traceback (most recent call last):
  File "./example-4.py", line 30, in <module>
    foo()
  File "./example-4.py", line 14, in wrapper
    return func(*args, **kwargs)
  File "./example-4.py", line 28, in foo
    raise Exception('test')
Exception: test

绝地大反攻(?)

那么,还能做什么呢?似乎使用“传统”方式来解决这个问题是行不通的,因为语法的变化,我也不想在项目层面开始与回溯打印机制(使用traceback模块)进行操作。这是因为它将很难甚至可能无法实现可扩展性,这不会对试图更改回溯、在顶层自定义格式打印回溯或者执行其他与该问题相关的任何其他包造成影响。

此外,有人能解释一下为什么最后一种技术完全失败吗?

(我在python 2.6、2.7、3.4、3.6上尝试了这些示例)

编辑:经过思考,我认为Python 3的行为更合理,以至于Python 2的行为几乎看起来像是一个设计缺陷,但我仍然认为应该有一种方法来处理这种情况。


虽然我不能完全理解这个问题,但还是给你一个赞。 :) - iehrlich
谢谢!^_^ 我已经追踪这个问题大约一周了。 - Re.po
@Kyuuhachi 在 下面的解答 中提供的方法最终成功了。我只需要做一些非常小的更改,所以在我的情况下,这个解答 显示了我现在正在使用的内容。 - Pierre D
2个回答

5
简单的回答是,您不应该这样做。从回溯中隐藏东西是危险的。您可能认为您不想显示那行代码是因为它很琐碎或者只是一个“包装器”,但通常情况下,如果包装器函数没有起到作用,您就不会编写它。接下来,您会发现包装器函数中有一个错误,而这个错误现在无法被发现,因为包装器函数已经从回溯中删除了自己。
处理回溯中的额外行,或者如果您真的想要过滤掉它们,请覆盖sys.excepthook并在顶层进行过滤。如果您担心其他人也会覆盖sys.excepthook,则将所有代码包装在一个顶层函数中,该函数自己打印异常。隐藏回溯中的级别既不容易也不应该容易。

如果你看一下我放置的 # Do something 注释,我特意将它们放在了所提出的习语周围,这样任何其他异常都会像往常一样显示出来。我只对调用是微不足道的情况下跳过包装感兴趣。 - Re.po
如果我使用sys.excepthook,我就会失去对其特定省略的精细控制,因为如果包装器也调用其他函数,那么区分异常是在包装函数中发生还是在其他函数中发生就不容易了。 - Re.po
1
隐藏回溯的部分是危险的,但在极少数情况下,您可能会决定它真的值得。例如,importlib有自己的丑陋黑客,因此当导入过程中出现问题时,importlib帧通常不会显示在回溯中。 - user2357112
@Re.po:即使那一行代码非常简单,这也不意味着抑制整个代码块是一个好的想法。例如,装饰器可能会在之前的代码行中更改参数甚至调用的函数,因此由调用行引发的异常可能在包装器中具有其真正的原因。如果抑制整个代码块,则无法看到这一点。话虽如此,如果你在谷歌上搜寻,你会看到人们建议模仿jinja2,它有效地重新实现了traceback对象。 - BrenBarn
1
我不是有意无礼,但我特别指的是当我既不更改func也不更改argskwargs的情况。至于jinja2,这正是我想要避免的过度实现。我希望实现尽可能简单明了。我有一个涉及sys.excepthook的想法,它将使用一个辅助模块,并且仍然不会对其他代码产生影响(只需用其他东西包装原始函数)。如果我想出来并有时间写下这个想法,我会发布一篇编辑文章。 - Re.po
显示剩余2条评论

1

内部_testcapi模块包含PyErr_SetExcInfo函数的绑定,这使得实现相当容易。仅在cpython 3.9上进行了测试;不能保证它将来会继续工作。

import functools
import typing as T

A_Callable = T.TypeVar("A_Callable", bound=T.Callable[..., T.Any])

try:
    from sys import exc_info
    from _testcapi import set_exc_info
    def silent_wrapper(f: A_Callable) -> A_Callable:
        @functools.wraps(f)
        def wrapper(*args: T.Any, **kwargs: T.Any) -> T.Any:
            try:
                return f(*args, **kwargs)
            except:
                tp, exc, tb = exc_info()
                # Drop two frames, one for silent_wrapper itself and one for the
                # wrapped function
                set_exc_info(tp, exc, tb.tb_next.tb_next) # type: ignore[union-attr]
                del tp, exc, tb
                raise
        return T.cast(A_Callable, wrapper)

except ImportError:
    def silent_wrapper(f: A_Callable) -> A_Callable:
        return f

是的!很棒的技巧。但为什么要两个框架?我的理解是我们只想从跟踪中删除装饰器。当我只删除一个框架时,我得到了这个(即异常来自包装函数)。 - Pierre D
1
我做这个已经有一段时间了,但是如果我没记错的话,这是一个装饰器,用于在其他装饰器上隐藏它们的跟踪。因此,您需要删除由相关装饰器创建的帧以及由 silent_wrapper 本身创建的帧。 - Kyuuhachi
哦,我明白了。非常好。我正在寻找“简单”的方法,其中装饰器想要删除自身,就像这里 - Pierre D

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接