mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-29 00:11:28 +01:00
fix(designer): release pyqtgraph theme callbacks before shutdown
This commit is contained in:
1 parent
3b7a26fb26
commit
ed145cc6d6
2 files changed
+124
No files matched your search
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user