Исключение было неожиданно обработано в слотах Pyside

Проблема. Когда исключения генерируются в слотах, вызывается сигналами, они, как обычно, не распространяются через стек вызовов Pythons . В приведенном ниже примере кода:

  • on_raise_without_signal() : будет обрабатывать исключение, как ожидалось.
  • on_raise_with_signal() : напечатает исключение и затем неожиданно распечатает сообщение об успешном завершении из блока else .

Вопрос: В чем причина исключительного обращения, которое вызывает удивление при поднятии в слот? Является ли это некоторой детальностью / ограничением реализации пакета PySide Qt для сигналов / слотов? Есть что-то читать в документах?

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

Как вы уже отметили в своем вопросе, реальной проблемой здесь является обращение с необработанными исключениями, возникающими в коде python, выполняемом с C ++. Так что речь идет не только о сигналах: это также влияет на переопределенные виртуальные методы.

В PySide, PyQt4 и всех версиях PyQt5 до 5.5, поведение по умолчанию заключается в том, чтобы автоматически уловить ошибку на стороне C ++ и сбросить трассировку на stderr. Обычно сценарий python также автоматически заканчивается после этого. Но это не то, что здесь происходит. Вместо этого сценарий PySide / PyQt выполняется независимо, и многие люди совершенно справедливо считают это ошибкой (или, по крайней мере, ошибкой). В PyQt-5.5 это поведение теперь изменилось, так что qFatal() также вызывается на стороне C ++, и программа будет прервана, как и обычный скрипт python. (Я не знаю, что такое текущая ситуация с PySide2, хотя).

Итак – что нужно сделать обо всем этом? Лучшим решением для всех версий PySide и PyQt является установка крюка исключений – поскольку он всегда будет иметь приоритет над поведением по умолчанию (что бы это ни было). Любое необработанное исключение, sys.excepthook сигналом, виртуальным методом или другим кодом python, сначала вызовет sys.excepthook , позволяя вам полностью настраивать поведение любым способом.

В вашем примере скрипта это может означать добавление чего-то вроде этого:

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

и теперь исключение, on_raise_with_signal можно обрабатывать так же, как и все другие необработанные исключения.

Конечно, это означает, что наилучшая практика для большинства приложений PySide / PyQt заключается в использовании в основном централизованной обработки исключений. Это часто включает показ своего рода диалогового окна с сбоем, когда пользователь может сообщать о неожиданных ошибках.

В соответствии с документами Qt5 вам нужно обрабатывать исключения внутри вызываемого слота.

Выброс исключения из слота, вызванного механизмом соединения сигнального слота 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() чтобы исключение обрабатывалось правильно.

Спасибо, что ответили ребятам. Я нашел ekhumoros ответ особенно полезен, чтобы понять, где обрабатываются исключения, и из-за идеи использовать sys.excepthook .

Я высмеял быстрое решение через диспетчер контекста, чтобы временно расширить текущий sys.excepthook чтобы записать любое исключение в области «C ++-вызов Python» (как это происходит, когда слоты вызывают сигналы или виртуальные методы) повышать после выхода из контекста для достижения ожидаемого потока управления в блоках try / except / else / finally .

Менеджер контекста позволяет on_raise_with_signal поддерживать тот же поток управления, что и on_raise_without_signal с окружающим on_raise_without_signal try / except / else / finally .


 # -*- 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_())