fix(designer): release pyqtgraph theme callbacks before shutdown

This commit is contained in:
wakonig_k authored and Jan Wyzula committed 2026-10-07 12:50:07 +02:00
1 parent 3b7a26fb26
commit ed145cc6d6
2 files changed
+124

No files matched your search

+44
View File
@@ -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))
+80
View File
@@ -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()