mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-06-11 07:38:54 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab6a1aecc1 | |||
| d99db7d042 | |||
| a976837cff | |||
| 56427a7f0c |
@@ -1,6 +1,20 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v3.12.1 (2026-05-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **device_input**: Correct cleanup unsubscribe
|
||||
([`56427a7`](https://github.com/bec-project/bec_widgets/commit/56427a7f0c3a89fe847d415c8b45212e663434c4))
|
||||
|
||||
- **device_input**: Ensure callback is removed after cleanup
|
||||
([`d99db7d`](https://github.com/bec-project/bec_widgets/commit/d99db7d04208945b86a39d65022b211ba093caed))
|
||||
|
||||
- **signal_combobox**: Signature matched for update_signals_from_filters
|
||||
([`a976837`](https://github.com/bec-project/bec_widgets/commit/a976837cff612349f2a3f17900903c203bc3d250))
|
||||
|
||||
|
||||
## v3.12.0 (2026-05-20)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -665,9 +665,9 @@ class LaunchWindow(BECMainWindow):
|
||||
try:
|
||||
parent = connection.parent()
|
||||
if parent is None and connection.objectName() != self.objectName():
|
||||
# logger.info(
|
||||
# f"Found non-launcher connection without parent: {connection.objectName()}"
|
||||
# ) #TODO disabled due to high count
|
||||
logger.info(
|
||||
f"Found non-launcher connection without parent: {connection.objectName()}"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting parent of connection: {e}")
|
||||
|
||||
@@ -22,7 +22,6 @@ logger = bec_logger.logger
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib.endpoints import EndpointInfo
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.rpc_server import RPCServer
|
||||
|
||||
|
||||
@@ -43,7 +42,6 @@ class QtThreadSafeCallback(QObject):
|
||||
self.cb_info = cb_info
|
||||
|
||||
self.cb = cb
|
||||
self.cb_owner = louie.saferef.safe_ref(cb.__self__) if hasattr(cb, "__self__") else None
|
||||
self.cb_ref = louie.saferef.safe_ref(cb)
|
||||
self.cb_signal.connect(self.cb)
|
||||
self.topics = set()
|
||||
@@ -242,22 +240,6 @@ class BECDispatcher:
|
||||
# pylint: disable=protected-access
|
||||
self.disconnect_topics(self.client.connector._topics_cb)
|
||||
|
||||
def disconnect_owner(self, owner: BECWidget):
|
||||
"""
|
||||
Disconnect all slots owned by a particular widget.
|
||||
|
||||
Args:
|
||||
owner(BECWidget): The owner widget whose slots should be disconnected
|
||||
"""
|
||||
slots_to_disconnect = []
|
||||
for connected_slot in self._registered_slots.values():
|
||||
if connected_slot.cb_owner is not None and connected_slot.cb_owner() == owner:
|
||||
slots_to_disconnect.append(connected_slot)
|
||||
for slot in slots_to_disconnect:
|
||||
topics = slot.topics.copy()
|
||||
for topic in topics:
|
||||
self.disconnect_slot(slot.cb, topic)
|
||||
|
||||
def start_cli_server(self, gui_id: str | None = None):
|
||||
"""
|
||||
Start the CLI server.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -326,72 +325,19 @@ class BECWidget(BECConnector):
|
||||
return
|
||||
dock.setFloating()
|
||||
|
||||
def _debug_bec_parent_chain(self) -> list[str]:
|
||||
"""Return BECWidget ancestors for warning-level lifecycle diagnostics."""
|
||||
chain: list[str] = []
|
||||
parent = self.parent()
|
||||
while parent is not None:
|
||||
if not shiboken6.isValid(parent):
|
||||
chain.append(f"<invalid parent py_id={id(parent)}>")
|
||||
break
|
||||
if isinstance(parent, BECWidget):
|
||||
chain.append(
|
||||
f"{parent.__class__.__name__}"
|
||||
f"(object={parent.objectName()}, py_id={id(parent)}, "
|
||||
f"destroyed={getattr(parent, '_destroyed', None)})"
|
||||
)
|
||||
parent = parent.parent() if hasattr(parent, "parent") else None
|
||||
return chain
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
logger.warning(
|
||||
"BEC WIDGET LIFECYCLE TRACE | "
|
||||
f"event=cleanup:start | class={self.__class__.__name__} | "
|
||||
f"object={self.objectName()} | py_id={id(self)} | "
|
||||
f"thread={threading.current_thread().name}:{threading.get_ident()} | "
|
||||
f"destroyed={getattr(self, '_destroyed', None)} | "
|
||||
f"bec_parent_chain={self._debug_bec_parent_chain()}"
|
||||
)
|
||||
with RPCRegister.delayed_broadcast():
|
||||
# All widgets need to call super().cleanup() in their cleanup method
|
||||
logger.info(f"Registry cleanup for widget {self.__class__.__name__}")
|
||||
self.rpc_register.remove_rpc(self)
|
||||
children = self.findChildren(BECWidget)
|
||||
logger.warning(
|
||||
"BEC WIDGET LIFECYCLE TRACE | "
|
||||
f"event=cleanup:children-found | class={self.__class__.__name__} | "
|
||||
f"object={self.objectName()} | py_id={id(self)} | child_count={len(children)} | "
|
||||
f"bec_parent_chain={self._debug_bec_parent_chain()}"
|
||||
)
|
||||
for child in children:
|
||||
if not shiboken6.isValid(child):
|
||||
# If the child is not valid, it means it has already been deleted
|
||||
logger.warning(
|
||||
"BEC WIDGET LIFECYCLE TRACE | "
|
||||
f"event=cleanup:skip-invalid-child | parent={self.objectName()} | "
|
||||
f"parent_py_id={id(self)} | child_py_id={id(child)} | "
|
||||
f"parent_bec_parent_chain={self._debug_bec_parent_chain()}"
|
||||
)
|
||||
continue
|
||||
logger.warning(
|
||||
"BEC WIDGET LIFECYCLE TRACE | "
|
||||
f"event=cleanup:closing-child | parent={self.objectName()} | "
|
||||
f"parent_py_id={id(self)} | child_class={child.__class__.__name__} | "
|
||||
f"child_object={child.objectName()} | child_py_id={id(child)} | "
|
||||
f"child_destroyed={getattr(child, '_destroyed', None)} | "
|
||||
f"parent_bec_parent_chain={self._debug_bec_parent_chain()} | "
|
||||
f"child_bec_parent_chain={child._debug_bec_parent_chain()}"
|
||||
)
|
||||
child.close()
|
||||
child.deleteLater()
|
||||
logger.warning(
|
||||
"BEC WIDGET LIFECYCLE TRACE | "
|
||||
f"event=cleanup:child-deleteLater-called | parent={self.objectName()} | "
|
||||
f"parent_py_id={id(self)} | child_class={child.__class__.__name__} | "
|
||||
f"child_object={child.objectName()} | child_py_id={id(child)} | "
|
||||
f"child_bec_parent_chain={child._debug_bec_parent_chain()}"
|
||||
)
|
||||
|
||||
# Tear down busy overlay explicitly to stop spinner and remove filters
|
||||
overlay = getattr(self, "_busy_overlay", None)
|
||||
@@ -411,61 +357,12 @@ class BECWidget(BECConnector):
|
||||
overlay.deleteLater()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to delete busy overlay: {exc}")
|
||||
logger.warning(
|
||||
"BEC WIDGET LIFECYCLE TRACE | "
|
||||
f"event=cleanup:end | class={self.__class__.__name__} | "
|
||||
f"object={self.objectName()} | py_id={id(self)} | "
|
||||
f"destroyed={getattr(self, '_destroyed', None)} | "
|
||||
f"bec_parent_chain={self._debug_bec_parent_chain()}"
|
||||
)
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Wrap the close even to ensure the rpc_register is cleaned up."""
|
||||
logger.warning(
|
||||
"BEC WIDGET LIFECYCLE TRACE | "
|
||||
f"event=closeEvent:enter | class={self.__class__.__name__} | "
|
||||
f"object={self.objectName()} | py_id={id(self)} | "
|
||||
f"thread={threading.current_thread().name}:{threading.get_ident()} | "
|
||||
f"destroyed={getattr(self, '_destroyed', None)} | "
|
||||
f"bec_parent_chain={self._debug_bec_parent_chain()}"
|
||||
)
|
||||
try:
|
||||
if not self._destroyed:
|
||||
self.bec_dispatcher.disconnect_owner(self)
|
||||
logger.warning(
|
||||
"BEC WIDGET LIFECYCLE TRACE | "
|
||||
f"event=closeEvent:before-cleanup | class={self.__class__.__name__} | "
|
||||
f"object={self.objectName()} | py_id={id(self)} | "
|
||||
f"bec_parent_chain={self._debug_bec_parent_chain()}"
|
||||
)
|
||||
self.cleanup()
|
||||
self._destroyed = True
|
||||
logger.warning(
|
||||
"BEC WIDGET LIFECYCLE TRACE | "
|
||||
f"event=closeEvent:after-cleanup-set-destroyed | "
|
||||
f"class={self.__class__.__name__} | object={self.objectName()} | "
|
||||
f"py_id={id(self)} | destroyed={self._destroyed} | "
|
||||
f"bec_parent_chain={self._debug_bec_parent_chain()}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"BEC WIDGET LIFECYCLE TRACE | "
|
||||
f"event=closeEvent:already-destroyed | class={self.__class__.__name__} | "
|
||||
f"object={self.objectName()} | py_id={id(self)} | "
|
||||
f"bec_parent_chain={self._debug_bec_parent_chain()}"
|
||||
)
|
||||
finally:
|
||||
logger.warning(
|
||||
"BEC WIDGET LIFECYCLE TRACE | "
|
||||
f"event=closeEvent:calling-super | class={self.__class__.__name__} | "
|
||||
f"object={self.objectName()} | py_id={id(self)} | "
|
||||
f"bec_parent_chain={self._debug_bec_parent_chain()}"
|
||||
)
|
||||
super().closeEvent(event) # pylint: disable=no-member
|
||||
logger.warning(
|
||||
"BEC WIDGET LIFECYCLE TRACE | "
|
||||
f"event=closeEvent:exit | class={self.__class__.__name__} | "
|
||||
f"object={self.objectName()} | py_id={id(self)} | "
|
||||
f"destroyed={getattr(self, '_destroyed', None)} | "
|
||||
f"bec_parent_chain={self._debug_bec_parent_chain()}"
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy import PYSIDE6
|
||||
from qtpy.QtCore import QEvent, QFile, QIODevice, QObject
|
||||
from qtpy.QtCore import QFile, QIODevice
|
||||
|
||||
from bec_widgets.utils.plugin_utils import get_designer_plugin
|
||||
|
||||
@@ -9,56 +9,16 @@ logger = bec_logger.logger
|
||||
if PYSIDE6:
|
||||
from qtpy.QtUiTools import QUiLoader
|
||||
|
||||
class _LoadedUiCloser(QObject):
|
||||
"""Forward root close events to widgets instantiated by ``QUiLoader``.
|
||||
|
||||
Destroying a parent widget does not guarantee ``closeEvent`` is delivered to
|
||||
every child widget. Some of our designer plugins rely on ``closeEvent`` /
|
||||
``cleanup`` to unregister callbacks, so explicitly close loaded descendants
|
||||
when the loaded form itself is closed.
|
||||
"""
|
||||
|
||||
def __init__(self, root_widget):
|
||||
super().__init__(root_widget)
|
||||
self._root_widget = root_widget
|
||||
self._widgets = []
|
||||
root_widget.installEventFilter(self)
|
||||
|
||||
def register_widget(self, widget):
|
||||
if widget is None or widget is self._root_widget:
|
||||
return
|
||||
self._widgets.append(widget)
|
||||
|
||||
def eventFilter(self, watched, event):
|
||||
if watched is self._root_widget and event.type() == QEvent.Close:
|
||||
for widget in reversed(self._widgets):
|
||||
try:
|
||||
widget.close()
|
||||
except RuntimeError:
|
||||
continue
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
class CustomUiLoader(QUiLoader):
|
||||
def __init__(self, baseinstance):
|
||||
super().__init__(baseinstance)
|
||||
self.baseinstance = baseinstance
|
||||
self._closer = _LoadedUiCloser(baseinstance) if baseinstance is not None else None
|
||||
|
||||
def createWidget(self, class_name, parent=None, name=""):
|
||||
if parent is None and self.baseinstance is not None:
|
||||
return self.baseinstance
|
||||
|
||||
widget_parent = parent if parent is not None else self.baseinstance
|
||||
widget = get_designer_plugin(class_name, raise_on_missing=False)
|
||||
if widget is not None:
|
||||
created_widget = widget(widget_parent)
|
||||
created_widget.setObjectName(name)
|
||||
else:
|
||||
created_widget = super().createWidget(class_name, widget_parent, name)
|
||||
|
||||
if self._closer is not None:
|
||||
self._closer.register_widget(created_widget)
|
||||
return created_widget
|
||||
return widget(self.baseinstance)
|
||||
return super().createWidget(class_name, self.baseinstance, name)
|
||||
|
||||
|
||||
class UILoader:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Literal, Mapping, Sequence, cast
|
||||
|
||||
@@ -167,31 +166,9 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
|
||||
def _default_close_handler(self, dock: CDockWidget, widget: QWidget) -> None:
|
||||
"""Default dock close routine used when no custom handler is provided."""
|
||||
logger.warning(
|
||||
"DOCK AREA LIFECYCLE TRACE | "
|
||||
f"event=default-close-handler:start | dock={dock.objectName()} | "
|
||||
f"dock_py_id={id(dock)} | widget={widget.objectName()} | "
|
||||
f"widget_class={widget.__class__.__name__} | widget_py_id={id(widget)} | "
|
||||
f"thread={threading.current_thread().name}:{threading.get_ident()}"
|
||||
)
|
||||
widget.close()
|
||||
logger.warning(
|
||||
"DOCK AREA LIFECYCLE TRACE | "
|
||||
f"event=default-close-handler:after-widget-close | dock={dock.objectName()} | "
|
||||
f"dock_valid={isValid(dock)} | widget={widget.objectName()} | "
|
||||
f"widget_valid={isValid(widget)}"
|
||||
)
|
||||
dock.closeDockWidget()
|
||||
logger.warning(
|
||||
"DOCK AREA LIFECYCLE TRACE | "
|
||||
f"event=default-close-handler:after-closeDockWidget | dock={dock.objectName()} | "
|
||||
f"dock_valid={isValid(dock)}"
|
||||
)
|
||||
dock.deleteDockWidget()
|
||||
logger.warning(
|
||||
"DOCK AREA LIFECYCLE TRACE | "
|
||||
f"event=default-close-handler:end | dock_py_id={id(dock)} | dock_valid={isValid(dock)}"
|
||||
)
|
||||
|
||||
def close_dock(self, dock: CDockWidget, widget: QWidget | None = None) -> None:
|
||||
"""
|
||||
@@ -395,45 +372,12 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
close_handler = self._resolve_close_handler(widget, on_close)
|
||||
|
||||
def on_widget_destroyed():
|
||||
logger.warning(
|
||||
"DOCK AREA LIFECYCLE TRACE | "
|
||||
f"event=widget_removed-signal:start | dock_py_id={id(dock)} | "
|
||||
f"dock_valid={isValid(dock)} | widget={widget.objectName()} | "
|
||||
f"widget_py_id={id(widget)} | thread={threading.current_thread().name}:{threading.get_ident()}"
|
||||
)
|
||||
if not isValid(dock):
|
||||
logger.warning(
|
||||
"DOCK AREA LIFECYCLE TRACE | "
|
||||
f"event=widget_removed-signal:dock-invalid-return | dock_py_id={id(dock)}"
|
||||
)
|
||||
return
|
||||
dock.closeDockWidget()
|
||||
logger.warning(
|
||||
"DOCK AREA LIFECYCLE TRACE | "
|
||||
f"event=widget_removed-signal:after-closeDockWidget | dock={dock.objectName()} | "
|
||||
f"dock_valid={isValid(dock)}"
|
||||
)
|
||||
dock.deleteDockWidget()
|
||||
logger.warning(
|
||||
"DOCK AREA LIFECYCLE TRACE | "
|
||||
f"event=widget_removed-signal:end | dock_py_id={id(dock)} | dock_valid={isValid(dock)}"
|
||||
)
|
||||
|
||||
def on_close_requested():
|
||||
logger.warning(
|
||||
"DOCK AREA LIFECYCLE TRACE | "
|
||||
f"event=closeRequested:start | dock={dock.objectName()} | dock_py_id={id(dock)} | "
|
||||
f"widget={widget.objectName()} | widget_class={widget.__class__.__name__} | "
|
||||
f"widget_py_id={id(widget)} | thread={threading.current_thread().name}:{threading.get_ident()}"
|
||||
)
|
||||
close_handler(dock)
|
||||
logger.warning(
|
||||
"DOCK AREA LIFECYCLE TRACE | "
|
||||
f"event=closeRequested:end | dock_py_id={id(dock)} | dock_valid={isValid(dock)} | "
|
||||
f"widget_py_id={id(widget)} | widget_valid={isValid(widget)}"
|
||||
)
|
||||
|
||||
dock.closeRequested.connect(on_close_requested)
|
||||
dock.closeRequested.connect(lambda: close_handler(dock))
|
||||
if hasattr(widget, "widget_removed"):
|
||||
widget.widget_removed.connect(on_widget_destroyed)
|
||||
|
||||
@@ -466,50 +410,13 @@ class DockAreaWidget(BECWidget, QWidget):
|
||||
return dock
|
||||
|
||||
def _delete_dock(self, dock: CDockWidget) -> None:
|
||||
logger.warning(
|
||||
"DOCK AREA LIFECYCLE TRACE | "
|
||||
f"event=delete-dock:start | dock={dock.objectName()} | dock_py_id={id(dock)} | "
|
||||
f"dock_valid={isValid(dock)} | thread={threading.current_thread().name}:{threading.get_ident()}"
|
||||
)
|
||||
widget = dock.widget()
|
||||
logger.warning(
|
||||
"DOCK AREA LIFECYCLE TRACE | "
|
||||
f"event=delete-dock:widget-resolved | dock_py_id={id(dock)} | "
|
||||
f"widget={widget.objectName() if widget else None} | "
|
||||
f"widget_class={widget.__class__.__name__ if widget else None} | "
|
||||
f"widget_py_id={id(widget) if widget else None} | "
|
||||
f"widget_valid={isValid(widget) if widget else None}"
|
||||
)
|
||||
if widget and isValid(widget):
|
||||
widget.close()
|
||||
logger.warning(
|
||||
"DOCK AREA LIFECYCLE TRACE | "
|
||||
f"event=delete-dock:after-widget-close | dock_py_id={id(dock)} | "
|
||||
f"widget={widget.objectName()} | widget_valid={isValid(widget)}"
|
||||
)
|
||||
widget.deleteLater()
|
||||
logger.warning(
|
||||
"DOCK AREA LIFECYCLE TRACE | "
|
||||
f"event=delete-dock:after-widget-deleteLater | dock_py_id={id(dock)} | "
|
||||
f"widget_py_id={id(widget)} | widget_valid={isValid(widget)}"
|
||||
)
|
||||
if isValid(dock):
|
||||
dock.closeDockWidget()
|
||||
logger.warning(
|
||||
"DOCK AREA LIFECYCLE TRACE | "
|
||||
f"event=delete-dock:after-closeDockWidget | dock={dock.objectName()} | "
|
||||
f"dock_valid={isValid(dock)}"
|
||||
)
|
||||
dock.deleteDockWidget()
|
||||
logger.warning(
|
||||
"DOCK AREA LIFECYCLE TRACE | "
|
||||
f"event=delete-dock:end | dock_py_id={id(dock)} | dock_valid={isValid(dock)}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"DOCK AREA LIFECYCLE TRACE | "
|
||||
f"event=delete-dock:dock-invalid-skip | dock_py_id={id(dock)}"
|
||||
)
|
||||
|
||||
def _resolve_dock_reference(
|
||||
self, ref: CDockWidget | QWidget | str | None, *, allow_none: bool = True
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import threading
|
||||
|
||||
from bec_lib.callback_handler import EventType
|
||||
from bec_lib.device import ComputedSignal, Device, Positioner, ReadoutPriority
|
||||
@@ -217,28 +216,12 @@ class DeviceComboBox(BECWidget, QComboBox):
|
||||
else:
|
||||
self.setCurrentText("")
|
||||
|
||||
self._log_callback_state("init: before DEVICE_UPDATE register")
|
||||
self._callback_id = self.bec_dispatcher.client.callbacks.register(
|
||||
EventType.DEVICE_UPDATE, self.on_device_update
|
||||
)
|
||||
self._log_callback_state("init: after DEVICE_UPDATE register")
|
||||
self.device_config_update.connect(self.update_devices_from_filters)
|
||||
self._log_callback_state("init: device_config_update connected")
|
||||
self.currentTextChanged.connect(self.check_validity)
|
||||
self.check_validity(self.currentText())
|
||||
self._log_callback_state("init: finished")
|
||||
|
||||
def _log_callback_state(self, event: str, **details) -> None:
|
||||
logger.warning(
|
||||
"DEVICE COMBOBOX CALLBACK TRACE | "
|
||||
f"event={event} | object={self.objectName()} | py_id={id(self)} | "
|
||||
f"thread={threading.current_thread().name}:{threading.get_ident()} | "
|
||||
f"callback_id={getattr(self, '_callback_id', None)} | "
|
||||
f"destroyed={getattr(self, '_destroyed', None)} | "
|
||||
f"current={self.currentText()} | devices_count={len(getattr(self, '_devices', []))} | "
|
||||
f"bec_parent_chain={self._debug_bec_parent_chain()} | "
|
||||
f"details={details}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _process_config(config: DeviceInputConfig | dict | None) -> DeviceInputConfig:
|
||||
@@ -272,25 +255,16 @@ class DeviceComboBox(BECWidget, QComboBox):
|
||||
@SafeSlot()
|
||||
def update_devices_from_filters(self):
|
||||
"""Refresh the available device list from current device/readout/signal filters."""
|
||||
self._log_callback_state(
|
||||
"update_devices_from_filters: enter",
|
||||
apply_filter=self.apply_filter,
|
||||
device_filter=[entry.value for entry in self.device_filter],
|
||||
readout_filter=[entry.value for entry in self.readout_filter],
|
||||
signal_class_filter=self.signal_class_filter,
|
||||
)
|
||||
self.config.device_filter = [entry.value for entry in self.device_filter]
|
||||
self.config.readout_filter = [entry.value for entry in self.readout_filter]
|
||||
self.config.signal_class_filter = self.signal_class_filter
|
||||
if not self.apply_filter:
|
||||
self._log_callback_state("update_devices_from_filters: apply-filter false return")
|
||||
return
|
||||
|
||||
devices = self._filter_devices_by_signal_class(self.dev.enabled_devices)
|
||||
devices = [device for device in devices if self._check_device_filter(device)]
|
||||
devices = [device for device in devices if self._check_readout_filter(device)]
|
||||
self.devices = [device.name for device in devices]
|
||||
self._log_callback_state("update_devices_from_filters: finished")
|
||||
|
||||
@SafeSlot(list)
|
||||
def set_available_devices(self, devices: list[str]):
|
||||
@@ -515,26 +489,18 @@ class DeviceComboBox(BECWidget, QComboBox):
|
||||
action: Device update action emitted by BEC.
|
||||
content: Device update payload. Currently unused.
|
||||
"""
|
||||
self._log_callback_state("on_device_update: enter", action=action, content=content)
|
||||
if self._callback_id is None or getattr(self, "_destroyed", False):
|
||||
return
|
||||
if action in ["add", "remove", "reload"]:
|
||||
self._log_callback_state("on_device_update: emitting device_config_update")
|
||||
self.device_config_update.emit()
|
||||
self._log_callback_state("on_device_update: emitted device_config_update")
|
||||
else:
|
||||
self._log_callback_state("on_device_update: ignored action", action=action)
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
self._log_callback_state("cleanup: start")
|
||||
if self._callback_id is not None:
|
||||
self._log_callback_state("cleanup: removing callback")
|
||||
self.bec_dispatcher.client.callbacks.remove(self._callback_id)
|
||||
callback_id = self._callback_id
|
||||
self._callback_id = None
|
||||
self._log_callback_state("cleanup: callback removed")
|
||||
else:
|
||||
self._log_callback_state("cleanup: callback already None")
|
||||
self.bec_dispatcher.client.callbacks.remove(callback_id)
|
||||
super().cleanup()
|
||||
self._log_callback_state("cleanup: after super")
|
||||
|
||||
def get_current_device(self) -> object:
|
||||
"""Return the current BEC device object.
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
from bec_lib.callback_handler import EventType
|
||||
from bec_lib.device import Signal as BECSignal
|
||||
from bec_lib.logger import bec_logger
|
||||
@@ -139,13 +137,10 @@ class SignalComboBox(BECWidget, QComboBox):
|
||||
if self.config.autocomplete:
|
||||
self.autocomplete = True
|
||||
|
||||
self._log_callback_state("init: before DEVICE_UPDATE register")
|
||||
self._device_update_register = self.bec_dispatcher.client.callbacks.register(
|
||||
EventType.DEVICE_UPDATE, self.on_device_update
|
||||
EventType.DEVICE_UPDATE, self.update_signals_from_filters
|
||||
)
|
||||
self._log_callback_state("init: after DEVICE_UPDATE register")
|
||||
self.currentTextChanged.connect(self.on_text_changed)
|
||||
self._log_callback_state("init: currentTextChanged connected")
|
||||
|
||||
self.set_filter(signal_filter or [Kind.hinted, Kind.normal, Kind.config])
|
||||
|
||||
@@ -154,19 +149,6 @@ class SignalComboBox(BECWidget, QComboBox):
|
||||
if default is not None:
|
||||
self.set_signal(default)
|
||||
self.check_validity(self.currentText())
|
||||
self._log_callback_state("init: finished")
|
||||
|
||||
def _log_callback_state(self, event: str, **details) -> None:
|
||||
logger.warning(
|
||||
"SIGNAL COMBOBOX CALLBACK TRACE | "
|
||||
f"event={event} | object={self.objectName()} | py_id={id(self)} | "
|
||||
f"thread={threading.current_thread().name}:{threading.get_ident()} | "
|
||||
f"callback_id={getattr(self, '_device_update_register', None)} | "
|
||||
f"destroyed={getattr(self, '_destroyed', None)} | "
|
||||
f"device={getattr(self, '_device', None)} | current={self.currentText()} | "
|
||||
f"bec_parent_chain={self._debug_bec_parent_chain()} | "
|
||||
f"details={details}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _process_config(config: SignalComboBoxConfig | dict | None) -> SignalComboBoxConfig:
|
||||
@@ -208,18 +190,11 @@ class SignalComboBox(BECWidget, QComboBox):
|
||||
"""
|
||||
previous_device = self._device
|
||||
valid_device = device if self.validate_device(device) else None
|
||||
self._log_callback_state(
|
||||
"set_device: before update_signals_from_filters",
|
||||
requested_device=device,
|
||||
previous_device=previous_device,
|
||||
valid_device=valid_device,
|
||||
)
|
||||
self._device = valid_device
|
||||
self.config.device = self._device
|
||||
if valid_device is None or valid_device != previous_device:
|
||||
self.setCurrentText("")
|
||||
self.update_signals_from_filters()
|
||||
self._log_callback_state("set_device: after update_signals_from_filters")
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(str, dict)
|
||||
@@ -231,27 +206,19 @@ class SignalComboBox(BECWidget, QComboBox):
|
||||
actions trigger a refresh.
|
||||
content: Optional callback payload from BEC device updates. Currently unused.
|
||||
"""
|
||||
if action is not None and action not in ["add", "remove", "reload"]:
|
||||
self._log_callback_state("update_signals_from_filters: ignored action", action=action)
|
||||
if self._device_update_register is None or getattr(self, "_destroyed", False):
|
||||
return
|
||||
|
||||
if action is not None and action not in ["add", "remove", "reload"]:
|
||||
return
|
||||
|
||||
self._log_callback_state(
|
||||
"update_signals_from_filters: enter",
|
||||
action=action,
|
||||
content=content,
|
||||
signal_class_filter=self._signal_class_filter,
|
||||
require_device=self._require_device,
|
||||
)
|
||||
self.config.signal_filter = [kind.name for kind in self.signal_filter]
|
||||
|
||||
if self._signal_class_filter:
|
||||
self._log_callback_state("update_signals_from_filters: class-filter path")
|
||||
self.update_signals_from_signal_classes()
|
||||
self._log_callback_state("update_signals_from_filters: class-filter return")
|
||||
return
|
||||
|
||||
if not self.validate_device(self._device):
|
||||
self._log_callback_state("update_signals_from_filters: invalid-device return")
|
||||
self._device = None
|
||||
self.config.device = None
|
||||
self._set_signal_groups([], [], [])
|
||||
@@ -261,7 +228,6 @@ class SignalComboBox(BECWidget, QComboBox):
|
||||
device_info = device._info.get("signals", {})
|
||||
|
||||
if isinstance(device, BECSignal):
|
||||
self._log_callback_state("update_signals_from_filters: bec-signal return")
|
||||
self._set_signal_groups([(self._device, {})], [], [])
|
||||
return
|
||||
|
||||
@@ -285,20 +251,6 @@ class SignalComboBox(BECWidget, QComboBox):
|
||||
device_name=self._device,
|
||||
),
|
||||
)
|
||||
self._log_callback_state(
|
||||
"update_signals_from_filters: finished",
|
||||
signal_count=len(self._signals),
|
||||
hinted_count=len(self._hinted_signals),
|
||||
normal_count=len(self._normal_signals),
|
||||
config_count=len(self._config_signals),
|
||||
)
|
||||
|
||||
def on_device_update(self, action: str, content: dict) -> None:
|
||||
"""Log BEC device-update callback entry before refreshing filters."""
|
||||
self._log_callback_state("on_device_update: enter", action=action, content=content)
|
||||
self._log_callback_state("on_device_update: before update_signals_from_filters")
|
||||
self.update_signals_from_filters(action, content)
|
||||
self._log_callback_state("on_device_update: after update_signals_from_filters")
|
||||
|
||||
@Property(str)
|
||||
def device(self) -> str:
|
||||
@@ -641,16 +593,11 @@ class SignalComboBox(BECWidget, QComboBox):
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
self._log_callback_state("cleanup: start")
|
||||
if self._device_update_register is not None:
|
||||
self._log_callback_state("cleanup: removing callback")
|
||||
self.bec_dispatcher.client.callbacks.remove(self._device_update_register)
|
||||
callback_id = self._device_update_register
|
||||
self._device_update_register = None
|
||||
self._log_callback_state("cleanup: callback removed")
|
||||
else:
|
||||
self._log_callback_state("cleanup: callback already None")
|
||||
self.bec_dispatcher.client.callbacks.remove(callback_id)
|
||||
super().cleanup()
|
||||
self._log_callback_state("cleanup: after super")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_kind(value: Kind | str) -> Kind | None:
|
||||
|
||||
@@ -5,9 +5,6 @@ from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DeviceSelection(QWidget):
|
||||
@@ -17,7 +14,6 @@ class DeviceSelection(QWidget):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
self.client = client
|
||||
self._cleanup_done = False
|
||||
self.supported_signals = [
|
||||
"PreviewSignal",
|
||||
"AsyncSignal",
|
||||
@@ -142,12 +138,10 @@ class DeviceSelection(QWidget):
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up the widget resources."""
|
||||
logger.error("Cleaning up DeviceSelection")
|
||||
if self._cleanup_done:
|
||||
return
|
||||
self._cleanup_done = True
|
||||
self.device_combo_box.close()
|
||||
self.device_combo_box.deleteLater()
|
||||
self.signal_combo_box.close()
|
||||
self.signal_combo_box.deleteLater()
|
||||
|
||||
|
||||
def device_selection_bundle(components: ToolbarComponents, client=None) -> ToolbarBundle:
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "3.12.0"
|
||||
version = "3.12.1"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.11"
|
||||
classifiers = [
|
||||
@@ -65,6 +65,7 @@ qtermwidget = ["pyside6_qtermwidget"]
|
||||
|
||||
|
||||
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
@@ -139,6 +139,23 @@ def test_device_input_combobox_cleanup_unregisters_callback(qtbot, mocked_client
|
||||
assert widget._callback_id is None
|
||||
|
||||
|
||||
def test_device_input_combobox_cleanup_clears_callback_before_unregister(qtbot, mocked_client):
|
||||
widget = DeviceComboBox(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
callback_id = widget._callback_id
|
||||
|
||||
def assert_callback_cleared(removed_callback_id):
|
||||
assert removed_callback_id == callback_id
|
||||
assert widget._callback_id is None
|
||||
|
||||
with mock.patch.object(
|
||||
mocked_client.callbacks, "remove", side_effect=assert_callback_cleared
|
||||
) as remove_mock:
|
||||
widget.cleanup()
|
||||
|
||||
remove_mock.assert_called_once_with(callback_id)
|
||||
|
||||
|
||||
def test_get_device_from_input_combobox_init(device_input_combobox):
|
||||
device_input_combobox.setCurrentIndex(0)
|
||||
device_text = device_input_combobox.currentText()
|
||||
|
||||
@@ -196,6 +196,50 @@ def test_device_signal_input_base_cleanup(qtbot, mocked_client):
|
||||
assert widget._device_update_register is None
|
||||
|
||||
|
||||
def test_signal_combobox_cleanup_clears_callback_before_unregister(qtbot, mocked_client):
|
||||
widget = create_widget(qtbot=qtbot, widget=SignalComboBox, client=mocked_client)
|
||||
callback_id = widget._device_update_register
|
||||
|
||||
def assert_callback_cleared(removed_callback_id):
|
||||
assert removed_callback_id == callback_id
|
||||
assert widget._device_update_register is None
|
||||
|
||||
with mock.patch.object(
|
||||
mocked_client.callbacks, "remove", side_effect=assert_callback_cleared
|
||||
) as remove_mock:
|
||||
widget.cleanup()
|
||||
|
||||
remove_mock.assert_called_once_with(callback_id)
|
||||
|
||||
|
||||
def test_signal_combobox_cleanup_blocks_in_flight_device_update(qtbot, mocked_client):
|
||||
widget = create_widget(qtbot=qtbot, widget=SignalComboBox, client=mocked_client)
|
||||
callback_id = widget._device_update_register
|
||||
|
||||
def trigger_in_flight_update(_):
|
||||
widget.update_signals_from_filters("reload", {})
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
mocked_client.callbacks, "remove", side_effect=trigger_in_flight_update
|
||||
) as remove_mock,
|
||||
mock.patch.object(widget, "_set_signal_groups") as set_signal_groups,
|
||||
):
|
||||
widget.cleanup()
|
||||
|
||||
remove_mock.assert_called_once_with(callback_id)
|
||||
set_signal_groups.assert_not_called()
|
||||
|
||||
|
||||
def test_signal_combobox_device_update_ignores_update_action(qtbot, mocked_client):
|
||||
widget = create_widget(qtbot=qtbot, widget=SignalComboBox, client=mocked_client)
|
||||
|
||||
with mock.patch.object(widget, "_set_signal_groups") as set_signal_groups:
|
||||
widget.update_signals_from_filters("update", {})
|
||||
|
||||
set_signal_groups.assert_not_called()
|
||||
|
||||
|
||||
def test_signal_combobox_get_signal_name_with_item_data(qtbot, device_signal_combobox):
|
||||
"""Test get_signal_name returns obj_name from item data when available."""
|
||||
device_signal_combobox.include_normal_signals = True
|
||||
|
||||
@@ -35,17 +35,6 @@ def test_initialization_defaults(qtbot, mocked_client):
|
||||
assert bec_image_view._color_bar is None
|
||||
|
||||
|
||||
def test_image_cleanup_cleans_toolbar_device_selection_callbacks(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
device_selection = bec_image_view.toolbar.components.get_action("device_selection").widget
|
||||
|
||||
bec_image_view.cleanup()
|
||||
|
||||
assert device_selection._cleanup_done is True
|
||||
assert device_selection.device_combo_box._callback_id is None
|
||||
assert device_selection.signal_combo_box._device_update_register is None
|
||||
|
||||
|
||||
def test_setting_color_map(qtbot, mocked_client):
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.color_map = "viridis"
|
||||
|
||||
Reference in New Issue
Block a user