Pyside 插槽中的异常处理令人惊讶

Posted

技术标签:

【中文标题】Pyside 插槽中的异常处理令人惊讶【英文标题】:Exception handled surprisingly in Pyside slots 【发布时间】:2018-01-28 22:41:57 【问题描述】:

问题:当在槽中引发异常时,由信号调用,它们似乎不会像往常一样通过 Pythons 调用堆栈传播。在下面的示例代码中调用:

on_raise_without_signal():将按预期处理异常。 on_raise_with_signal():将打印异常,然后意外打印来自else 块的成功消息。

问题: 异常在槽中引发时令人惊讶地处理的原因是什么?它是 PySide Qt 包装信号/插槽的一些实现细节/限制吗?文档中有什么要读的吗?

PS:当我在实现QAbstractTableModels 虚拟方法insertRows() 时使用try/except/else/finally 得到了令人惊讶的结果时,我最初遇到了这个话题和removeRows()


# -*- coding: utf-8 -*-
"""Testing exception handling in PySide slots."""
from __future__ import unicode_literals, print_function, division

import logging
import sys

from PySide import QtCore
from PySide import QtGui


logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)


class ExceptionTestWidget(QtGui.QWidget):

    raise_exception = QtCore.Signal()

    def __init__(self, *args, **kwargs):
        super(ExceptionTestWidget, self).__init__(*args, **kwargs)

        self.raise_exception.connect(self.slot_raise_exception)

        layout = QtGui.QVBoxLayout()
        self.setLayout(layout)

        # button to invoke handler that handles raised exception as expected
        btn_raise_without_signal = QtGui.QPushButton("Raise without signal")
        btn_raise_without_signal.clicked.connect(self.on_raise_without_signal)
        layout.addWidget(btn_raise_without_signal)

        # button to invoke handler that handles raised exception via signal unexpectedly
        btn_raise_with_signal = QtGui.QPushButton("Raise with signal")
        btn_raise_with_signal.clicked.connect(self.on_raise_with_signal)
        layout.addWidget(btn_raise_with_signal)

    def slot_raise_exception(self):
        raise ValueError("ValueError on purpose")

    def on_raise_without_signal(self):
        """Call function that raises exception directly."""
        try:
            self.slot_raise_exception()
        except ValueError as exception_instance:
            logger.error("".format(exception_instance))
        else:
            logger.info("on_raise_without_signal() executed successfully")

    def on_raise_with_signal(self):
        """Call slot that raises exception via signal."""
        try:
            self.raise_exception.emit()
        except ValueError as exception_instance:
            logger.error("".format(exception_instance))
        else:
            logger.info("on_raise_with_signal() executed successfully")


if (__name__ == "__main__"):
    application = QtGui.QApplication(sys.argv)

    widget = ExceptionTestWidget()
    widget.show()

    sys.exit(application.exec_())

【问题讨论】:

【参考方案1】:

正如您在问题中已经指出的那样,这里的真正问题是处理从 C++ 执行的 python 代码中引发的未处理异常。所以这不仅仅是关于信号:它也会影响重新实现的虚拟方法。

在 PySide、PyQt4 和 5.5 之前的所有 PyQt5 版本中,默认行为是自动捕获 C++ 端的错误并将回溯转储到 stderr。通常,python 脚本也会在此之后自动终止。但这不是这里发生的事情。相反,PySide/PyQt 脚本无论如何都会继续运行,许多人完全正确地认为这是一个错误(或至少是一个错误功能)。在 PyQt-5.5 中,此行为现在已更改,因此在 C++ 端也调用了qFatal(),并且程序将像普通的 python 脚本一样中止。 (不过,我不知道 PySide2 目前的情况如何)。

那么 - 应该如何处理这一切?所有版本的 PySide 和 PyQt 的最佳解决方案是安装 exception hook - 因为它总是优先于默认行为(无论是什么)。任何由信号、虚拟方法或其他 python 代码引发的未处理异常都会首先调用sys.excepthook,让您可以以任何您喜欢的方式完全自定义行为。

在您的示例脚本中,这可能只是意味着添加如下内容:

def excepthook(cls, exception, traceback):
    print('calling excepthook...')
    logger.error("".format(exception))

sys.excepthook = excepthook

