在 Pyside 插槽中意外处理异常

Exception handled surprisingly in Pyside slots

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

问题:在槽中引发的异常被意外处理的背后原因是什么?它是 signals/slots 的 PySide Qt 包装的某种实现 detail/limitation 吗?文档中有什么值得阅读的内容吗?

PS: 当我使用 try/except/else/finally 得到令人惊讶的结果时,我最初遇到了这个话题实施 QAbstractTableModels 虚拟方法 insertRows()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_())

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

Throwing an exception from a slot invoked by Qt's signal-slot connection mechanism is considered undefined behaviour, unless it is handled within the slot

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()));

If the slot was invoked directly, like a regular function call, exceptions may be used. This is because the connection mechanism is bypassed when invoking slots directly

在第一种情况下,您直接调用 slot_raise_exception(),这样就可以了。

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

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

在 PySide、PyQt4 和所有 PyQt5 5.5 之前的版本中,默认行为是自动捕获 C++ 端的错误并将回溯转储到 stderr。通常,python 脚本也会在此之后自动终止。但这不是这里发生的事情。相反,PySide/PyQt 脚本会不顾一切地继续运行,许多人非常正确地将其视为错误(或至少是错误功能)。在 PyQt-5.5 中,此行为现已更改,因此 qFatal() 也会在 C++ 端调用,程序将像正常的 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 应用程序的最佳做法是主要使用集中式异常处理。这通常包括显示某种崩溃对话框,用户可以在其中报告意外错误。

感谢您的回答。我发现 ekhumoros 的回答对于理解异常处理的位置以及利用 sys.excepthook.

的想法特别有用

我通过上下文管理器模拟了一个快速解决方案来临时扩展当前 sys.excepthook 以记录 "C++ calling 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_())

考虑到 Signal/Slot 体系结构提出了信号和槽之间的松散耦合交互,这种处理异常的方式并不奇怪。这意味着信号不应期望插槽内发生任何事情。

虽然timmwagener的解决方法很巧妙,但还是要谨慎使用。问题可能不在于 Qt 连接之间如何处理异常,而是 signal/slot 体系结构不适合您的应用程序。此外,如果连接了来自不同线程的插槽,或者使用了 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 中的要求,声明您需要在被调用的插槽中处理异常。

Throwing an exception from a slot invoked by Qt's signal-slot connection mechanism is considered undefined behaviour, unless it is handled within the slot

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