1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-03-05 00:12:49 +01:00

feat(qt_utils): added error handle utility with popup messageBoxes

This commit is contained in:
2024-07-16 13:47:28 +02:00
parent 9a91583ed0
commit 9ce2279533
3 changed files with 226 additions and 0 deletions

View File

@@ -0,0 +1,169 @@
import sys
import traceback
from qtpy.QtCore import QObject, Qt, Signal, Slot
from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
class ErrorPopupUtility(QObject):
"""
Utility class to manage error popups in the application to show error messages to the users.
This class is singleton and the error popup can be enabled or disabled globally or attach to widget methods with decorator @error_managed.
"""
error_occurred = Signal(str, str, QWidget)
_instance = None
_initialized = False
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super(ErrorPopupUtility, cls).__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self, parent=None):
if not self._initialized:
super().__init__(parent=parent)
self.error_occurred.connect(self.show_error_message)
self.enable_error_popup = False
self.original_excepthook = sys.excepthook
self._initialized = True
@Slot(str, str, QWidget)
def show_error_message(self, title, message, widget):
detailed_text = self.format_traceback(message)
error_message = self.parse_error_message(detailed_text)
msg = QMessageBox(widget)
msg.setIcon(QMessageBox.Critical)
msg.setWindowTitle(title)
msg.setText(error_message)
msg.setStandardButtons(QMessageBox.Ok)
msg.setDetailedText(detailed_text)
msg.setTextInteractionFlags(Qt.TextSelectableByMouse)
msg.setMinimumWidth(600)
msg.setMinimumHeight(400)
msg.exec_()
def format_traceback(self, traceback_message: str) -> str:
"""
Format the traceback message to be displayed in the error popup by adding indentation to each line.
Args:
traceback_message(str): The traceback message to be formatted.
Returns:
str: The formatted traceback message.
"""
formatted_lines = []
lines = traceback_message.split("\n")
for line in lines:
formatted_lines.append(" " + line) # Add indentation to each line
return "\n".join(formatted_lines)
def parse_error_message(self, traceback_message):
lines = traceback_message.split("\n")
error_message = "Error occurred. See details."
capture = False
captured_message = []
for line in lines:
if "raise" in line:
capture = True
continue
if capture:
if line.strip() and not line.startswith(" File "):
captured_message.append(line.strip())
else:
break
if captured_message:
error_message = " ".join(captured_message)
return error_message
def custom_exception_hook(self, exctype, value, tb):
if self.enable_error_popup:
error_message = traceback.format_exception(exctype, value, tb)
self.error_occurred.emit("Application Error", "".join(error_message), self.parent())
else:
self.original_excepthook(exctype, value, tb) # Call the original excepthook
def enable_global_error_popups(self, state: bool):
"""
Enable or disable global error popups for all applications.
Args:
state(bool): True to enable error popups, False to disable error popups.
"""
self.enable_error_popup = bool(state)
if self.enable_error_popup:
sys.excepthook = self.custom_exception_hook
else:
sys.excepthook = self.original_excepthook
@classmethod
def reset_singleton(cls):
"""
Reset the singleton instance.
"""
cls._instance = None
cls._initialized = False
def error_managed(method):
"""Decorator to manage errors with the ErrorPopupUtility"""
def wrapper(*args, **kwargs):
try:
return method(*args, **kwargs)
except Exception as e:
exc_type, exc_value, exc_traceback = sys.exc_info()
error_message = traceback.format_exception(exc_type, exc_value, exc_traceback)
ErrorPopupUtility().error_occurred.emit("Error in Method", "".join(error_message), None)
if not ErrorPopupUtility()._instance.enable_error_popup:
raise
return wrapper
class ExampleWidget(QWidget): # pragma: no cover
"""
Example widget to demonstrate error handling with the ErrorPopupUtility.
Warnings -> This example works properly only with PySide6, PyQt6 has a bug with the error handling.
"""
def __init__(self, parent=None):
super().__init__(parent=parent)
self.init_ui()
def init_ui(self):
self.layout = QVBoxLayout(self)
# Button to trigger method with error handling
self.error_button = QPushButton("Trigger Handled Error", self)
self.error_button.clicked.connect(self.method_with_error_handling)
self.layout.addWidget(self.error_button)
# Button to trigger method without error handling
self.normal_button = QPushButton("Trigger Normal Error", self)
self.normal_button.clicked.connect(self.method_without_error_handling)
self.layout.addWidget(self.normal_button)
@error_managed
def method_with_error_handling(self):
"""This method raises an error and the exception is handled by the decorator."""
raise ValueError("This is a handled error.")
def method_without_error_handling(self):
"""This method raises an error and the exception is not handled here."""
raise ValueError("This is an unhandled error.")
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
widget = ExampleWidget()
widget.show()
sys.exit(app.exec_())

View File

@@ -13,6 +13,7 @@ from qtpy.QtCore import QObject, QRunnable, QThreadPool, Signal
from qtpy.QtCore import Slot as pyqtSlot
from bec_widgets.cli.rpc_register import RPCRegister
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
@@ -94,6 +95,9 @@ class BECConnector(BECWidget):
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self)
# Error popups
self.error_utility = ErrorPopupUtility()
self._thread_pool = QThreadPool.globalInstance()
def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker:

View File

@@ -0,0 +1,53 @@
from unittest.mock import patch
import pytest
from qtpy.QtWidgets import QMessageBox
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility, ExampleWidget
@pytest.fixture
def widget(qtbot):
test_widget = ExampleWidget()
qtbot.addWidget(test_widget)
qtbot.waitExposed(test_widget)
yield test_widget
test_widget.close()
@patch.object(QMessageBox, "exec_", return_value=QMessageBox.Ok)
def test_show_error_message_global(mock_exec, widget, qtbot):
error_utility = ErrorPopupUtility()
error_utility.enable_global_error_popups(True)
with qtbot.waitSignal(error_utility.error_occurred, timeout=1000) as blocker:
error_utility.error_occurred.emit("Test Error", "This is a test error message.", widget)
assert mock_exec.called
assert blocker.signal_triggered
@patch.object(QMessageBox, "exec_", return_value=QMessageBox.Ok)
def test_decorated_function(mock_exec, widget, qtbot):
error_utility = ErrorPopupUtility()
error_utility.enable_global_error_popups(False)
with pytest.raises(ValueError) as excinfo:
with qtbot.waitSignal(error_utility.error_occurred, timeout=1000) as blocker:
widget.method_with_error_handling()
assert blocker.signal_triggered
assert mock_exec.called
@patch.object(QMessageBox, "exec_", return_value=QMessageBox.Ok)
def test_not_decorated_function(mock_exec, widget, qtbot):
error_utility = ErrorPopupUtility()
error_utility.enable_global_error_popups(False)
with pytest.raises(ValueError) as excinfo:
with qtbot.waitSignal(error_utility.error_occurred, timeout=1000) as blocker:
widget.method_without_error_handling()
assert not blocker.signal_triggered
assert not mock_exec.called