现在on_raise_with_signal 引发的异常可以像处理所有其他未处理的异常一样处理。

当然,这确实意味着大多数 PySide/PyQt 应用程序的最佳实践是使用高度集中的异常处理。这通常包括显示某种崩溃对话框,用户可以在其中报告意外错误。

【讨论】:

【参考方案2】:

根据Qt5 docs,您需要在被调用的槽内处理异常。

从 Qt 的信号槽连接机制调用的槽中抛出异常被认为是未定义的行为,除非它在槽内处理

State state;
StateListener stateListener;

// OK; the exception is handled before it leaves the slot.
QObject::connect(&state, SIGNAL(stateChanged()), &stateListener, SLOT(throwHandledException()));
// Undefined behaviour; upon invocation of the slot, the exception will be propagated to the
// point of emission, unwinding the stack of the Qt code (which is not guaranteed to be exception safe).
QObject::connect(&state, SIGNAL(stateChanged()), &stateListener, SLOT(throwUnhandledException()));

如果槽被直接调用,就像一个常规的函数调用, 可以使用例外。这是因为连接机制是 直接调用槽时绕过

在第一种情况下,您直接调用slot_raise_exception(),所以这很好。

在第二种情况下,您通过raise_exception 信号调用它,因此异常只会传播到调用slot_raise_exception() 的位置。您需要将try/except/else 放在slot_raise_exception() 中,以便正确处理异常。

【讨论】:

【参考方案3】:

感谢大家回答。我发现 ekhumoros 的答案对于了解异常处理的位置以及利用 sys.excepthook 的想法特别有用。

我通过上下文管理器模拟了一个快速解决方案,以临时扩展当前的 sys.excepthook 以记录 “C++ 调用 Python” 领域中的任何异常(因为它似乎在调用插槽时发生通过信号或虚拟方法)并可能在退出上下文时重新引发以实现 try/except/else/finally 块中的预期控制流。

上下文管理器允许 on_raise_with_signal 与周围的 try/except/else/finally 块保持与 on_raise_without_signal 相同的控制流。


# -*- coding: utf-8 -*-
"""Testing exception handling in PySide slots."""
from __future__ import unicode_literals, print_function, division

import logging
import sys
from functools import wraps

from PySide import QtCore
from PySide import QtGui


logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)


class ExceptionHook(object):

    def extend_exception_hook(self, exception_hook):
        """Decorate sys.excepthook to store a record on the context manager
        instance that might be used upon leaving the context.
        """

        @wraps(exception_hook)
        def wrapped_exception_hook(exc_type, exc_val, exc_tb):
            self.exc_val = exc_val
            return exception_hook(exc_type, exc_val, exc_tb)

        return wrapped_exception_hook

    def __enter__(self):
        """Temporary extend current exception hook."""
        self.current_exception_hook = sys.excepthook
        sys.excepthook = self.extend_exception_hook(sys.excepthook)

        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        """Reset current exception hook and re-raise in Python call stack after
        we have left the realm of `C++ calling Python`.
        """
        sys.excepthook = self.current_exception_hook

        try:
            exception_type = type(self.exc_val)
        except AttributeError:
            pass
        else:
            msg = "".format(self.exc_val)
            raise exception_type(msg)


class ExceptionTestWidget(QtGui.QWidget):

    raise_exception = QtCore.Signal()

    def __init__(self, *args, **kwargs):
        super(ExceptionTestWidget, self).__init__(*args, **kwargs)

        self.raise_exception.connect(self.slot_raise_exception)

        layout = QtGui.QVBoxLayout()
        self.setLayout(layout)

        # button to invoke handler that handles raised exception as expected
        btn_raise_without_signal = QtGui.QPushButton("Raise without signal")
        btn_raise_without_signal.clicked.connect(self.on_raise_without_signal)
        layout.addWidget(btn_raise_without_signal)

        # button to invoke handler that handles raised exception via signal unexpectedly
        btn_raise_with_signal = QtGui.QPushButton("Raise with signal")
        btn_raise_with_signal.clicked.connect(self.on_raise_with_signal)
        layout.addWidget(btn_raise_with_signal)

    def slot_raise_exception(self):
        raise ValueError("ValueError on purpose")

    def on_raise_without_signal(self):
        """Call function that raises exception directly."""
        try:
            self.slot_raise_exception()
        except ValueError as exception_instance:
            logger.error("".format(exception_instance))
        else:
            logger.info("on_raise_without_signal() executed successfully")

    def on_raise_with_signal(self):
        """Call slot that raises exception via signal."""
        try:
            with ExceptionHook() as exception_hook:
                self.raise_exception.emit()
        except ValueError as exception_instance:
            logger.error("".format(exception_instance))
        else:
            logger.info("on_raise_with_signal() executed successfully")


