Compare commits

..

8 Commits

Author SHA1 Message Date
semantic-release 80694d151f 3.13.0
Automatically generated by python-semantic-release
2026-05-21 14:20:49 +00:00
wakonig_k f03a5d9e85 feat(rpc-base): set default RPC timeout and allow customization 2026-05-21 16:19:48 +02:00
semantic-release 5e8f0e8083 3.12.2
Automatically generated by python-semantic-release
2026-05-21 13:29:11 +00:00
wyzula_j 9eb05416ab fix(toggle): disable styling implemented 2026-05-21 15:28:17 +02:00
semantic-release ab6a1aecc1 3.12.1
Automatically generated by python-semantic-release
2026-05-21 11:40:06 +00:00
wyzula_j d99db7d042 fix(device_input): ensure callback is removed after cleanup 2026-05-21 13:39:19 +02:00
wyzula_j a976837cff fix(signal_combobox): signature matched for update_signals_from_filters 2026-05-21 13:39:19 +02:00
wyzula_j 56427a7f0c fix(device_input): correct cleanup unsubscribe 2026-05-21 13:39:19 +02:00
18 changed files with 202 additions and 391 deletions
+30
View File
@@ -1,6 +1,36 @@
# CHANGELOG
## v3.13.0 (2026-05-21)
### Features
- **rpc-base**: Set default RPC timeout and allow customization
([`f03a5d9`](https://github.com/bec-project/bec_widgets/commit/f03a5d9e853bd62b8ec1bad1c1e112fe01befe70))
## v3.12.2 (2026-05-21)
### Bug Fixes
- **toggle**: Disable styling implemented
([`9eb0541`](https://github.com/bec-project/bec_widgets/commit/9eb05416ab68dcb88732dca8974c665030d34e0b))
## 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
+3 -3
View File
@@ -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}")
+11
View File
@@ -222,6 +222,7 @@ class BECGuiClient(RPCBase):
self._ipython_registry: dict[str, RPCReference] = {}
self.available_widgets = AvailableWidgetsNamespace()
register_serializer_extension()
self._rpc_timeout = 5
####################
#### Client API ####
@@ -232,6 +233,16 @@ class BECGuiClient(RPCBase):
"""The launcher object."""
return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher")
def set_rpc_timeout(self, timeout: float):
"""Set the timeout for RPC calls to the GUI server.
Args:
timeout(float): The timeout in seconds.
"""
if not isinstance(timeout, (int, float)) or timeout < 0:
raise ValueError("Timeout must be a non-negative number.")
self._rpc_timeout = timeout
def _safe_register_stream(self, endpoint: EndpointInfo, cb: Callable, **kwargs):
"""Check if already registered for registration in idempotent functions."""
if not self._client.connector.any_stream_is_registered(endpoint, cb=cb):
+9 -3
View File
@@ -24,6 +24,8 @@ else:
# pylint: disable=protected-access
_DEFAULT_RPC_TIMEOUT = object()
def _name_arg(arg):
if isinstance(arg, DeviceBaseWithConfig):
@@ -154,6 +156,7 @@ class RPCReference:
class RPCBase:
def __init__(
self,
gui_id: str | None = None,
@@ -211,8 +214,8 @@ class RPCBase:
self,
method,
*args,
wait_for_rpc_response=True,
timeout=5,
wait_for_rpc_response: bool = True,
timeout: float | None | object = _DEFAULT_RPC_TIMEOUT,
gui_id: str | None = None,
**kwargs,
) -> Any:
@@ -223,13 +226,16 @@ class RPCBase:
method: The method to call.
args: The arguments to pass to the method.
wait_for_rpc_response: Whether to wait for the RPC response.
timeout: The timeout for the RPC response.
timeout: The timeout for the RPC response. If omitted, the client's default RPC
timeout is used. If explicitly set to None, wait indefinitely.
gui_id: The GUI ID to use for the RPC call. If None, the default GUI ID is used.
kwargs: The keyword arguments to pass to the method.
Returns:
The result of the RPC call.
"""
if timeout is _DEFAULT_RPC_TIMEOUT:
timeout = self._root._rpc_timeout
if method in ["show", "hide", "raise"] and gui_id is None:
obj = self._root._server_registry.get(self._gui_id)
if obj is None:
-18
View File
@@ -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.
-103
View File
@@ -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()}"
)
+3 -43
View File
@@ -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:
+35 -9
View File
@@ -1,6 +1,6 @@
import sys
from qtpy.QtCore import Property, QEasingCurve, QPointF, QPropertyAnimation, Qt, Signal
from qtpy.QtCore import Property, QEasingCurve, QEvent, QPointF, QPropertyAnimation, Qt, Signal
from qtpy.QtGui import QColor, QPainter
from qtpy.QtWidgets import QApplication, QWidget
@@ -41,10 +41,22 @@ class ToggleSwitch(QWidget):
theme = getattr(QApplication.instance(), "theme", None)
colors = theme.colors if theme else {}
self._active_track_color = colors.get("PRIMARY", QColor(33, 150, 243))
self._active_thumb_color = colors.get("ON_PRIMARY", QColor(255, 255, 255))
self._inactive_track_color = colors.get("SEPARATOR", QColor(200, 200, 200))
self._inactive_thumb_color = colors.get("ON_PRIMARY", QColor(255, 255, 255))
self._active_track_color = self._theme_color(colors, "PRIMARY", QColor(33, 150, 243))
self._active_thumb_color = self._theme_color(colors, "ON_PRIMARY", QColor(255, 255, 255))
self._inactive_track_color = self._theme_color(colors, "SEPARATOR", QColor(200, 200, 200))
self._inactive_thumb_color = self._theme_color(colors, "ON_PRIMARY", QColor(255, 255, 255))
self._disabled_track_color = self._theme_color(colors, "DISABLED_BG", QColor(220, 220, 220))
self._disabled_thumb_color = self._theme_color(colors, "DISABLED_FG", QColor(150, 150, 150))
self._disabled_border_color = self._theme_color(
colors, "DISABLED_BORDER", QColor(170, 170, 170)
)
if hasattr(self, "_checked"):
self.update_colors()
@staticmethod
def _theme_color(colors: dict, key: str, fallback: QColor) -> QColor:
color = colors.get(key, fallback)
return color if isinstance(color, QColor) else QColor(color)
@Property(bool)
def checked(self):
@@ -119,29 +131,40 @@ class ToggleSwitch(QWidget):
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Draw track
painter.setBrush(self._track_color)
painter.setPen(Qt.NoPen)
painter.setPen(self._disabled_border_color if not self.isEnabled() else Qt.PenStyle.NoPen)
painter.drawRoundedRect(
0, 0, self.width(), self.height(), self.height() / 2, self.height() / 2
)
# Draw thumb
painter.setBrush(self._thumb_color)
painter.setPen(Qt.PenStyle.NoPen)
diameter = int(self.height() * 0.8)
painter.drawEllipse(int(self._thumb_pos.x()), int(self._thumb_pos.y()), diameter, diameter)
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton:
if self.isEnabled() and event.button() == Qt.MouseButton.LeftButton:
self.checked = not self.checked
def update_colors(self):
if not self.isEnabled():
self._thumb_color = self._disabled_thumb_color
self._track_color = self._disabled_track_color
return
self._thumb_color = self.active_thumb_color if self._checked else self.inactive_thumb_color
self._track_color = self.active_track_color if self._checked else self.inactive_track_color
def changeEvent(self, event):
if event.type() == QEvent.Type.EnabledChange:
self.update_colors()
self.update()
super().changeEvent(event)
def get_thumb_pos(self, checked):
return QPointF(self.width() - self.height() + 3, 2) if checked else QPointF(3, 2)
@@ -167,7 +190,7 @@ class ToggleSwitch(QWidget):
if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QHBoxLayout, QVBoxLayout, QWidget
from qtpy.QtWidgets import QHBoxLayout, QWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
@@ -177,9 +200,12 @@ if __name__ == "__main__": # pragma: no cover
widget = QWidget()
layout = QHBoxLayout(widget)
toggle = ToggleSwitch()
toggle_disabled = ToggleSwitch()
dark_mode_btn = DarkModeButton()
layout.addWidget(toggle)
layout.addWidget(toggle_disabled)
layout.addWidget(dark_mode_btn)
toggle_disabled.setEnabled(False)
window = QWidget()
window.setLayout(layout)
window.show()
+4 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "bec_widgets"
version = "3.12.0"
version = "3.13.0"
description = "BEC Widgets"
requires-python = ">=3.11"
classifiers = [
@@ -65,6 +65,9 @@ qtermwidget = ["pyside6_qtermwidget"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
+9
View File
@@ -5,6 +5,7 @@ import pytest
from bec_widgets.cli.client import BECDockArea
from bec_widgets.cli.client_utils import BECGuiClient, _start_plot_process
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCResponseTimeoutError, rpc_timeout
@pytest.fixture
@@ -257,3 +258,11 @@ def test_client_utils_delete_falls_back_to_direct_close():
gui.delete("dock")
widget._run_rpc.assert_called_once_with("close")
def test_client_utils_gui_client_set_rpc_timeout():
gui = BECGuiClient()
assert gui._rpc_timeout == 5
gui.set_rpc_timeout(10)
assert gui._rpc_timeout == 10
@@ -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"
+23
View File
@@ -36,3 +36,26 @@ def test_toggle_click(qtbot, toggle):
qtbot.mouseClick(toggle, Qt.LeftButton)
toggle.paintEvent(None)
assert toggle.checked is not init_state
def test_toggle_disabled_state_blocks_clicks_and_restores_colors(qtbot, toggle):
toggle.checked = True
assert toggle._track_color == toggle.active_track_color
assert toggle._thumb_color == toggle.active_thumb_color
toggle.setEnabled(False)
assert toggle._track_color == toggle._disabled_track_color
assert toggle._thumb_color == toggle._disabled_thumb_color
qtbot.mouseClick(toggle, Qt.LeftButton)
assert toggle.checked is True
assert toggle._track_color == toggle._disabled_track_color
assert toggle._thumb_color == toggle._disabled_thumb_color
toggle.setEnabled(True)
assert toggle.checked is True
assert toggle._track_color == toggle.active_track_color
assert toggle._thumb_color == toggle.active_thumb_color