diff --git a/bec_widgets/utils/bec_designer.py b/bec_widgets/utils/bec_designer.py index d92682b2..fece831e 100644 --- a/bec_widgets/utils/bec_designer.py +++ b/bec_widgets/utils/bec_designer.py @@ -4,11 +4,13 @@ import os import site import sys import sysconfig +import warnings from pathlib import Path from bec_qthemes import material_icon from qtpy import PYSIDE6 from qtpy.QtGui import QIcon +from qtpy.QtWidgets import QApplication from bec_widgets.utils.bec_plugin_helper import user_widget_plugin @@ -25,6 +27,47 @@ if PYSIDE6: import bec_widgets +def _install_designer_cleanup() -> None: + """Release pyqtgraph's application callbacks before Designer finalizes Python. + + Designer owns the QApplication in C++; its style hints outlive the embedded + interpreter. A Python callback left connected there can crash on destruction. + Install this only through Designer's icon entry point, preserving normal apps + and keeping theme updates active until Designer quits. + """ + app = QApplication.instance() + if app is None or getattr(app, "_bec_designer_cleanup_installed", False): + return + + def cleanup(): + # This callback must also be released while Python is still initialized. + app.aboutToQuit.disconnect(cleanup) + app._bec_designer_cleanup_installed = False + pg_qt = sys.modules.get("pyqtgraph.Qt") + if pg_qt is None or getattr(pg_qt, "QAPP", None) is not app: + return + callbacks = ( + (app.styleHints().colorSchemeChanged, "_onColorSchemeChange"), + (app.paletteChanged, "_onPaletteChange"), + ) + for signal, name in callbacks: + callback = getattr(pg_qt, name, None) + if callback is None: + continue + # pyqtgraph selects one of these callbacks depending on the Qt version. + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", message=".*Failed to disconnect", category=RuntimeWarning + ) + try: + signal.disconnect(callback) + except (RuntimeError, TypeError): + pass + + app.aboutToQuit.connect(cleanup) + app._bec_designer_cleanup_installed = True + + def designer_material_icon(icon_name: str) -> QIcon: """ Create a QIcon for the BECDesigner with the given material icon name. @@ -35,6 +78,7 @@ def designer_material_icon(icon_name: str) -> QIcon: Returns: QIcon: The QIcon for the material icon. """ + _install_designer_cleanup() return QIcon(material_icon(icon_name, filled=True, convert_to_pixmap=True)) diff --git a/tests/unit_tests/test_bec_designer.py b/tests/unit_tests/test_bec_designer.py new file mode 100644 index 00000000..60628b99 --- /dev/null +++ b/tests/unit_tests/test_bec_designer.py @@ -0,0 +1,80 @@ +import sys +from types import SimpleNamespace +from unittest import mock + +import pytest +from qtpy.QtCore import SIGNAL, QObject, Qt, Signal + +from bec_widgets.utils import bec_designer + + +class _DesignerStyleHints(QObject): + colorSchemeChanged = Signal(object) + + +class _DesignerApplication(QObject): + """Private signal sources: emitting quit must never shut down pytest's qapp.""" + + aboutToQuit = Signal() + paletteChanged = Signal(object) + + def __init__(self): + super().__init__() + self._style_hints = _DesignerStyleHints(self) + + def styleHints(self): + return self._style_hints + + +@pytest.mark.parametrize("theme_signal", ["colorSchemeChanged", "paletteChanged"]) +def test_designer_disconnects_pyqtgraph_callbacks_on_quit(qapp, monkeypatch, theme_signal): + """Theme updates work until quit, then only pyqtgraph's callback is released.""" + app = _DesignerApplication() + callback = mock.Mock() + unrelated = mock.Mock() + real_shutdown = mock.Mock() + qapp.aboutToQuit.connect(real_shutdown) + if theme_signal == "colorSchemeChanged": + signal = app.styleHints().colorSchemeChanged + value = Qt.ColorScheme.Dark + callback_name = "_onColorSchemeChange" + else: + signal = app.paletteChanged + value = qapp.palette() + callback_name = "_onPaletteChange" + + pg_qt = SimpleNamespace(QAPP=app, **{callback_name: callback}) + signal.connect(callback) + signal.connect(unrelated) + try: + # Restore QApplication.instance before the shared Qt fixtures tear down. + with mock.patch.object(bec_designer.QApplication, "instance", return_value=app): + bec_designer._install_designer_cleanup() + # Several plugins ask for icons. Only one shutdown callback is needed. + bec_designer._install_designer_cleanup() + assert app.receivers(SIGNAL("aboutToQuit()")) == 1 + # pyqtgraph may be imported after the first plugin's icon is requested. + monkeypatch.setitem(sys.modules, "pyqtgraph.Qt", pg_qt) + signal.emit(value) + callback.assert_called_once() + + app.aboutToQuit.emit() + assert not app._bec_designer_cleanup_installed + assert app.receivers(SIGNAL("aboutToQuit()")) == 0 + signal.emit(value) + callback.assert_called_once() + assert unrelated.call_count == 2 + # Self-disconnection also makes a repeated private quit harmless. + app.aboutToQuit.emit() + real_shutdown.assert_not_called() + finally: + signal.disconnect(unrelated) + if getattr(app, "_bec_designer_cleanup_installed", False): + app.aboutToQuit.emit() + qapp.aboutToQuit.disconnect(real_shutdown) + app.deleteLater() + + +def test_designer_cleanup_without_application(monkeypatch): + with mock.patch.object(bec_designer.QApplication, "instance", return_value=None): + bec_designer._install_designer_cleanup()