if (__name__ == "__main__"):
    application = QtGui.QApplication(sys.argv)

    widget = ExceptionTestWidget()
    widget.show()

    sys.exit(application.exec_())

【讨论】:

【参考方案4】:

考虑到信号/插槽架构提出了信号和插槽之间的松散耦合交互,这种处理异常的方式并不令人意外。这意味着信号不应该期望槽内发生任何事情。

虽然timmwagener 的解决方案非常聪明,但应谨慎使用。问题可能不在于 Qt 连接之间如何处理异常,而在于信号/插槽架构对于您的应用程序来说并不理想。此外,如果连接了来自不同线程的插槽,或者使用了 Qt.QueuedConnection,则该解决方案将不起作用。

解决插槽中出现的错误问题的一个好方法是在连接处而不是在发射处确定错误。然后可以以松散耦合的方式处理错误。

class ExceptionTestWidget(QtGui.QWidget):

    error = QtCore.Signal(object)

    def abort_execution():
        pass

    def error_handler(self, err):
        self.error.emit(error)
        self.abort_execution()

(...)

def connect_with_async_error_handler(sig, slot, error_handler, *args,
                                     conn_type=None, **kwargs):                              

    @functools.wraps(slot)
    def slot_with_error_handler(*args):
        try:
            slot(*args)
        except Exception as err:
            error_handler(err)

    if conn_type is not None:
        sig.connect(slot_with_error_handler, conn_type)
    else:
        sig.connect(slot_with_error_handler)

这样,我们将遵守Qt5 docs 中的要求,说明您需要在被调用的槽内处理异常。

从 Qt 的信号槽调用的槽中抛出异常 连接机制被认为是未定义的行为,除非它是 槽内处理

PS:这只是基于对您的用例的一个非常小的概述的建议。 没有正确/错误的解决方法,我只是想提出一个不同的观点:)

【讨论】:

感谢您指出我的异步代码示例中可能存在的问题。我认为这是对的。但是,我不确定在 Qt 的 Python 包装器(如 PySide2)中引发插槽是否是“官方未定义的行为”(我猜你的示例来自 C++ 文档)。正如@ekhumoro 指出的那样,似乎已经做出了明确的努力,通过调用sys.excepthook (它具有将异常记录到stderr 的定义行为) 来包含Python 的默认根异常处理。或者你能找到任何指向 PySide2 文档的链接也说明这将是未定义的? 在调试复杂的异步应用程序时,附加 sys.excepthook 非常方便,并且不会触发应该触发的信号,因为在执行过程中某个位置的插槽中被抑制了错误。这足以成为这种明确努力的理由。此外,当然,PEP 声明所有未捕获的异常都应该通过 sys.excepthook。因此,这种明确努力的理由绰绰有余。此外,Py QT 只是 C++ QT 之上的绑定。所以在我的解释中,如果它是 C++ 中未定义的行为,它在 Python 中也是未定义的。 也许我对下一条评论有误,但如果您不打算使用异步应用程序,我认为使用 QT Signal/Slot 机制没有什么意义。如果唯一的好处是部件的解耦,那么在这种情况下可以使用更简单的结构。这让我回到了我的第一个建议:也许信号/插槽架构不适合他的应用。我的一般经验法则是:如果你需要开始做很多花哨和聪明的变通办法,你可能走错了路,需要重新考虑你的应用程序的架构。

以上是关于Pyside 插槽中的异常处理令人惊讶的主要内容,如果未能解决你的问题,请参考以下文章

java中的Arrays.copyOfRange方法抛出不正确的异常

Debug.Assert 与异常

Java中处理异常的9个最佳实践

来自未处理异常的 C++ 堆栈跟踪?

9 个Java 异常处理的规则!

PySide 中未处理的信号