bec-widgets 0.86.0__py3-none-any.whl → 0.87.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## v0.87.0 (2024-07-17)
4
+
5
+ ### Feature
6
+
7
+ * feat(qt_utils): added warning utility with simple API to setup warning message ([`787f749`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/787f74949bac27aaa51cbb43911919071481707c))
8
+
9
+ * feat(qt_utils): added error handle utility with popup messageBoxes ([`196ef7a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/196ef7afe11a1b5dcc536f8859dc3b6044ea628e))
10
+
11
+ ### Unknown
12
+
13
+ * tests: add unit tests for error and warning message boxes ([`8f104cf`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8f104cf4024d3a4516e6aba5daa8fb78c85e2bfd))
14
+
3
15
  ## v0.86.0 (2024-07-17)
4
16
 
5
17
  ### Feature
@@ -131,11 +143,3 @@
131
143
  ### Refactor
132
144
 
133
145
  * refactor(device_input): DeviceComboBox and DeviceLineEdit moved to top layer of widgets ([`f048629`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f04862933f049030554086adef3ec9e1aebd3eda))
134
-
135
- * refactor(stop_button): moved to top layer, plugin added ([`f5b8375`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f5b8375fd36e3bb681de571da86a6c0bdb3cb6f0))
136
-
137
- * refactor(motor_map_widget): removed restriction of only PySide6 for widget ([`db1cdf4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/db1cdf42806fef6d7c6d2db83528f32df3f9751d))
138
-
139
- * refactor(color_button): ColorButton moved to top level of widgets ([`fa1e86f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fa1e86ff07b25d2c47c73117b00765b8e2f25da4))
140
-
141
- ## v0.81.2 (2024-07-07)
PKG-INFO CHANGED
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: bec_widgets
3
- Version: 0.86.0
3
+ Version: 0.87.0
4
4
  Summary: BEC Widgets
5
5
  Project-URL: Bug Tracker, https://gitlab.psi.ch/bec/bec_widgets/issues
6
6
  Project-URL: Homepage, https://gitlab.psi.ch/bec/bec_widgets
@@ -0,0 +1,225 @@
1
+ import functools
2
+ import sys
3
+ import traceback
4
+
5
+ from qtpy.QtCore import QObject, Qt, Signal, Slot
6
+ from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
7
+
8
+
9
+ def SafeSlot(*slot_args, **slot_kwargs):
10
+ """Function with args, acting like a decorator, applying "error_managed" decorator + Qt Slot
11
+ to the passed function, to display errors instead of potentially raising an exception
12
+
13
+ 'popup_error' keyword argument can be passed with boolean value if a dialog should pop up,
14
+ otherwise error display is left to the original exception hook
15
+ """
16
+ popup_error = bool(slot_kwargs.pop("popup_error", False))
17
+
18
+ def error_managed(method):
19
+ @Slot(*slot_args, **slot_kwargs)
20
+ @functools.wraps(method)
21
+ def wrapper(*args, **kwargs):
22
+ try:
23
+ return method(*args, **kwargs)
24
+ except Exception:
25
+ ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=popup_error)
26
+
27
+ return wrapper
28
+
29
+ return error_managed
30
+
31
+
32
+ class WarningPopupUtility(QObject):
33
+ """
34
+ Utility class to show warning popups in the application.
35
+ """
36
+
37
+ def __init__(self, parent=None):
38
+ super().__init__(parent)
39
+
40
+ @Slot(str, str, str, QWidget)
41
+ def show_warning_message(self, title, message, detailed_text, widget):
42
+ msg = QMessageBox(widget)
43
+ msg.setIcon(QMessageBox.Warning)
44
+ msg.setWindowTitle(title)
45
+ msg.setText(message)
46
+ msg.setStandardButtons(QMessageBox.Ok)
47
+ msg.setDetailedText(detailed_text)
48
+ msg.exec_()
49
+
50
+ def show_warning(self, title: str, message: str, detailed_text: str, widget: QWidget = None):
51
+ """
52
+ Show a warning message with the given title, message, and detailed text.
53
+
54
+ Args:
55
+ title (str): The title of the warning message.
56
+ message (str): The main text of the warning message.
57
+ detailed_text (str): The detailed text to show when the user expands the message.
58
+ widget (QWidget): The parent widget for the message box.
59
+ """
60
+ self.show_warning_message(title, message, detailed_text, widget)
61
+
62
+
63
+ class ErrorPopupUtility(QObject):
64
+ """
65
+ Utility class to manage error popups in the application to show error messages to the users.
66
+ This class is singleton and the error popup can be enabled or disabled globally or attach to widget methods with decorator @error_managed.
67
+ """
68
+
69
+ error_occurred = Signal(str, str, QWidget)
70
+
71
+ _instance = None
72
+ _initialized = False
73
+
74
+ def __new__(cls, *args, **kwargs):
75
+ if cls._instance is None:
76
+ cls._instance = super(ErrorPopupUtility, cls).__new__(cls)
77
+ cls._instance._initialized = False
78
+ return cls._instance
79
+
80
+ def __init__(self, parent=None):
81
+ if not self._initialized:
82
+ super().__init__(parent=parent)
83
+ self.error_occurred.connect(self.show_error_message)
84
+ self.enable_error_popup = False
85
+ self._initialized = True
86
+ sys.excepthook = self.custom_exception_hook
87
+
88
+ @Slot(str, str, QWidget)
89
+ def show_error_message(self, title, message, widget):
90
+ detailed_text = self.format_traceback(message)
91
+ error_message = self.parse_error_message(detailed_text)
92
+
93
+ msg = QMessageBox(widget)
94
+ msg.setIcon(QMessageBox.Critical)
95
+ msg.setWindowTitle(title)
96
+ msg.setText(error_message)
97
+ msg.setStandardButtons(QMessageBox.Ok)
98
+ msg.setDetailedText(detailed_text)
99
+ msg.setTextInteractionFlags(Qt.TextSelectableByMouse)
100
+ msg.setMinimumWidth(600)
101
+ msg.setMinimumHeight(400)
102
+ msg.exec_()
103
+
104
+ def format_traceback(self, traceback_message: str) -> str:
105
+ """
106
+ Format the traceback message to be displayed in the error popup by adding indentation to each line.
107
+
108
+ Args:
109
+ traceback_message(str): The traceback message to be formatted.
110
+
111
+ Returns:
112
+ str: The formatted traceback message.
113
+ """
114
+ formatted_lines = []
115
+ lines = traceback_message.split("\n")
116
+ for line in lines:
117
+ formatted_lines.append(" " + line) # Add indentation to each line
118
+ return "\n".join(formatted_lines)
119
+
120
+ def parse_error_message(self, traceback_message):
121
+ lines = traceback_message.split("\n")
122
+ error_message = "Error occurred. See details."
123
+ capture = False
124
+ captured_message = []
125
+
126
+ for line in lines:
127
+ if "raise" in line:
128
+ capture = True
129
+ continue
130
+ if capture:
131
+ if line.strip() and not line.startswith(" File "):
132
+ captured_message.append(line.strip())
133
+ else:
134
+ break
135
+
136
+ if captured_message:
137
+ error_message = " ".join(captured_message)
138
+ return error_message
139
+
140
+ def custom_exception_hook(self, exctype, value, tb, popup_error=False):
141
+ if popup_error or self.enable_error_popup:
142
+ error_message = traceback.format_exception(exctype, value, tb)
143
+ self.error_occurred.emit(
144
+ "Method error" if popup_error else "Application Error",
145
+ "".join(error_message),
146
+ self.parent(),
147
+ )
148
+ else:
149
+ sys.__excepthook__(exctype, value, tb) # Call the original excepthook
150
+
151
+ def enable_global_error_popups(self, state: bool):
152
+ """
153
+ Enable or disable global error popups for all applications.
154
+
155
+ Args:
156
+ state(bool): True to enable error popups, False to disable error popups.
157
+ """
158
+ self.enable_error_popup = bool(state)
159
+
160
+ @classmethod
161
+ def reset_singleton(cls):
162
+ """
163
+ Reset the singleton instance.
164
+ """
165
+ cls._instance = None
166
+ cls._initialized = False
167
+
168
+
169
+ class ExampleWidget(QWidget): # pragma: no cover
170
+ """
171
+ Example widget to demonstrate error handling with the ErrorPopupUtility.
172
+
173
+ Warnings -> This example works properly only with PySide6, PyQt6 has a bug with the error handling.
174
+ """
175
+
176
+ def __init__(self, parent=None):
177
+ super().__init__(parent=parent)
178
+ self.init_ui()
179
+ self.warning_utility = WarningPopupUtility(self)
180
+
181
+ def init_ui(self):
182
+ self.layout = QVBoxLayout(self)
183
+
184
+ # Button to trigger method with error handling
185
+ self.error_button = QPushButton("Trigger Handled Error", self)
186
+ self.error_button.clicked.connect(self.method_with_error_handling)
187
+ self.layout.addWidget(self.error_button)
188
+
189
+ # Button to trigger method without error handling
190
+ self.normal_button = QPushButton("Trigger Normal Error", self)
191
+ self.normal_button.clicked.connect(self.method_without_error_handling)
192
+ self.layout.addWidget(self.normal_button)
193
+
194
+ # Button to trigger warning popup
195
+ self.warning_button = QPushButton("Trigger Warning", self)
196
+ self.warning_button.clicked.connect(self.trigger_warning)
197
+ self.layout.addWidget(self.warning_button)
198
+
199
+ @SafeSlot(popup_error=True)
200
+ def method_with_error_handling(self):
201
+ """This method raises an error and the exception is handled by the decorator."""
202
+ raise ValueError("This is a handled error.")
203
+
204
+ @SafeSlot()
205
+ def method_without_error_handling(self):
206
+ """This method raises an error and the exception is not handled here."""
207
+ raise ValueError("This is an unhandled error.")
208
+
209
+ @SafeSlot()
210
+ def trigger_warning(self):
211
+ """Trigger a warning using the WarningPopupUtility."""
212
+ self.warning_utility.show_warning(
213
+ title="Warning",
214
+ message="This is a warning message.",
215
+ detailed_text="This is the detailed text of the warning message.",
216
+ widget=self,
217
+ )
218
+
219
+
220
+ if __name__ == "__main__": # pragma: no cover
221
+
222
+ app = QApplication(sys.argv)
223
+ widget = ExampleWidget()
224
+ widget.show()
225
+ sys.exit(app.exec_())
@@ -13,6 +13,7 @@ from qtpy.QtCore import QObject, QRunnable, QThreadPool, Signal
13
13
  from qtpy.QtCore import Slot as pyqtSlot
14
14
 
15
15
  from bec_widgets.cli.rpc_register import RPCRegister
16
+ from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
16
17
  from bec_widgets.utils.bec_widget import BECWidget
17
18
  from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
18
19
 
@@ -94,6 +95,9 @@ class BECConnector(BECWidget):
94
95
  self.rpc_register = RPCRegister()
95
96
  self.rpc_register.add_rpc(self)
96
97
 
98
+ # Error popups
99
+ self.error_utility = ErrorPopupUtility()
100
+
97
101
  self._thread_pool = QThreadPool.globalInstance()
98
102
 
99
103
  def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: bec_widgets
3
- Version: 0.86.0
3
+ Version: 0.87.0
4
4
  Summary: BEC Widgets
5
5
  Project-URL: Bug Tracker, https://gitlab.psi.ch/bec/bec_widgets/issues
6
6
  Project-URL: Homepage, https://gitlab.psi.ch/bec/bec_widgets
@@ -2,11 +2,11 @@
2
2
  .gitlab-ci.yml,sha256=zvb4A6QI5lQTsdfI5nPPL-tUNfcrz__SQjxW03QZ5Ek,8204
3
3
  .pylintrc,sha256=eeY8YwSI74oFfq6IYIbCqnx3Vk8ZncKaatv96n_Y8Rs,18544
4
4
  .readthedocs.yaml,sha256=aSOc277LqXcsTI6lgvm_JY80lMlr69GbPKgivua2cS0,603
5
- CHANGELOG.md,sha256=Gam_5nn79T5f-A773dIaB33QmP44uTIWBM3L5kj21sw,7441
5
+ CHANGELOG.md,sha256=J5sVQm-02kM-VZdTMEXiw7GffeaLehxOfJSeSmf2JEw,7474
6
6
  LICENSE,sha256=YRKe85CBRyP7UpEAWwU8_qSIyuy5-l_9C-HKg5Qm8MQ,1511
7
- PKG-INFO,sha256=KgvvrUy255cF4Cs816POOoo5Cm1JCCUcy4NVzND-eNU,1308
7
+ PKG-INFO,sha256=zgqysKuGz67anBxMymaso7hUNfkKS04uMlwLVOL5e38,1308
8
8
  README.md,sha256=Od69x-RS85Hph0-WwWACwal4yUd67XkEn4APEfHhHFw,2649
9
- pyproject.toml,sha256=zmDd5EnTI1ncYL1_Bk83z9D8e74STHBEoegO6OwI-LM,2357
9
+ pyproject.toml,sha256=j-UQTLL_5Y-jv2eqR7KkbyNjyqJrnoQ6uWGwLEUzkUo,2357
10
10
  .git_hooks/pre-commit,sha256=n3RofIZHJl8zfJJIUomcMyYGFi_rwq4CC19z0snz3FI,286
11
11
  .gitlab/issue_templates/bug_report_template.md,sha256=gAuyEwl7XlnebBrkiJ9AqffSNOywmr8vygUFWKTuQeI,386
12
12
  .gitlab/issue_templates/documentation_update_template.md,sha256=FHLdb3TS_D9aL4CYZCjyXSulbaW5mrN2CmwTaeLPbNw,860
@@ -34,10 +34,11 @@ bec_widgets/examples/plugin_example_pyside/tictactoe.py,sha256=s3rCurXloVcmMdzZi
34
34
  bec_widgets/examples/plugin_example_pyside/tictactoeplugin.py,sha256=BBt3MD8oDLUMCCY3mioJa1QRR0WQdW6DuvVmK1Taovk,1734
35
35
  bec_widgets/examples/plugin_example_pyside/tictactoetaskmenu.py,sha256=LNwplI6deUdKY6FOhUuWBanotxk9asF2G-6k7lFfA8Y,2301
36
36
  bec_widgets/qt_utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
37
+ bec_widgets/qt_utils/error_popups.py,sha256=WAN3Qtccy9Yww29kZ3HbLt9VyipgrIamJ6y4PhGTe3I,7983
37
38
  bec_widgets/qt_utils/settings_dialog.py,sha256=rR_Zk4RGTnI4dz5OEzCc13lVpxlOKuwOf4_7wqXSbRw,3373
38
39
  bec_widgets/qt_utils/toolbar.py,sha256=yzxCmZ7c00B2JD1TkUpPeQgM_-v7zuTYe38Qkw_yvrc,2430
39
40
  bec_widgets/utils/__init__.py,sha256=1930ji1Jj6dVuY81Wd2kYBhHYNV-2R0bN_L4o9zBj1U,533
40
- bec_widgets/utils/bec_connector.py,sha256=NypWbIrqb2ls3SIpflM6KihidV9fkroiJu2tQk6KwOA,9604
41
+ bec_widgets/utils/bec_connector.py,sha256=5FHeoN-2cwBJvei-5CIWYSJm1p06N6ZIqbN5GZxPRhM,9741
41
42
  bec_widgets/utils/bec_designer.py,sha256=ak3G8FdojUPjVBBwdPXw7tN5P2Uxr-SSoQt394jXeAA,4308
42
43
  bec_widgets/utils/bec_dispatcher.py,sha256=fhI7_X0kSZCtXyR55Qn-w7BfNdk2Roc1Tyx0bx3bjoE,6195
43
44
  bec_widgets/utils/bec_table.py,sha256=nA2b8ukSeUfquFMAxGrUVOqdrzMoDYD6O_4EYbOG2zk,717
@@ -245,6 +246,7 @@ tests/unit_tests/test_crosshair.py,sha256=3OMAJ2ZaISYXMOtkXf1rPdy94vCr8njeLi6uHb
245
246
  tests/unit_tests/test_device_box.py,sha256=q9IVFpt1NF3TBF0Jhk-I-LRiuvvHG3FGUalw4jEYwVo,3431
246
247
  tests/unit_tests/test_device_input_base.py,sha256=r1tI7BFAhpv7V7gf_n5gjusyrBFOfuCqIkdVg7YA7vY,2444
247
248
  tests/unit_tests/test_device_input_widgets.py,sha256=39MtgF-Q67UWz6qapyYP4ukDEUOD81iEJ_jhATyG7dM,5889
249
+ tests/unit_tests/test_error_utils.py,sha256=LQOxz29WCGOe0qwFkaPDixjUmdnF3qeAGxD4A3t9IKg,2108
248
250
  tests/unit_tests/test_generate_cli_client.py,sha256=ng-eV5iF7Dhm-6YpxYo99CMY0KgqoaZBQNkMeKULDBU,3355
249
251
  tests/unit_tests/test_generate_plugin.py,sha256=9603ucZChM-pYpHadzsR94U1Zec1KZT34WedX9qzgMo,4464
250
252
  tests/unit_tests/test_motor_map_widget.py,sha256=3nbINg3NYvWUrrGGMRPs8SDtePjXhoehSY_CShFGvEI,7507
@@ -271,8 +273,8 @@ tests/unit_tests/test_configs/config_device_no_entry.yaml,sha256=hdvue9KLc_kfNzG
271
273
  tests/unit_tests/test_configs/config_scan.yaml,sha256=vo484BbWOjA_e-h6bTjSV9k7QaQHrlAvx-z8wtY-P4E,1915
272
274
  tests/unit_tests/test_msgs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
273
275
  tests/unit_tests/test_msgs/available_scans_message.py,sha256=m_z97hIrjHXXMa2Ex-UvsPmTxOYXfjxyJaGkIY6StTY,46532
274
- bec_widgets-0.86.0.dist-info/METADATA,sha256=KgvvrUy255cF4Cs816POOoo5Cm1JCCUcy4NVzND-eNU,1308
275
- bec_widgets-0.86.0.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
276
- bec_widgets-0.86.0.dist-info/entry_points.txt,sha256=3otEkCdDB9LZJuBLzG4pFLK5Di0CVybN_12IsZrQ-58,166
277
- bec_widgets-0.86.0.dist-info/licenses/LICENSE,sha256=YRKe85CBRyP7UpEAWwU8_qSIyuy5-l_9C-HKg5Qm8MQ,1511
278
- bec_widgets-0.86.0.dist-info/RECORD,,
276
+ bec_widgets-0.87.0.dist-info/METADATA,sha256=zgqysKuGz67anBxMymaso7hUNfkKS04uMlwLVOL5e38,1308
277
+ bec_widgets-0.87.0.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
278
+ bec_widgets-0.87.0.dist-info/entry_points.txt,sha256=3otEkCdDB9LZJuBLzG4pFLK5Di0CVybN_12IsZrQ-58,166
279
+ bec_widgets-0.87.0.dist-info/licenses/LICENSE,sha256=YRKe85CBRyP7UpEAWwU8_qSIyuy5-l_9C-HKg5Qm8MQ,1511
280
+ bec_widgets-0.87.0.dist-info/RECORD,,
pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bec_widgets"
7
- version = "0.86.0"
7
+ version = "0.87.0"
8
8
  description = "BEC Widgets"
9
9
  requires-python = ">=3.10"
10
10
  classifiers = [
@@ -0,0 +1,63 @@
1
+ from unittest.mock import patch
2
+
3
+ import pytest
4
+ import pytestqt
5
+ from qtpy.QtWidgets import QMessageBox
6
+
7
+ from bec_widgets.qt_utils.error_popups import ErrorPopupUtility, ExampleWidget
8
+
9
+
10
+ @pytest.fixture
11
+ def widget(qtbot):
12
+ test_widget = ExampleWidget()
13
+ qtbot.addWidget(test_widget)
14
+ qtbot.waitExposed(test_widget)
15
+ yield test_widget
16
+ test_widget.close()
17
+
18
+
19
+ @patch.object(QMessageBox, "exec_", return_value=QMessageBox.Ok)
20
+ def test_show_error_message_global(mock_exec, widget, qtbot):
21
+ error_utility = ErrorPopupUtility()
22
+ error_utility.enable_global_error_popups(True)
23
+
24
+ with qtbot.waitSignal(error_utility.error_occurred, timeout=1000) as blocker:
25
+ error_utility.error_occurred.emit("Test Error", "This is a test error message.", widget)
26
+
27
+ assert mock_exec.called
28
+ assert blocker.signal_triggered
29
+
30
+
31
+ @pytest.mark.parametrize("global_pop", [False, True])
32
+ @patch.object(QMessageBox, "exec_", return_value=QMessageBox.Ok)
33
+ def test_slot_with_popup_on_error(mock_exec, widget, qtbot, global_pop):
34
+ error_utility = ErrorPopupUtility()
35
+ error_utility.enable_global_error_popups(global_pop)
36
+
37
+ with qtbot.waitSignal(error_utility.error_occurred, timeout=200) as blocker:
38
+ widget.method_with_error_handling()
39
+
40
+ assert blocker.signal_triggered
41
+ assert mock_exec.called
42
+
43
+
44
+ @pytest.mark.parametrize("global_pop", [False, True])
45
+ @patch.object(QMessageBox, "exec_", return_value=QMessageBox.Ok)
46
+ def test_slot_no_popup_by_default_on_error(mock_exec, widget, qtbot, capsys, global_pop):
47
+ error_utility = ErrorPopupUtility()
48
+ error_utility.enable_global_error_popups(global_pop)
49
+
50
+ try:
51
+ with qtbot.waitSignal(error_utility.error_occurred, timeout=200) as blocker:
52
+ widget.method_without_error_handling()
53
+ except pytestqt.exceptions.TimeoutError:
54
+ assert not global_pop
55
+
56
+ if global_pop:
57
+ assert blocker.signal_triggered
58
+ assert mock_exec.called
59
+ else:
60
+ assert not blocker.signal_triggered
61
+ assert not mock_exec.called
62
+ stdout, stderr = capsys.readouterr()
63
+ assert "ValueError" in stderr