1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-12-30 18:51:19 +01:00

feat(dm-view): initial device manager view added

This commit is contained in:
2025-08-22 07:55:33 +02:00
committed by wyzula-jan
parent fc4ad051f8
commit 5d0ec2186b
36 changed files with 4995 additions and 552 deletions

View File

@@ -3,6 +3,9 @@ from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
from bec_widgets.applications.navigation_centre.side_bar import SideBar
from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem
from bec_widgets.applications.views.device_manager_view.device_manager_widget import (
DeviceManagerWidget,
)
from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
@@ -44,10 +47,18 @@ class BECMainApp(BECMainWindow):
def _add_views(self):
self.add_section("BEC Applications", "bec_apps")
self.ads = AdvancedDockArea(self)
self.device_manager = DeviceManagerWidget(self)
self.add_view(
icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks"
)
self.add_view(
icon="display_settings",
title="Device Manager",
id="device_manager",
widget=self.device_manager,
mini_text="DM",
)
if self._show_examples:
self.add_section("Examples", "examples")
@@ -184,6 +195,7 @@ if __name__ == "__main__": # pragma: no cover
app = QApplication([sys.argv[0], *qt_args])
apply_theme("dark")
w = BECMainApp(show_examples=args.examples)
w.resize(1920, 1200)
w.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,687 @@
from __future__ import annotations
import os
from functools import partial
from typing import List, Literal
import PySide6QtAds as QtAds
import yaml
from bec_lib import config_helper
from bec_lib.bec_yaml_loader import yaml_load
from bec_lib.file_utils import DeviceConfigWriter
from bec_lib.logger import bec_logger
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
from bec_qthemes import apply_theme
from PySide6QtAds import CDockManager, CDockWidget
from qtpy.QtCore import Qt, QThreadPool, QTimer
from qtpy.QtWidgets import (
QDialog,
QFileDialog,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QSizePolicy,
QSplitter,
QTextEdit,
QVBoxLayout,
QWidget,
)
from bec_widgets import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.help_inspector.help_inspector import HelpInspector
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.control.device_manager.components import (
DeviceTableView,
DMConfigView,
DMOphydTest,
DocstringView,
)
from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal
from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources import (
AvailableDeviceResources,
)
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
CommunicateConfigAction,
)
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
PresetClassDeviceConfigDialog,
)
logger = bec_logger.logger
_yes_no_question = partial(
QMessageBox.question,
buttons=QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
defaultButton=QMessageBox.StandardButton.No,
)
def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None:
"""
Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1].
Works for horizontal or vertical splitters and sets matching stretch factors.
"""
def apply():
n = splitter.count()
if n == 0:
return
w = list(weights[:n]) + [1] * max(0, n - len(weights))
w = [max(0.0, float(x)) for x in w]
tot_w = sum(w)
if tot_w <= 0:
w = [1.0] * n
tot_w = float(n)
total_px = (
splitter.width()
if splitter.orientation() == Qt.Orientation.Horizontal
else splitter.height()
)
if total_px < 2:
QTimer.singleShot(0, apply)
return
sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w]
diff = total_px - sum(sizes)
if diff != 0:
idx = max(range(n), key=lambda i: w[i])
sizes[idx] = max(1, sizes[idx] + diff)
splitter.setSizes(sizes)
for i, wi in enumerate(w):
splitter.setStretchFactor(i, max(1, int(round(wi * 100))))
QTimer.singleShot(0, apply)
class ConfigChoiceDialog(QDialog):
REPLACE = 1
ADD = 2
CANCEL = 0
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Load Config")
layout = QVBoxLayout(self)
label = QLabel("Do you want to replace the current config or add to it?")
label.setWordWrap(True)
layout.addWidget(label)
# Buttons: equal size, stacked vertically
self.replace_btn = QPushButton("Replace")
self.add_btn = QPushButton("Add")
self.cancel_btn = QPushButton("Cancel")
btn_layout = QHBoxLayout()
for btn in (self.replace_btn, self.add_btn, self.cancel_btn):
btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
btn_layout.addWidget(btn)
layout.addLayout(btn_layout)
# Connect signals to explicit slots
self.replace_btn.clicked.connect(self.accept_replace)
self.add_btn.clicked.connect(self.accept_add)
self.cancel_btn.clicked.connect(self.reject_cancel)
self._result = self.CANCEL
def accept_replace(self):
self._result = self.REPLACE
self.accept()
def accept_add(self):
self._result = self.ADD
self.accept()
def reject_cancel(self):
self._result = self.CANCEL
self.reject()
def result(self):
return self._result
AVAILABLE_RESOURCE_IS_READY = False
class DeviceManagerView(BECWidget, QWidget):
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent=parent, client=None, *args, **kwargs)
self._config_helper = config_helper.ConfigHelper(self.client.connector)
self._shared_selection = SharedSelectionSignal()
# Top-level layout hosting a toolbar and the dock manager
self._root_layout = QVBoxLayout(self)
self._root_layout.setContentsMargins(0, 0, 0, 0)
self._root_layout.setSpacing(0)
self.dock_manager = CDockManager(self)
self.dock_manager.setStyleSheet("")
self._root_layout.addWidget(self.dock_manager)
# Device Table View widget
self.device_table_view = DeviceTableView(
self, shared_selection_signal=self._shared_selection
)
self.device_table_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Table", self)
self.device_table_view_dock.setWidget(self.device_table_view)
# Device Config View widget
self.dm_config_view = DMConfigView(self)
self.dm_config_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Config View", self)
self.dm_config_view_dock.setWidget(self.dm_config_view)
# Docstring View
self.dm_docs_view = DocstringView(self)
self.dm_docs_view_dock = QtAds.CDockWidget(self.dock_manager, "Docstring View", self)
self.dm_docs_view_dock.setWidget(self.dm_docs_view)
# Ophyd Test view
self.ophyd_test_view = DMOphydTest(self)
self.ophyd_test_dock_view = QtAds.CDockWidget(self.dock_manager, "Ophyd Test View", self)
self.ophyd_test_dock_view.setWidget(self.ophyd_test_view)
# Help Inspector
widget = QWidget(self)
layout = QVBoxLayout(widget)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.help_inspector = HelpInspector(self)
layout.addWidget(self.help_inspector)
text_box = QTextEdit(self)
text_box.setReadOnly(False)
text_box.setPlaceholderText("Help text will appear here...")
layout.addWidget(text_box)
self.help_inspector_dock = QtAds.CDockWidget(self.dock_manager, "Help Inspector", self)
self.help_inspector_dock.setWidget(widget)
# Register callback
self.help_inspector.bec_widget_help.connect(text_box.setMarkdown)
# Error Logs View
self.error_logs_view = QTextEdit(self)
self.error_logs_view.setReadOnly(True)
self.error_logs_view.setPlaceholderText("Error logs will appear here...")
self.error_logs_dock = QtAds.CDockWidget(self.dock_manager, "Error Logs", self)
self.error_logs_dock.setWidget(self.error_logs_view)
self.ophyd_test_view.validation_msg_md.connect(self.error_logs_view.setMarkdown)
# Arrange widgets within the QtAds dock manager
# Central widget area
self.central_dock_area = self.dock_manager.setCentralWidget(self.device_table_view_dock)
# Right area - should be pushed into view if something is active
self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.RightDockWidgetArea,
self.ophyd_test_dock_view,
self.central_dock_area,
)
# create bottom area (2-arg -> area)
self.bottom_dock_area = self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.BottomDockWidgetArea, self.dm_docs_view_dock
)
# YAML view left of docstrings (docks relative to bottom area)
self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.LeftDockWidgetArea, self.dm_config_view_dock, self.bottom_dock_area
)
# Error/help area right of docstrings (dock relative to bottom area)
area = self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.RightDockWidgetArea,
self.help_inspector_dock,
self.bottom_dock_area,
)
self.dock_manager.addDockWidgetTabToArea(self.error_logs_dock, area)
for dock in self.dock_manager.dockWidgets():
dock.setFeature(CDockWidget.DockWidgetClosable, False)
dock.setFeature(CDockWidget.DockWidgetFloatable, False)
dock.setFeature(CDockWidget.DockWidgetMovable, False)
# Apply stretch after the layout is done
self.set_default_view([2, 8, 2], [7, 3])
for signal, slots in [
(
self.device_table_view.selected_devices,
(self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config),
),
(
self.ophyd_test_view.device_validated,
(self.device_table_view.update_device_validation,),
),
(
self.device_table_view.device_configs_changed,
(self.ophyd_test_view.change_device_configs,),
),
]:
for slot in slots:
signal.connect(slot)
# Once available resource is ready, add it to the view again
if AVAILABLE_RESOURCE_IS_READY:
# Available Resources Widget
self.available_devices = AvailableDeviceResources(
self, shared_selection_signal=self._shared_selection
)
self.available_devices_dock = QtAds.CDockWidget(
self.dock_manager, "Available Devices", self
)
self.available_devices_dock.setWidget(self.available_devices)
# Connect slots for available reosource
for signal, slots in [
(
self.available_devices.selected_devices,
(self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config),
),
(
self.device_table_view.device_configs_changed,
(self.available_devices.mark_devices_used,),
),
(
self.available_devices.add_selected_devices,
(self.device_table_view.add_device_configs,),
),
(
self.available_devices.del_selected_devices,
(self.device_table_view.remove_device_configs,),
),
]:
for slot in slots:
signal.connect(slot)
# Add toolbar
self._add_toolbar()
def _add_toolbar(self):
self.toolbar = ModularToolBar(self)
# Add IO actions
self._add_io_actions()
self._add_table_actions()
self.toolbar.show_bundles(["IO", "Table"])
self._root_layout.insertWidget(0, self.toolbar)
def _add_io_actions(self):
# Create IO bundle
io_bundle = ToolbarBundle("IO", self.toolbar.components)
# Load from disk
load = MaterialIconAction(
text_position="under",
icon_name="file_open",
parent=self,
tooltip="Load configuration file from disk",
label_text="Load Config",
)
self.toolbar.components.add_safe("load", load)
load.action.triggered.connect(self._load_file_action)
io_bundle.add_action("load")
# Add safe to disk
save_to_disk = MaterialIconAction(
text_position="under",
icon_name="file_save",
parent=self,
tooltip="Save config to disk",
label_text="Save Config",
)
self.toolbar.components.add_safe("save_to_disk", save_to_disk)
save_to_disk.action.triggered.connect(self._save_to_disk_action)
io_bundle.add_action("save_to_disk")
# Add load config from redis
load_redis = MaterialIconAction(
text_position="under",
icon_name="cached",
parent=self,
tooltip="Load current config from Redis",
label_text="Get Current Config",
)
load_redis.action.triggered.connect(self._load_redis_action)
self.toolbar.components.add_safe("load_redis", load_redis)
io_bundle.add_action("load_redis")
# Update config action
update_config_redis = MaterialIconAction(
text_position="under",
icon_name="cloud_upload",
parent=self,
tooltip="Update current config in Redis",
label_text="Update Config",
)
update_config_redis.action.setEnabled(False)
update_config_redis.action.triggered.connect(self._update_redis_action)
self.toolbar.components.add_safe("update_config_redis", update_config_redis)
io_bundle.add_action("update_config_redis")
# Add load config from plugin dir
self.toolbar.add_bundle(io_bundle)
# Table actions
def _add_table_actions(self) -> None:
table_bundle = ToolbarBundle("Table", self.toolbar.components)
# Reset composed view
reset_composed = MaterialIconAction(
text_position="under",
icon_name="delete_sweep",
parent=self,
tooltip="Reset current composed config view",
label_text="Reset Config",
)
reset_composed.action.triggered.connect(self._reset_composed_view)
self.toolbar.components.add_safe("reset_composed", reset_composed)
table_bundle.add_action("reset_composed")
# Add device
add_device = MaterialIconAction(
text_position="under",
icon_name="add",
parent=self,
tooltip="Add new device",
label_text="Add Device",
)
add_device.action.triggered.connect(self._add_device_action)
self.toolbar.components.add_safe("add_device", add_device)
table_bundle.add_action("add_device")
# Remove device
remove_device = MaterialIconAction(
text_position="under",
icon_name="remove",
parent=self,
tooltip="Remove device",
label_text="Remove Device",
)
remove_device.action.triggered.connect(self._remove_device_action)
self.toolbar.components.add_safe("remove_device", remove_device)
table_bundle.add_action("remove_device")
# Rerun validation
rerun_validation = MaterialIconAction(
text_position="under",
icon_name="checklist",
parent=self,
tooltip="Run device validation with 'connect' on selected devices",
label_text="Validate Connection",
)
rerun_validation.action.triggered.connect(self._rerun_validation_action)
self.toolbar.components.add_safe("rerun_validation", rerun_validation)
table_bundle.add_action("rerun_validation")
# Add load config from plugin dir
self.toolbar.add_bundle(table_bundle)
# IO actions
def _coming_soon(self):
return QMessageBox.question(
self,
"Not implemented yet",
"This feature has not been implemented yet, will be coming soon...!!",
QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Cancel,
)
@SafeSlot()
def _load_file_action(self):
"""Action for the 'load' action to load a config from disk for the io_bundle of the toolbar."""
try:
plugin_path = plugin_repo_path()
plugin_name = plugin_package_name()
config_path = os.path.join(plugin_path, plugin_name, "device_configs")
except ValueError:
# Get the recovery config path as fallback
config_path = self._get_recovery_config_path()
logger.warning(
f"No plugin repository installed, fallback to recovery config path: {config_path}"
)
# Implement the file loading logic here
start_dir = os.path.abspath(config_path)
file_path = self._get_file_path(start_dir, "open_file")
if file_path:
self._load_config_from_file(file_path)
def _get_file_path(self, start_dir: str, mode: Literal["open_file", "save_file"]) -> str:
if mode == "open_file":
file_path, _ = QFileDialog.getOpenFileName(
self, caption="Select Config File", dir=start_dir
)
else:
file_path, _ = QFileDialog.getSaveFileName(
self, caption="Save Config File", dir=start_dir
)
return file_path
def _load_config_from_file(self, file_path: str):
"""
Load device config from a given file path and update the device table view.
Args:
file_path (str): Path to the configuration file.
"""
try:
config = [{"name": k, **v} for k, v in yaml_load(file_path).items()]
except Exception as e:
logger.error(f"Failed to load config from file {file_path}. Error: {e}")
return
self._open_config_choice_dialog(config)
def _open_config_choice_dialog(self, config: List[dict]):
"""
Open a dialog to choose whether to replace or add the loaded config.
Args:
config (List[dict]): List of device configurations loaded from the file.
"""
dialog = ConfigChoiceDialog(self)
if dialog.exec():
if dialog.result() == ConfigChoiceDialog.REPLACE:
self.device_table_view.set_device_config(config)
elif dialog.result() == ConfigChoiceDialog.ADD:
self.device_table_view.add_device_configs(config)
# TODO would we ever like to add the current config to an existing composition
@SafeSlot()
def _load_redis_action(self):
"""Action for the 'load_redis' action to load the current config from Redis for the io_bundle of the toolbar."""
reply = _yes_no_question(
self,
"Load currently active config",
"Do you really want to discard the current config and reload?",
)
if reply == QMessageBox.StandardButton.Yes and self.client.device_manager is not None:
self.device_table_view.set_device_config(
self.client.device_manager._get_redis_device_config()
)
else:
return
@SafeSlot()
def _update_redis_action(self) -> None | QMessageBox.StandardButton:
"""Action to push the current composition to Redis"""
reply = _yes_no_question(
self,
"Push composition to Redis",
"Do you really want to replace the active configuration in the BEC server with the current composition? ",
)
if reply != QMessageBox.StandardButton.Yes:
return
if self.device_table_view.table.contains_invalid_devices():
return QMessageBox.warning(
self, "Validation has errors!", "Please resolve before proceeding."
)
if self.ophyd_test_view.validation_running():
return QMessageBox.warning(
self, "Validation has not completed.", "Please wait for the validation to finish."
)
self._push_composition_to_redis()
def _push_composition_to_redis(self):
config = {cfg.pop("name"): cfg for cfg in self.device_table_view.table.all_configs()}
threadpool = QThreadPool.globalInstance()
comm = CommunicateConfigAction(self._config_helper, None, config, "set")
threadpool.start(comm)
@SafeSlot()
def _save_to_disk_action(self):
"""Action for the 'save_to_disk' action to save the current config to disk."""
# Check if plugin repo is installed...
try:
config_path = self._get_recovery_config_path()
except ValueError:
# Get the recovery config path as fallback
config_path = os.path.abspath(os.path.expanduser("~"))
logger.warning(f"Failed to find recovery config path, fallback to: {config_path}")
# Implement the file loading logic here
file_path = self._get_file_path(config_path, "save_file")
if file_path:
config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()}
with open(file_path, "w") as file:
file.write(yaml.dump(config))
# Table actions
@SafeSlot()
def _reset_composed_view(self):
"""Action for the 'reset_composed_view' action to reset the composed view."""
reply = _yes_no_question(
self,
"Clear View",
"You are about to clear the current composed config view, please confirm...",
)
if reply == QMessageBox.StandardButton.Yes:
self.device_table_view.clear_device_configs()
# TODO Bespoke Form to add a new device
@SafeSlot()
def _add_device_action(self):
"""Action for the 'add_device' action to add a new device."""
dialog = PresetClassDeviceConfigDialog(parent=self)
dialog.accepted_data.connect(self._add_to_table_from_dialog)
dialog.open()
@SafeSlot(dict)
def _add_to_table_from_dialog(self, data):
self.device_table_view.add_device_configs([data])
@SafeSlot()
def _remove_device_action(self):
"""Action for the 'remove_device' action to remove a device."""
self.device_table_view.remove_selected_rows()
@SafeSlot()
@SafeSlot(bool)
def _rerun_validation_action(self, connect: bool = True):
"""Action for the 'rerun_validation' action to rerun validation on selected devices."""
configs = self.device_table_view.table.selected_configs()
self.ophyd_test_view.change_device_configs(configs, True, connect)
####### Default view has to be done with setting up splitters ########
def set_default_view(
self, horizontal_weights: list, vertical_weights: list
): # TODO separate logic for all ads based widgets
"""Apply initial weights to every horizontal and vertical splitter.
Examples:
horizontal_weights = [1, 3, 2, 1]
vertical_weights = [3, 7] # top:bottom = 30:70
"""
splitters_h = []
splitters_v = []
for splitter in self.findChildren(QSplitter):
if splitter.orientation() == Qt.Orientation.Horizontal:
splitters_h.append(splitter)
elif splitter.orientation() == Qt.Orientation.Vertical:
splitters_v.append(splitter)
def apply_all():
for s in splitters_h:
set_splitter_weights(s, horizontal_weights)
for s in splitters_v:
set_splitter_weights(s, vertical_weights)
QTimer.singleShot(0, apply_all)
def set_stretch(
self, *, horizontal=None, vertical=None
): # TODO separate logic for all ads based widgets
"""Update splitter weights and re-apply to all splitters.
Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict
for convenience: horizontal roles = {"left","center","right"},
vertical roles = {"top","bottom"}.
"""
def _coerce_h(x):
if x is None:
return None
if isinstance(x, (list, tuple)):
return list(map(float, x))
if isinstance(x, dict):
return [
float(x.get("left", 1)),
float(x.get("center", x.get("middle", 1))),
float(x.get("right", 1)),
]
return None
def _coerce_v(x):
if x is None:
return None
if isinstance(x, (list, tuple)):
return list(map(float, x))
if isinstance(x, dict):
return [float(x.get("top", 1)), float(x.get("bottom", 1))]
return None
h = _coerce_h(horizontal)
v = _coerce_v(vertical)
if h is None:
h = [1, 1, 1]
if v is None:
v = [1, 1]
self.set_default_view(h, v)
def _get_recovery_config_path(self) -> str:
"""Get the recovery config path from the log_writer config."""
# pylint: disable=protected-access
log_writer_config = self.client._service_config.config.get("log_writer", {})
writer = DeviceConfigWriter(service_config=log_writer_config)
return os.path.abspath(os.path.expanduser(writer.get_recovery_directory()))
if __name__ == "__main__":
import sys
from copy import deepcopy
from bec_lib.bec_yaml_loader import yaml_load
from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
app = QApplication(sys.argv)
w = QWidget()
l = QVBoxLayout()
w.setLayout(l)
apply_theme("dark")
button = DarkModeButton()
l.addWidget(button)
device_manager_view = DeviceManagerView()
l.addWidget(device_manager_view)
# config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml"
# cfg = yaml_load(config_path)
# cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}})
# # config = device_manager_view.client.device_manager._get_redis_device_config()
# device_manager_view.device_table_view.set_device_config(cfg)
w.show()
w.setWindowTitle("Device Manager View")
w.resize(1920, 1080)
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
sys.exit(app.exec_())

View File

@@ -0,0 +1,119 @@
"""Top Level wrapper for device_manager widget"""
from __future__ import annotations
import os
from bec_lib.bec_yaml_loader import yaml_load
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy import QtCore, QtWidgets
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
class DeviceManagerWidget(BECWidget, QtWidgets.QWidget):
def __init__(self, parent=None, client=None):
super().__init__(client=client, parent=parent)
self.stacked_layout = QtWidgets.QStackedLayout()
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
self.stacked_layout.setSpacing(0)
self.stacked_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll)
self.setLayout(self.stacked_layout)
# Add device manager view
self.device_manager_view = DeviceManagerView()
self.stacked_layout.addWidget(self.device_manager_view)
# Add overlay widget
self._overlay_widget = QtWidgets.QWidget(self)
self._customize_overlay()
self.stacked_layout.addWidget(self._overlay_widget)
self.stacked_layout.setCurrentWidget(self._overlay_widget)
def _customize_overlay(self):
self._overlay_widget.setAutoFillBackground(True)
self._overlay_layout = QtWidgets.QVBoxLayout()
self._overlay_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self._overlay_widget.setLayout(self._overlay_layout)
self._overlay_widget.setSizePolicy(
QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding
)
# Load current config
self.button_load_current_config = QtWidgets.QPushButton("Load Current Config")
icon = material_icon(icon_name="database", size=(24, 24), convert_to_pixmap=False)
self.button_load_current_config.setIcon(icon)
self._overlay_layout.addWidget(self.button_load_current_config)
self.button_load_current_config.clicked.connect(self._load_config_clicked)
# Load config from disk
self.button_load_config_from_file = QtWidgets.QPushButton("Load Config From File")
icon = material_icon(icon_name="folder", size=(24, 24), convert_to_pixmap=False)
self.button_load_config_from_file.setIcon(icon)
self._overlay_layout.addWidget(self.button_load_config_from_file)
self.button_load_config_from_file.clicked.connect(self._load_config_from_file_clicked)
self._overlay_widget.setVisible(True)
def _load_config_from_file_clicked(self):
"""Handle click on 'Load Config From File' button."""
start_dir = os.path.expanduser("~")
file_path, _ = QtWidgets.QFileDialog.getOpenFileName(
self, caption="Select Config File", dir=start_dir
)
if file_path:
self._load_config_from_file(file_path)
def _load_config_from_file(self, file_path: str):
try:
config = yaml_load(file_path)
except Exception as e:
logger.error(f"Failed to load config from file {file_path}. Error: {e}")
return
config_list = []
for name, cfg in config.items():
config_list.append(cfg)
config_list[-1]["name"] = name
self.device_manager_view.device_table_view.set_device_config(config_list)
# self.device_manager_view.ophyd_test.on_device_config_update(config)
self.stacked_layout.setCurrentWidget(self.device_manager_view)
@SafeSlot()
def _load_config_clicked(self):
"""Handle click on 'Load Current Config' button."""
config = self.client.device_manager._get_redis_device_config()
self.device_manager_view.device_table_view.set_device_config(config)
self.stacked_layout.setCurrentWidget(self.device_manager_view)
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
from bec_widgets.utils.colors import apply_theme
apply_theme("light")
widget = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(widget)
widget.setLayout(layout)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
device_manager = DeviceManagerWidget()
# config = device_manager.client.device_manager._get_redis_device_config()
# device_manager.device_table_view.set_device_config(config)
layout.addWidget(device_manager)
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
dark_mode_button = DarkModeButton()
layout.addWidget(dark_mode_button)
widget.show()
device_manager.setWindowTitle("Device Manager View")
device_manager.resize(1600, 1200)
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
sys.exit(app.exec_())

View File

@@ -192,6 +192,7 @@ class BECWidget(BECConnector):
Returns:
str: The help text in markdown format.
"""
return ""
@SafeSlot()
@SafeSlot(str)

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import Signal
from qtpy.QtCore import QSize, Signal
from qtpy.QtWidgets import (
QApplication,
QFrame,
@@ -19,7 +19,8 @@ from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
class ExpandableGroupFrame(QFrame):
broadcast_size_hint = Signal(QSize)
imminent_deletion = Signal()
expansion_state_changed = Signal()
EXPANDED_ICON_NAME: str = "collapse_all"
@@ -31,10 +32,11 @@ class ExpandableGroupFrame(QFrame):
super().__init__(parent=parent)
self._expanded = expanded
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain)
self._title_text = f"<b>{title}</b>"
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Raised)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setContentsMargins(5, 0, 0, 0)
self.setLayout(self._layout)
self._create_title_layout(title, icon)
@@ -49,21 +51,27 @@ class ExpandableGroupFrame(QFrame):
def _create_title_layout(self, title: str, icon: str):
self._title_layout = QHBoxLayout()
self._layout.addLayout(self._title_layout)
self._internal_title_layout = QHBoxLayout()
self._title_layout.addLayout(self._internal_title_layout)
self._title = ClickableLabel(f"<b>{title}</b>")
self._title = ClickableLabel()
self._set_title_text(self._title_text)
self._title_icon = ClickableLabel()
self._title_layout.addWidget(self._title_icon)
self._title_layout.addWidget(self._title)
self._internal_title_layout.addWidget(self._title_icon)
self._internal_title_layout.addWidget(self._title)
self.icon_name = icon
self._title.clicked.connect(self.switch_expanded_state)
self._title_icon.clicked.connect(self.switch_expanded_state)
self._title_layout.addStretch(1)
self._internal_title_layout.addStretch(1)
self._expansion_button = QToolButton()
self._update_expansion_icon()
self._title_layout.addWidget(self._expansion_button, stretch=1)
def get_title_layout(self) -> QHBoxLayout:
return self._internal_title_layout
def set_layout(self, layout: QLayout) -> None:
self._contents.setLayout(layout)
self._contents.layout().setContentsMargins(0, 0, 0, 0) # type: ignore
@@ -112,6 +120,18 @@ class ExpandableGroupFrame(QFrame):
else:
self._title_icon.setVisible(False)
@SafeProperty(str)
def title_text(self): # type: ignore
return self._title_text
@title_text.setter
def title_text(self, title_text: str):
self._title_text = title_text
self._set_title_text(self._title_text)
def _set_title_text(self, title_text: str):
self._title.setText(title_text)
# Application example
if __name__ == "__main__": # pragma: no cover

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from types import NoneType
from types import GenericAlias, NoneType, UnionType
from typing import NamedTuple
from bec_lib.logger import bec_logger
@@ -11,7 +11,7 @@ from qtpy.QtWidgets import QApplication, QGridLayout, QLabel, QSizePolicy, QVBox
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.forms_from_types import styles
from bec_widgets.utils.forms_from_types.items import (
DynamicFormItem,
@@ -215,6 +215,9 @@ class PydanticModelForm(TypedForm):
self._connect_to_theme_change()
@SafeSlot()
def clear(self): ...
def set_pretty_display_theme(self, theme: str = "dark"):
if self._pretty_display:
self.setStyleSheet(styles.pretty_display_theme(theme))
@@ -279,3 +282,24 @@ class PydanticModelForm(TypedForm):
self.form_data_cleared.emit(None)
self.validity_proc.emit(False)
return False
class PydanticModelFormItem(DynamicFormItem):
def __init__(
self, parent: QWidget | None = None, *, spec: FormItemSpec, model: type[BaseModel]
) -> None:
self._data_model = model
super().__init__(parent=parent, spec=spec)
self._main_widget.form_data_updated.connect(self._value_changed)
def _add_main_widget(self) -> None:
self._main_widget = PydanticModelForm(data_model=self._data_model)
self._layout.addWidget(self._main_widget)
def getValue(self):
return self._main_widget.get_form_data()
def setValue(self, value: dict):
self._main_widget.set_data(self._data_model.model_validate(value))

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import inspect
import typing
from abc import abstractmethod
from decimal import Decimal
@@ -14,8 +15,10 @@ from typing import (
NamedTuple,
Optional,
OrderedDict,
Protocol,
TypeVar,
get_args,
runtime_checkable,
)
from bec_lib.logger import bec_logger
@@ -170,9 +173,10 @@ class DynamicFormItem(QWidget):
self._desc = self._spec.info.description
self.setLayout(self._layout)
self._add_main_widget()
# Sadly, QWidget and ABC are not compatible
assert isinstance(self._main_widget, QWidget), "Please set a widget in _add_main_widget()" # type: ignore
self._main_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
self._main_widget.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
if not spec.pretty_display:
if clearable_required(spec.info):
self._add_clear_button()
@@ -187,6 +191,7 @@ class DynamicFormItem(QWidget):
@abstractmethod
def _add_main_widget(self) -> None:
self._main_widget: QWidget
"""Add the main data entry widget to self._main_widget and appply any
constraints from the field info"""
@@ -404,7 +409,7 @@ class ListFormItem(DynamicFormItem):
def sizeHint(self):
default = super().sizeHint()
return QSize(default.width(), QFontMetrics(self.font()).height() * 6)
return QSize(default.width(), QFontMetrics(self.font()).height() * 4)
def _add_main_widget(self) -> None:
self._main_widget = QListWidget()
@@ -454,10 +459,17 @@ class ListFormItem(DynamicFormItem):
self._add_list_item(val)
self._repop(self._data)
def _item_height(self):
return int(QFontMetrics(self.font()).height() * 1.5)
def _add_list_item(self, val):
item = QListWidgetItem(self._main_widget)
item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEditable)
item_widget = self._types.widget(parent=self)
item_widget.setMinimumHeight(self._item_height())
self._main_widget.setGridSize(QSize(0, self._item_height()))
if (layout := item_widget.layout()) is not None:
layout.setContentsMargins(0, 0, 0, 0)
WidgetIO.set_value(item_widget, val)
self._main_widget.setItemWidget(item, item_widget)
self._main_widget.addItem(item)
@@ -494,14 +506,11 @@ class ListFormItem(DynamicFormItem):
self._data = list(value)
self._repop(self._data)
def _line_height(self):
return QFontMetrics(self._main_widget.font()).height()
def set_max_height_in_lines(self, lines: int):
outer_inc = 1 if self._spec.pretty_display else 3
self._main_widget.setFixedHeight(self._line_height() * max(lines, self._min_lines))
self._button_holder.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + 1))
self.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + outer_inc))
self._main_widget.setFixedHeight(self._item_height() * max(lines, self._min_lines))
self._button_holder.setFixedHeight(self._item_height() * (max(lines, self._min_lines) + 1))
self.setFixedHeight(self._item_height() * (max(lines, self._min_lines) + outer_inc))
def scale_to_data(self, *_):
self.set_max_height_in_lines(self._main_widget.count() + 1)
@@ -584,6 +593,16 @@ class OptionalStrLiteralFormItem(StrLiteralFormItem):
WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]]
@runtime_checkable
class _ItemTypeFn(Protocol):
def __call__(self, spec: FormItemSpec) -> type[DynamicFormItem]: ...
WidgetTypeRegistry = OrderedDict[
str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem] | _ItemTypeFn]
]
def _is_string_literal(t: type):
return type(t) is type(Literal[""]) and set(type(arg) for arg in get_args(t)) == {str}
@@ -637,7 +656,10 @@ def widget_from_type(
widget_types = widget_types or DEFAULT_WIDGET_TYPES
for predicate, widget_type in widget_types.values():
if predicate(spec):
return widget_type
if inspect.isclass(widget_type) and issubclass(widget_type, DynamicFormItem):
return widget_type
return widget_type(spec)
logger.warning(
f"Type {spec.item_type=} / {spec.info.annotation=} is not (yet) supported in dynamic form creation."
)

View File

@@ -11,6 +11,7 @@ from qtpy import QtCore, QtWidgets
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import AccentColors, get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.widget_io import WidgetHierarchy
logger = bec_logger.logger
@@ -100,7 +101,7 @@ class HelpInspector(BECWidget, QtWidgets.QWidget):
self._button.setChecked(False)
QtWidgets.QApplication.restoreOverrideCursor()
def eventFilter(self, obj, event):
def eventFilter(self, obj: QtWidgets.QWidget, event: QtCore.QEvent) -> bool:
"""
Filter events to capture Key_Escape event, and mouse clicks
if event filter is active. Any click event on a widget is suppressed, if
@@ -111,25 +112,33 @@ class HelpInspector(BECWidget, QtWidgets.QWidget):
obj (QObject): The object that received the event.
event (QEvent): The event to filter.
"""
if (
event.type() == QtCore.QEvent.KeyPress
and event.key() == QtCore.Qt.Key_Escape
and self._active
):
# If not active, return immediately
if not self._active:
return super().eventFilter(obj, event)
# If active, handle escape key
if event.type() == QtCore.QEvent.KeyPress and event.key() == QtCore.Qt.Key_Escape:
self._toggle_mode(False)
return super().eventFilter(obj, event)
if self._active and event.type() == QtCore.QEvent.MouseButtonPress:
# If active, and left mouse button pressed, handle click
if event.type() == QtCore.QEvent.MouseButtonPress:
if event.button() == QtCore.Qt.LeftButton:
widget = self._app.widgetAt(event.globalPos())
if widget is None:
return super().eventFilter(obj, event)
# Get BECWidget ancestor
# TODO check what happens if the HELP Inspector itself is embedded in another BECWidget
# I suppose we would like to get the first ancestor that is a BECWidget, not the topmost one
if not isinstance(widget, BECWidget):
widget = WidgetHierarchy._get_becwidget_ancestor(widget)
if widget:
if widget is self or self.isAncestorOf(widget):
if widget is self:
self._toggle_mode(False)
return True
for cb in self._callbacks.values():
try:
cb(widget)
except Exception as e:
print(f"Error occurred in callback {cb}: {e}")
logger.error(f"Error occurred in callback {cb}: {e}")
return True
return super().eventFilter(obj, event)

View File

@@ -0,0 +1,133 @@
import re
from functools import partial
from re import Pattern
from typing import Generic, Iterable, NamedTuple, TypeVar
from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize, Qt
from qtpy.QtWidgets import QListWidget, QListWidgetItem, QWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
from bec_widgets.widgets.control.device_manager.components._util import (
SORT_KEY_ROLE,
SortableQListWidgetItem,
)
logger = bec_logger.logger
_EF = TypeVar("_EF", bound=ExpandableGroupFrame)
class ListOfExpandableFrames(QListWidget, Generic[_EF]):
def __init__(
self, /, parent: QWidget | None = None, item_class: type[_EF] = ExpandableGroupFrame
) -> None:
super().__init__(parent)
_Items = NamedTuple("_Items", (("item", QListWidgetItem), ("widget", _EF)))
self.item_tuple = _Items
self._item_class = item_class
self._item_dict: dict[str, _Items] = {}
def __contains__(self, id: str):
return id in self._item_dict
def clear(self) -> None:
self._item_dict = {}
return super().clear()
def add_item(self, id: str, *args, **kwargs) -> tuple[QListWidgetItem, _EF]:
"""Adds the specified type of widget as an item. args and kwargs are passed to the constructor.
Args:
id (str): the key under which to store the list item in the internal dict
Returns:
The widget created in the addition process
"""
def _remove_item(item: QListWidgetItem):
self.takeItem(self.row(item))
del self._item_dict[id]
self.sortItems()
def _updatesize(item: QListWidgetItem, item_widget: _EF):
item_widget.adjustSize()
item.setSizeHint(QSize(item_widget.width(), item_widget.height()))
item = SortableQListWidgetItem(self)
item.setData(SORT_KEY_ROLE, id) # used for sorting
item_widget = self._item_class(*args, **kwargs)
item_widget.expansion_state_changed.connect(partial(_updatesize, item, item_widget))
item_widget.imminent_deletion.connect(partial(_remove_item, item))
item_widget.broadcast_size_hint.connect(item.setSizeHint)
self.addItem(item)
self.setItemWidget(item, item_widget)
self._item_dict[id] = self.item_tuple(item, item_widget)
item.setSizeHint(item_widget.sizeHint())
return (item, item_widget)
def sort_by_key(self, role=SORT_KEY_ROLE, order=Qt.SortOrder.AscendingOrder):
items = [self.takeItem(0) for i in range(self.count())]
items.sort(key=lambda it: it.data(role), reverse=(order == Qt.SortOrder.DescendingOrder))
for it in items:
self.addItem(it)
# reattach its custom widget
widget = self.itemWidget(it)
if widget:
self.setItemWidget(it, widget)
def item_widget_pairs(self):
return self._item_dict.values()
def widgets(self):
return (i.widget for i in self._item_dict.values())
def get_item_widget(self, id: str):
if (item := self._item_dict.get(id)) is None:
return None
return item
def set_hidden_pattern(self, pattern: Pattern):
self.hide_all()
self._set_hidden(filter(pattern.search, self._item_dict.keys()), False)
def set_hidden(self, ids: Iterable[str]):
self._set_hidden(ids, True)
def _set_hidden(self, ids: Iterable[str], hidden: bool):
for id in ids:
if (_item := self._item_dict.get(id)) is not None:
_item.item.setHidden(hidden)
_item.widget.setHidden(hidden)
else:
logger.warning(
f"List {self.__qualname__} does not have an item with ID {id} to hide!"
)
self.sortItems()
def hide_all(self):
self.set_hidden_state_on_all(True)
def unhide_all(self):
self.set_hidden_state_on_all(False)
def set_hidden_state_on_all(self, hidden: bool):
for _item in self._item_dict.values():
_item.item.setHidden(hidden)
_item.widget.setHidden(hidden)
self.sortItems()
@SafeSlot(str)
def update_filter(self, value: str):
if value == "":
return self.unhide_all()
try:
self.set_hidden_pattern(re.compile(value, re.IGNORECASE))
except Exception:
self.unhide_all()

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,4 @@
from .device_table_view import DeviceTableView
from .dm_config_view import DMConfigView
from .dm_docstring_view import DocstringView
from .dm_ophyd_test import DMOphydTest

View File

@@ -0,0 +1,53 @@
import json
from typing import Any, Callable, Generator, Iterable, TypeVar
from bec_lib.utils.json import ExtendedEncoder
from qtpy.QtCore import QByteArray, QMimeData, QObject, Signal # type: ignore
from qtpy.QtWidgets import QListWidgetItem
from bec_widgets.widgets.control.device_manager.components.constants import (
MIME_DEVICE_CONFIG,
SORT_KEY_ROLE,
)
_T = TypeVar("_T")
_RT = TypeVar("_RT")
def yield_only_passing(fn: Callable[[_T], _RT], vals: Iterable[_T]) -> Generator[_RT, Any, None]:
for v in vals:
try:
yield fn(v)
except BaseException:
pass
def mimedata_from_configs(configs: Iterable[dict]) -> QMimeData:
"""Takes an iterable of device configs, gives a QMimeData with the configs json-encoded under the type MIME_DEVICE_CONFIG"""
mime_obj = QMimeData()
byte_array = QByteArray(json.dumps(list(configs), cls=ExtendedEncoder).encode("utf-8"))
mime_obj.setData(MIME_DEVICE_CONFIG, byte_array)
return mime_obj
class SortableQListWidgetItem(QListWidgetItem):
"""Store a sorting string key with .setData(SORT_KEY_ROLE, key) to be able to sort a list with
custom widgets and this item."""
def __gt__(self, other):
if (self_key := self.data(SORT_KEY_ROLE)) is None or (
other_key := other.data(SORT_KEY_ROLE)
) is None:
return False
return self_key.lower() > other_key.lower()
def __lt__(self, other):
if (self_key := self.data(SORT_KEY_ROLE)) is None or (
other_key := other.data(SORT_KEY_ROLE)
) is None:
return False
return self_key.lower() < other_key.lower()
class SharedSelectionSignal(QObject):
proc = Signal(str)

View File

@@ -0,0 +1,3 @@
from .available_device_resources import AvailableDeviceResources
__all__ = ["AvailableDeviceResources"]

View File

@@ -0,0 +1,230 @@
from textwrap import dedent
from typing import NamedTuple
from uuid import uuid4
from bec_qthemes import material_icon
from qtpy.QtCore import QItemSelection, QSize, Signal
from qtpy.QtWidgets import QFrame, QHBoxLayout, QLabel, QListWidgetItem, QVBoxLayout, QWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal
from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_group_ui import (
Ui_AvailableDeviceGroup,
)
from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import (
HashableDevice,
)
from bec_widgets.widgets.control.device_manager.components.constants import CONFIG_DATA_ROLE
def _warning_string(spec: HashableDevice):
name_warning = (
"Device defined with multiple names! Please check:\n " + "\n ".join(spec.names)
if len(spec.names) > 1
else ""
)
source_warning = (
"Device found in multiple source files! Please check:\n " + "\n ".join(spec._source_files)
if len(spec._source_files) > 1
else ""
)
return f"{name_warning}{source_warning}"
class _DeviceEntryWidget(QFrame):
def __init__(self, device_spec: HashableDevice, parent=None, **kwargs):
super().__init__(parent, **kwargs)
self._device_spec = device_spec
self.included: bool = False
self.setFrameStyle(0)
self._layout = QVBoxLayout()
self._layout.setContentsMargins(2, 2, 2, 2)
self.setLayout(self._layout)
self.setup_title_layout(device_spec)
self.check_and_display_warning()
self.setToolTip(self._rich_text())
def _rich_text(self):
return dedent(
f"""
<b><u><h2> {self._device_spec.name}: </h2></u></b>
<table>
<tr><td> description: </td><td><i> {self._device_spec.description} </i></td></tr>
<tr><td> config: </td><td><i> {self._device_spec.deviceConfig} </i></td></tr>
<tr><td> enabled: </td><td><i> {self._device_spec.enabled} </i></td></tr>
<tr><td> read only: </td><td><i> {self._device_spec.readOnly} </i></td></tr>
</table>
"""
)
def setup_title_layout(self, device_spec: HashableDevice):
self._title_layout = QHBoxLayout()
self._title_layout.setContentsMargins(0, 0, 0, 0)
self._title_container = QWidget(parent=self)
self._title_container.setLayout(self._title_layout)
self._warning_label = QLabel()
self._title_layout.addWidget(self._warning_label)
self.title = QLabel(device_spec.name)
self.title.setToolTip(device_spec.name)
self.title.setStyleSheet(self.title_style("#FF0000"))
self._title_layout.addWidget(self.title)
self._title_layout.addStretch(1)
self._layout.addWidget(self._title_container)
def check_and_display_warning(self):
if len(self._device_spec.names) == 1 and len(self._device_spec._source_files) == 1:
self._warning_label.setText("")
self._warning_label.setToolTip("")
else:
self._warning_label.setPixmap(material_icon("warning", size=(12, 12), color="#FFAA00"))
self._warning_label.setToolTip(_warning_string(self._device_spec))
@property
def device_hash(self):
return hash(self._device_spec)
def title_style(self, color: str) -> str:
return f"QLabel {{ color: {color}; font-weight: bold; font-size: 10pt; }}"
def setTitle(self, text: str):
self.title.setText(text)
def set_included(self, included: bool):
self.included = included
self.title.setStyleSheet(self.title_style("#00FF00" if included else "#FF0000"))
class _DeviceEntry(NamedTuple):
list_item: QListWidgetItem
widget: _DeviceEntryWidget
class AvailableDeviceGroup(ExpandableGroupFrame, Ui_AvailableDeviceGroup):
selected_devices = Signal(list)
def __init__(
self,
parent=None,
name: str = "TagGroupTitle",
data: set[HashableDevice] = set(),
shared_selection_signal=SharedSelectionSignal(),
**kwargs,
):
super().__init__(parent=parent, **kwargs)
self.setupUi(self)
self._shared_selection_signal = shared_selection_signal
self._shared_selection_uuid = str(uuid4())
self._shared_selection_signal.proc.connect(self._handle_shared_selection_signal)
self.device_list.selectionModel().selectionChanged.connect(self._on_selection_changed)
self.title_text = name # type: ignore
self._mime_data = []
self._devices: dict[str, _DeviceEntry] = {}
for device in data:
self._add_item(device)
self.device_list.sortItems()
self.setMinimumSize(self.device_list.sizeHint())
self._update_num_included()
def _add_item(self, device: HashableDevice):
item = QListWidgetItem(self.device_list)
device_dump = device.model_dump(exclude_defaults=True)
item.setData(CONFIG_DATA_ROLE, device_dump)
self._mime_data.append(device_dump)
widget = _DeviceEntryWidget(device, self)
item.setSizeHint(QSize(widget.width(), widget.height()))
self.device_list.setItemWidget(item, widget)
self.device_list.addItem(item)
self._devices[device.name] = _DeviceEntry(item, widget)
def create_mime_data(self):
return self._mime_data
def reset_devices_state(self):
for dev in self._devices.values():
dev.widget.set_included(False)
self._update_num_included()
def set_item_state(self, /, device_hash: int, included: bool):
for dev in self._devices.values():
if dev.widget.device_hash == device_hash:
dev.widget.set_included(included)
self._update_num_included()
def _update_num_included(self):
n_included = sum(int(dev.widget.included) for dev in self._devices.values())
if n_included == 0:
color = "#FF0000"
elif n_included == len(self._devices):
color = "#00FF00"
else:
color = "#FFAA00"
self.n_included.setText(f"{n_included} / {len(self._devices)}")
self.n_included.setStyleSheet(f"QLabel {{ color: {color}; }}")
def sizeHint(self) -> QSize:
if not getattr(self, "device_list", None) or not self.expanded:
return super().sizeHint()
return QSize(
max(150, self.device_list.viewport().width()),
self.device_list.sizeHintForRow(0) * self.device_list.count() + 50,
)
@SafeSlot(QItemSelection, QItemSelection)
def _on_selection_changed(self, selected: QItemSelection, deselected: QItemSelection) -> None:
self._shared_selection_signal.proc.emit(self._shared_selection_uuid)
config = [dev.as_normal_device().model_dump() for dev in self.get_selection()]
self.selected_devices.emit(config)
@SafeSlot(str)
def _handle_shared_selection_signal(self, uuid: str):
if uuid != self._shared_selection_uuid:
self.device_list.clearSelection()
def resizeEvent(self, event):
super().resizeEvent(event)
self.setMinimumHeight(self.sizeHint().height())
self.setMaximumHeight(self.sizeHint().height())
def get_selection(self) -> set[HashableDevice]:
selection = self.device_list.selectedItems()
widgets = (w.widget for _, w in self._devices.items() if w.list_item in selection)
return set(w._device_spec for w in widgets)
def __repr__(self) -> str:
return f"{self.__class__.__name__}: {self.title_text}"
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = AvailableDeviceGroup(name="Tag group 1")
for item in [
HashableDevice(
**{
"name": f"test_device_{i}",
"deviceClass": "TestDeviceClass",
"readoutPriority": "baseline",
"enabled": True,
}
)
for i in range(5)
]:
widget._add_item(item)
widget._update_num_included()
widget.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,56 @@
from typing import TYPE_CHECKING
from qtpy.QtCore import QMetaObject, Qt
from qtpy.QtWidgets import QFrame, QLabel, QListWidget, QVBoxLayout
from bec_widgets.widgets.control.device_manager.components._util import mimedata_from_configs
from bec_widgets.widgets.control.device_manager.components.constants import (
CONFIG_DATA_ROLE,
MIME_DEVICE_CONFIG,
)
if TYPE_CHECKING:
from .available_device_group import AvailableDeviceGroup
class _DeviceListWiget(QListWidget):
def _item_iter(self):
return (self.item(i) for i in range(self.count()))
def all_configs(self):
return [item.data(CONFIG_DATA_ROLE) for item in self._item_iter()]
def mimeTypes(self):
return [MIME_DEVICE_CONFIG]
def mimeData(self, items):
return mimedata_from_configs(item.data(CONFIG_DATA_ROLE) for item in items)
class Ui_AvailableDeviceGroup(object):
def setupUi(self, AvailableDeviceGroup: "AvailableDeviceGroup"):
if not AvailableDeviceGroup.objectName():
AvailableDeviceGroup.setObjectName("AvailableDeviceGroup")
AvailableDeviceGroup.setMinimumWidth(150)
self.verticalLayout = QVBoxLayout()
self.verticalLayout.setObjectName("verticalLayout")
AvailableDeviceGroup.set_layout(self.verticalLayout)
title_layout = AvailableDeviceGroup.get_title_layout()
self.n_included = QLabel(AvailableDeviceGroup, text="...")
self.n_included.setObjectName("n_included")
title_layout.addWidget(self.n_included)
self.device_list = _DeviceListWiget(AvailableDeviceGroup)
self.device_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
self.device_list.setObjectName("device_list")
self.device_list.setFrameStyle(0)
self.device_list.setDragEnabled(True)
self.device_list.setAcceptDrops(False)
self.device_list.setDefaultDropAction(Qt.DropAction.CopyAction)
self.verticalLayout.addWidget(self.device_list)
AvailableDeviceGroup.setFrameStyle(QFrame.Shadow.Plain | QFrame.Shape.Box)
QMetaObject.connectSlotsByName(AvailableDeviceGroup)

View File

@@ -0,0 +1,128 @@
from random import randint
from typing import Any, Iterable
from uuid import uuid4
from qtpy.QtCore import QItemSelection, Signal # type: ignore
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.control.device_manager.components._util import (
SharedSelectionSignal,
yield_only_passing,
)
from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources_ui import (
Ui_availableDeviceResources,
)
from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import (
HashableDevice,
get_backend,
)
from bec_widgets.widgets.control.device_manager.components.constants import CONFIG_DATA_ROLE
class AvailableDeviceResources(BECWidget, QWidget, Ui_availableDeviceResources):
selected_devices = Signal(list) # list[dict[str,Any]] of device configs currently selected
add_selected_devices = Signal(list)
del_selected_devices = Signal(list)
def __init__(self, parent=None, shared_selection_signal=SharedSelectionSignal(), **kwargs):
super().__init__(parent=parent, **kwargs)
self.setupUi(self)
self._backend = get_backend()
self._shared_selection_signal = shared_selection_signal
self._shared_selection_uuid = str(uuid4())
self._shared_selection_signal.proc.connect(self._handle_shared_selection_signal)
self.device_groups_list.selectionModel().selectionChanged.connect(
self._on_selection_changed
)
self.grouping_selector.addItem("deviceTags")
self.grouping_selector.addItems(self._backend.allowed_sort_keys)
self._grouping_selection_changed("deviceTags")
self.grouping_selector.currentTextChanged.connect(self._grouping_selection_changed)
self.search_box.textChanged.connect(self.device_groups_list.update_filter)
self.tb_add_selected.action.triggered.connect(self._add_selected_action)
self.tb_del_selected.action.triggered.connect(self._del_selected_action)
def refresh_full_list(self, device_groups: dict[str, set[HashableDevice]]):
self.device_groups_list.clear()
for device_group, devices in device_groups.items():
self._add_device_group(device_group, devices)
if self.grouping_selector.currentText == "deviceTags":
self._add_device_group("Untagged devices", self._backend.untagged_devices)
self.device_groups_list.sortItems()
def _add_device_group(self, device_group: str, devices: set[HashableDevice]):
item, widget = self.device_groups_list.add_item(
device_group,
self.device_groups_list,
device_group,
devices,
shared_selection_signal=self._shared_selection_signal,
expanded=False,
)
item.setData(CONFIG_DATA_ROLE, widget.create_mime_data())
# Re-emit the selected items from a subgroup - all other selections should be disabled anyway
widget.selected_devices.connect(self.selected_devices)
def resizeEvent(self, event):
super().resizeEvent(event)
for list_item, device_group_widget in self.device_groups_list.item_widget_pairs():
list_item.setSizeHint(device_group_widget.sizeHint())
@SafeSlot()
def _add_selected_action(self):
self.add_selected_devices.emit(self.device_groups_list.any_selected_devices())
@SafeSlot()
def _del_selected_action(self):
self.del_selected_devices.emit(self.device_groups_list.any_selected_devices())
@SafeSlot(QItemSelection, QItemSelection)
def _on_selection_changed(self, selected: QItemSelection, deselected: QItemSelection) -> None:
self.selected_devices.emit(self.device_groups_list.selected_devices_from_groups())
self._shared_selection_signal.proc.emit(self._shared_selection_uuid)
@SafeSlot(str)
def _handle_shared_selection_signal(self, uuid: str):
if uuid != self._shared_selection_uuid:
self.device_groups_list.clearSelection()
def _set_devices_state(self, devices: Iterable[HashableDevice], included: bool):
for device in devices:
for device_group in self.device_groups_list.widgets():
device_group.set_item_state(hash(device), included)
@SafeSlot(list)
def mark_devices_used(self, config_list: list[dict[str, Any]], used: bool):
"""Set the display color of individual devices and update the group display of numbers
included. Accepts a list of dicts with the complete config as used in
bec_lib.atlas_models.Device."""
self._set_devices_state(
yield_only_passing(HashableDevice.model_validate, config_list), used
)
@SafeSlot(str)
def _grouping_selection_changed(self, sort_key: str):
self.search_box.setText("")
if sort_key == "deviceTags":
device_groups = self._backend.tag_groups
else:
device_groups = self._backend.group_by_key(sort_key)
self.refresh_full_list(device_groups)
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = AvailableDeviceResources()
widget._set_devices_state(
list(filter(lambda _: randint(0, 1) == 1, widget._backend.all_devices)), True
)
widget.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,135 @@
from __future__ import annotations
import itertools
from qtpy.QtCore import QMetaObject, Qt
from qtpy.QtWidgets import (
QAbstractItemView,
QComboBox,
QGridLayout,
QLabel,
QLineEdit,
QListView,
QListWidget,
QListWidgetItem,
QSizePolicy,
QVBoxLayout,
)
from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.control.device_manager.components._util import mimedata_from_configs
from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_group import (
AvailableDeviceGroup,
)
from bec_widgets.widgets.control.device_manager.components.constants import (
CONFIG_DATA_ROLE,
MIME_DEVICE_CONFIG,
)
class _ListOfDeviceGroups(ListOfExpandableFrames[AvailableDeviceGroup]):
def itemWidget(self, item: QListWidgetItem) -> AvailableDeviceGroup:
return super().itemWidget(item) # type: ignore
def any_selected_devices(self):
return self.selected_individual_devices() or self.selected_devices_from_groups()
def selected_individual_devices(self):
for widget in (self.itemWidget(self.item(i)) for i in range(self.count())):
if (selected := widget.get_selection()) != set():
return [dev.as_normal_device().model_dump() for dev in selected]
return []
def selected_devices_from_groups(self):
selected_items = (self.item(r.row()) for r in self.selectionModel().selectedRows())
widgets = (self.itemWidget(item) for item in selected_items)
return list(itertools.chain.from_iterable(w.device_list.all_configs() for w in widgets))
def mimeTypes(self):
return [MIME_DEVICE_CONFIG]
def mimeData(self, items):
return mimedata_from_configs(
itertools.chain.from_iterable(item.data(CONFIG_DATA_ROLE) for item in items)
)
class Ui_availableDeviceResources(object):
def setupUi(self, availableDeviceResources):
if not availableDeviceResources.objectName():
availableDeviceResources.setObjectName("availableDeviceResources")
self.verticalLayout = QVBoxLayout(availableDeviceResources)
self.verticalLayout.setObjectName("verticalLayout")
self._add_toolbar()
# Main area with search and filter using a grid layout
self.search_layout = QVBoxLayout()
self.grid_layout = QGridLayout()
self.grouping_selector = QComboBox()
self.grouping_selector.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
lbl_group = QLabel("Group by:")
lbl_group.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.grid_layout.addWidget(lbl_group, 0, 0)
self.grid_layout.addWidget(self.grouping_selector, 0, 1)
self.search_box = QLineEdit()
self.search_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
lbl_filter = QLabel("Filter:")
lbl_filter.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.grid_layout.addWidget(lbl_filter, 1, 0)
self.grid_layout.addWidget(self.search_box, 1, 1)
self.grid_layout.setColumnStretch(0, 0)
self.grid_layout.setColumnStretch(1, 1)
self.search_layout.addLayout(self.grid_layout)
self.verticalLayout.addLayout(self.search_layout)
self.device_groups_list = _ListOfDeviceGroups(
availableDeviceResources, AvailableDeviceGroup
)
self.device_groups_list.setObjectName("device_groups_list")
self.device_groups_list.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.device_groups_list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.device_groups_list.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.device_groups_list.setMovement(QListView.Movement.Static)
self.device_groups_list.setSpacing(4)
self.device_groups_list.setDragDropMode(QListWidget.DragDropMode.DragOnly)
self.device_groups_list.setSelectionBehavior(QListWidget.SelectionBehavior.SelectItems)
self.device_groups_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
self.device_groups_list.setDragEnabled(True)
self.device_groups_list.setAcceptDrops(False)
self.device_groups_list.setDefaultDropAction(Qt.DropAction.CopyAction)
self.device_groups_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
availableDeviceResources.setMinimumWidth(250)
availableDeviceResources.resize(250, availableDeviceResources.height())
self.verticalLayout.addWidget(self.device_groups_list)
QMetaObject.connectSlotsByName(availableDeviceResources)
def _add_toolbar(self):
self.toolbar = ModularToolBar(self)
io_bundle = ToolbarBundle("IO", self.toolbar.components)
self.tb_add_selected = MaterialIconAction(
icon_name="add_box", parent=self, tooltip="Add selected devices to composition"
)
self.toolbar.components.add_safe("add_selected", self.tb_add_selected)
io_bundle.add_action("add_selected")
self.tb_del_selected = MaterialIconAction(
icon_name="chips", parent=self, tooltip="Remove selected devices from composition"
)
self.toolbar.components.add_safe("del_selected", self.tb_del_selected)
io_bundle.add_action("del_selected")
self.verticalLayout.addWidget(self.toolbar)
self.toolbar.add_bundle(io_bundle)
self.toolbar.show_bundles(["IO"])

View File

@@ -0,0 +1,140 @@
from __future__ import annotations
import operator
import os
from enum import Enum, auto
from functools import partial, reduce
from glob import glob
from pathlib import Path
from typing import Protocol
import bec_lib
from bec_lib.atlas_models import HashableDevice, HashableDeviceSet
from bec_lib.bec_yaml_loader import yaml_load
from bec_lib.logger import bec_logger
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path, plugins_installed
logger = bec_logger.logger
# use the last n recovery files
_N_RECOVERY_FILES = 3
_BASE_REPO_PATH = Path(os.path.dirname(bec_lib.__file__)) / "../.."
def get_backend() -> DeviceResourceBackend:
return _ConfigFileBackend()
class HashModel(str, Enum):
DEFAULT = auto()
DEFAULT_DEVICECONFIG = auto()
DEFAULT_EPICS = auto()
class DeviceResourceBackend(Protocol):
@property
def tag_groups(self) -> dict[str, set[HashableDevice]]:
"""A dictionary of all availble devices separated by tag groups. The same device may
appear more than once (in different groups)."""
...
@property
def all_devices(self) -> set[HashableDevice]:
"""A set of all availble devices. The same device may not appear more than once."""
...
@property
def untagged_devices(self) -> set[HashableDevice]:
"""A set of all untagged devices. The same device may not appear more than once."""
...
@property
def allowed_sort_keys(self) -> set[str]:
"""A set of all fields which you may group devices by"""
...
def tags(self) -> set[str]:
"""Returns a set of all the tags in all available devices."""
...
def tag_group(self, tag: str) -> set[HashableDevice]:
"""Returns a set of the devices in the tag group with the given key."""
...
def group_by_key(self, key: str) -> dict[str, set[HashableDevice]]:
"""Return a dict of all devices, organised by the specified key, which must be one of
the string keys in the Device model."""
...
def _devices_from_file(file: str, include_source: bool = True):
data = yaml_load(file, process_includes=False)
return HashableDeviceSet(
HashableDevice.model_validate(
dev | {"name": name, "source_files": {file} if include_source else set()}
)
for name, dev in data.items()
)
class _ConfigFileBackend(DeviceResourceBackend):
def __init__(self) -> None:
self._raw_device_set: set[HashableDevice] = self._get_config_from_backup_files()
if plugins_installed() == 1:
self._raw_device_set.update(
self._get_configs_from_plugin_files(
Path(plugin_repo_path()) / plugin_package_name() / "device_configs/"
)
)
self._device_groups = self._get_tag_groups()
def _get_config_from_backup_files(self):
dir = _BASE_REPO_PATH / "logs/device_configs/recovery_configs"
files = sorted(glob("*.yaml", root_dir=dir))
last_n_files = files[-_N_RECOVERY_FILES:]
return reduce(
operator.or_,
map(
partial(_devices_from_file, include_source=False),
(str(dir / f) for f in last_n_files),
),
set(),
)
def _get_configs_from_plugin_files(self, dir: Path):
files = glob("*.yaml", root_dir=dir, recursive=True)
return reduce(operator.or_, map(_devices_from_file, (str(dir / f) for f in files)), set())
def _get_tag_groups(self) -> dict[str, set[HashableDevice]]:
return {
tag: set(filter(lambda dev: tag in dev.deviceTags, self._raw_device_set))
for tag in self.tags()
}
@property
def tag_groups(self):
return self._device_groups
@property
def all_devices(self):
return self._raw_device_set
@property
def untagged_devices(self):
return {d for d in self._raw_device_set if d.deviceTags == set()}
@property
def allowed_sort_keys(self) -> set[str]:
return {n for n, info in HashableDevice.model_fields.items() if info.annotation is str}
def tags(self) -> set[str]:
return reduce(operator.or_, (dev.deviceTags for dev in self._raw_device_set), set())
def tag_group(self, tag: str) -> set[HashableDevice]:
return self.tag_groups[tag]
def group_by_key(self, key: str) -> dict[str, set[HashableDevice]]:
if key not in self.allowed_sort_keys:
raise ValueError(f"Cannot group available devices by model key {key}")
group_names: set[str] = {getattr(item, key) for item in self._raw_device_set}
return {g: {d for d in self._raw_device_set if getattr(d, key) == g} for g in group_names}

View File

@@ -0,0 +1,72 @@
from typing import Final
# Denotes a MIME type for JSON-encoded list of device config dictionaries
MIME_DEVICE_CONFIG: Final[str] = "application/x-bec_device_config"
# Custom user roles
SORT_KEY_ROLE: Final[int] = 117
CONFIG_DATA_ROLE: Final[int] = 118
# TODO 882 keep in sync with headers in device_table_view.py
HEADERS_HELP_MD: dict[str, str] = {
"status": "\n".join(
[
"## Status",
"The current status of the device. Can be one of the following values: ",
"### **LOADED** \n The device with the specified configuration is loaded in the current config.",
"### **CONNECT_READY** \n The device config is valid and the connection has been validated. It has not yet been loaded to the current config.",
"### **CONNECT_FAILED** \n The device config is valid, but the connection could not be established.",
"### **VALID** \n The device config is valid, but the connection has not yet been validated.",
"### **INVALID** \n The device config is invalid and can not be loaded to the current config.",
]
),
"name": "\n".join(["## Name ", "The name of the device."]),
"deviceClass": "\n".join(
[
"## Device Class",
"The device class specifies the type of the device. It will be used to create the instance.",
]
),
"readoutPriority": "\n".join(
[
"## Readout Priority",
"The readout priority of the device. Can be one of the following values: ",
"### **monitored** \n The monitored priority is used for devices that are read out during the scan (i.e. at every step) and whose value may change during the scan.",
"### **baseline** \n The baseline priority is used for devices that are read out at the beginning of the scan and whose value does not change during the scan.",
"### **async** \n The async priority is used for devices that are asynchronous to the monitored devices, and send their data independently.",
"### **continuous** \n The continuous priority is used for devices that are read out continuously during the scan.",
"### **on_request** \n The on_request priority is used for devices that should not be read out during the scan, yet are configured to be read out manually.",
]
),
"deviceTags": "\n".join(
[
"## Device Tags",
"A list of tags associated with the device. Tags can be used to group devices and filter them in the device manager.",
]
),
"enabled": "\n".join(
[
"## Enabled",
"Indicator whether the device is enabled or disabled. Disabled devices can not be used.",
]
),
"readOnly": "\n".join(
["## Read Only", "Indicator that a device is read-only or can be modified."]
),
"onFailure": "\n".join(
[
"## On Failure",
"Specifies the behavior of the device in case of a failure. Can be one of the following values: ",
"### **buffer** \n The device readback will fall back to the last known value.",
"### **retry** \n The device readback will be retried once, and raises an error if it fails again.",
"### **raise** \n The device readback will raise immediately.",
]
),
"softwareTrigger": "\n".join(
[
"## Software Trigger",
"Indicator whether the device receives a software trigger from BEC during a scan.",
]
),
"description": "\n".join(["## Description", "A short description of the device."]),
}

View File

@@ -0,0 +1,100 @@
"""Module with a config view for the device manager."""
from __future__ import annotations
import traceback
import yaml
from bec_lib.logger import bec_logger
from qtpy import QtCore, QtWidgets
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
logger = bec_logger.logger
class DMConfigView(BECWidget, QtWidgets.QWidget):
def __init__(self, parent=None, client=None):
super().__init__(client=client, parent=parent, theme_update=True)
self.stacked_layout = QtWidgets.QStackedLayout()
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
self.stacked_layout.setSpacing(0)
self.setLayout(self.stacked_layout)
# Monaco widget
self.monaco_editor = MonacoWidget()
self._customize_monaco()
self.stacked_layout.addWidget(self.monaco_editor)
self._overlay_widget = QtWidgets.QLabel(text="Select single device to show config")
self._customize_overlay()
self.stacked_layout.addWidget(self._overlay_widget)
self.stacked_layout.setCurrentWidget(self._overlay_widget)
def _customize_monaco(self):
self.monaco_editor.set_language("yaml")
self.monaco_editor.set_vim_mode_enabled(False)
self.monaco_editor.set_minimap_enabled(False)
# self.monaco_editor.setFixedHeight(600)
self.monaco_editor.set_readonly(True)
self.monaco_editor.editor.set_scroll_beyond_last_line_enabled(False)
self.monaco_editor.editor.set_line_numbers_mode("off")
def _customize_overlay(self):
self._overlay_widget.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self._overlay_widget.setAutoFillBackground(True)
self._overlay_widget.setSizePolicy(
QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding
)
@SafeSlot(dict)
def on_select_config(self, device: list[dict]):
"""Handle selection of a device from the device table."""
if len(device) != 1:
text = ""
self.stacked_layout.setCurrentWidget(self._overlay_widget)
else:
try:
text = yaml.dump(device[0], default_flow_style=False)
self.stacked_layout.setCurrentWidget(self.monaco_editor)
except Exception:
content = traceback.format_exc()
logger.error(f"Error converting device to YAML:\n{content}")
text = ""
self.stacked_layout.setCurrentWidget(self._overlay_widget)
self.monaco_editor.set_readonly(False) # Enable editing
text = text.rstrip()
self.monaco_editor.set_text(text)
self.monaco_editor.set_readonly(True) # Disable editing again
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(widget)
widget.setLayout(layout)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
config_view = DMConfigView()
layout.addWidget(config_view)
combo_box = QtWidgets.QComboBox()
config = config_view.client.device_manager._get_redis_device_config()
combo_box.addItems([""] + [str(v) for v, item in enumerate(config)])
def on_select(text):
if text == "":
config_view.on_select_config([])
else:
config_view.on_select_config([config[int(text)]])
combo_box.currentTextChanged.connect(on_select)
layout.addWidget(combo_box)
widget.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,133 @@
"""Module to visualize the docstring of a device class."""
from __future__ import annotations
import inspect
import re
import textwrap
import traceback
from bec_lib.logger import bec_logger
from bec_lib.plugin_helper import get_plugin_class, plugin_package_name
from bec_lib.utils.rpc_utils import rgetattr
from qtpy import QtCore, QtWidgets
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
try:
import ophyd
import ophyd_devices
READY_TO_VIEW = True
except ImportError:
logger.warning(f"Optional dependencies not available: {ImportError}")
ophyd_devices = None
ophyd = None
def docstring_to_markdown(obj) -> str:
"""
Convert a Python docstring to Markdown suitable for QTextEdit.setMarkdown.
"""
raw = inspect.getdoc(obj) or "*No docstring available.*"
# Dedent and normalize newlines
text = textwrap.dedent(raw).strip()
md = ""
if hasattr(obj, "__name__"):
md += f"# {obj.__name__}\n\n"
# Highlight section headers for Markdown
headers = ["Parameters", "Args", "Returns", "Raises", "Attributes", "Examples", "Notes"]
for h in headers:
text = re.sub(rf"(?m)^({h})\s*:?\s*$", rf"### \1", text)
# Preserve code blocks (4+ space indented lines)
def fence_code(match: re.Match) -> str:
block = re.sub(r"^ {4}", "", match.group(0), flags=re.M)
return f"```\n{block}\n```"
doc = re.sub(r"(?m)(^ {4,}.*(\n {4,}.*)*)", fence_code, text)
# Preserve normal line breaks for Markdown
lines = doc.splitlines()
processed_lines = []
for line in lines:
if line.strip() == "":
processed_lines.append("")
else:
processed_lines.append(line + " ")
doc = "\n".join(processed_lines)
md += doc
return md
class DocstringView(QtWidgets.QTextEdit):
def __init__(self, parent: QtWidgets.QWidget | None = None):
super().__init__(parent)
self.setReadOnly(True)
self.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus)
if not READY_TO_VIEW:
self._set_text("Ophyd or ophyd_devices not installed, cannot show docstrings.")
self.setEnabled(False)
return
def _set_text(self, text: str):
self.setReadOnly(False)
self.setMarkdown(text)
self.setReadOnly(True)
@SafeSlot(list)
def on_select_config(self, device: list[dict]):
if len(device) != 1:
self._set_text("")
return
device_class = device[0].get("deviceClass", "")
self.set_device_class(device_class)
@SafeSlot(str)
def set_device_class(self, device_class_str: str) -> None:
if not READY_TO_VIEW:
return
try:
module_cls = get_plugin_class(device_class_str, [ophyd_devices, ophyd])
markdown = docstring_to_markdown(module_cls)
self._set_text(markdown)
except Exception:
logger.exception("Error retrieving docstring")
self._set_text(f"*Error retrieving docstring for `{device_class_str}`*")
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(widget)
widget.setLayout(layout)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
config_view = DocstringView()
config_view.set_device_class("ophyd_devices.sim.sim_camera.SimCamera")
layout.addWidget(config_view)
combo = QtWidgets.QComboBox()
combo.addItems(
[
"",
"ophyd_devices.sim.sim_camera.SimCamera",
"ophyd.EpicsSignalWithRBV",
"ophyd.EpicsMotor",
"csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs.MCSCardCSAXS",
]
)
combo.currentTextChanged.connect(config_view.set_device_class)
layout.addWidget(combo)
widget.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,418 @@
"""Module to run a static tests for devices from a yaml config."""
from __future__ import annotations
import enum
import re
from collections import deque
from concurrent.futures import CancelledError, Future, ThreadPoolExecutor
from html import escape
from threading import Event, RLock
from typing import Any, Iterable
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy import QtCore, QtWidgets
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
READY_TO_TEST = False
logger = bec_logger.logger
try:
import bec_server
import ophyd_devices
READY_TO_TEST = True
except ImportError:
logger.warning(f"Optional dependencies not available: {ImportError}")
ophyd_devices = None
bec_server = None
try:
from ophyd_devices.utils.static_device_test import StaticDeviceTest
except ImportError:
StaticDeviceTest = None
class ValidationStatus(int, enum.Enum):
"""Validation status for device configurations."""
PENDING = 0 # colors.default
VALID = 1 # colors.highlight
FAILED = 2 # colors.emergency
class DeviceValidationResult(QtCore.QObject):
"""Simple object to inject validation signals into QRunnable."""
# Device validation signal, device_name, ValidationStatus as int, error message or ''
device_validated = QtCore.Signal(str, bool, str)
class DeviceTester(QtCore.QRunnable):
def __init__(self, config: dict) -> None:
super().__init__()
self.signals = DeviceValidationResult()
self.shutdown_event = Event()
self._config = config
self._max_threads = 4
self._pending_event = Event()
self._lock = RLock()
self._test_executor = ThreadPoolExecutor(self._max_threads, "device_manager_tester")
self._pending_queue: deque[tuple[str, dict]] = deque([])
self._active: set[str] = set()
QtWidgets.QApplication.instance().aboutToQuit.connect(lambda: self.shutdown_event.set())
def run(self):
if StaticDeviceTest is None:
logger.error("Ophyd devices or bec_server not available, cannot run validation.")
return
while not self.shutdown_event.is_set():
self._pending_event.wait(timeout=0.5) # check if shutting down every 0.5s
if len(self._active) >= self._max_threads:
self._pending_event.clear() # it will be set again on removing something from active
continue
with self._lock:
if len(self._pending_queue) > 0:
item, cfg, connect = self._pending_queue.pop()
self._active.add(item)
fut = self._test_executor.submit(self._run_test, item, {item: cfg}, connect)
fut.__dict__["__device_name"] = item
fut.add_done_callback(self._done_cb)
self._safe_check_and_clear()
self._cleanup()
def submit(self, devices: Iterable[tuple[str, dict, bool]]):
with self._lock:
self._pending_queue.extend(devices)
self._pending_event.set()
@staticmethod
def _run_test(name: str, config: dict, connect: bool) -> tuple[str, bool, str]:
tester = StaticDeviceTest(config_dict=config) # type: ignore # we exit early if it is None
results = tester.run_with_list_output(connect=connect)
return name, results[0].success, results[0].message
def _safe_check_and_clear(self):
with self._lock:
if len(self._pending_queue) == 0:
self._pending_event.clear()
def _safe_remove_from_active(self, name: str):
with self._lock:
self._active.remove(name)
self._pending_event.set() # check again once a completed task is removed
def _done_cb(self, future: Future):
try:
name, success, message = future.result()
except CancelledError:
return
except Exception as e:
name, success, message = future.__dict__["__device_name"], False, str(e)
finally:
self._safe_remove_from_active(future.__dict__["__device_name"])
self.signals.device_validated.emit(name, success, message)
def _cleanup(self): ...
class ValidationListItem(QtWidgets.QWidget):
"""Custom list item widget showing device name and validation status."""
def __init__(self, device_name: str, device_config: dict, parent=None):
"""
Initialize the validation list item.
Args:
device_name (str): The name of the device.
device_config (dict): The configuration of the device.
validation_colors (dict[ValidationStatus, QtGui.QColor]): The colors for each validation status.
parent (QtWidgets.QWidget, optional): The parent widget.
"""
super().__init__(parent)
self.main_layout = QtWidgets.QHBoxLayout(self)
self.main_layout.setContentsMargins(2, 2, 2, 2)
self.main_layout.setSpacing(4)
self.device_name = device_name
self.device_config = device_config
self.validation_msg = "Validation in progress..."
self._setup_ui()
def _setup_ui(self):
"""Setup the UI for the list item."""
label = QtWidgets.QLabel(self.device_name)
self.main_layout.addWidget(label)
self.main_layout.addStretch()
self._spinner = SpinnerWidget(parent=self)
self._spinner.speed = 80
self._spinner.setFixedSize(24, 24)
self.main_layout.addWidget(self._spinner)
self._base_style = "font-weight: bold;"
self.setStyleSheet(self._base_style)
self._start_spinner()
def _start_spinner(self):
"""Start the spinner animation."""
self._spinner.start()
def _stop_spinner(self):
"""Stop the spinner animation."""
self._spinner.stop()
self._spinner.setVisible(False)
@SafeSlot()
def on_validation_restart(self):
"""Handle validation restart."""
self.validation_msg = ""
self._start_spinner()
self.setStyleSheet("") # Check if this works as expected
@SafeSlot(str)
def on_validation_failed(self, error_msg: str):
"""Handle validation failure."""
self.validation_msg = error_msg
colors = get_accent_colors()
self._stop_spinner()
self.main_layout.removeWidget(self._spinner)
self._spinner.deleteLater()
label = QtWidgets.QLabel("")
icon = material_icon("error", color=colors.emergency, size=(24, 24))
label.setPixmap(icon)
self.main_layout.addWidget(label)
class DMOphydTest(BECWidget, QtWidgets.QWidget):
"""Widget to test device configurations using ophyd devices."""
# Signal to emit the validation status of a device
device_validated = QtCore.Signal(str, int)
# validation_msg in markdown format
validation_msg_md = QtCore.Signal(str)
def __init__(self, parent=None, client=None):
super().__init__(parent=parent, client=client)
if not READY_TO_TEST:
self.setDisabled(True)
self.tester = None
else:
self.tester = DeviceTester({})
self.tester.signals.device_validated.connect(self._on_device_validated)
QtCore.QThreadPool.globalInstance().start(self.tester)
self._device_list_items: dict[str, QtWidgets.QListWidgetItem] = {}
# TODO Consider using the thread pool from BECConnector instead of fetching the global instance!
self._thread_pool = QtCore.QThreadPool.globalInstance()
self._main_layout = QtWidgets.QVBoxLayout(self)
self._main_layout.setContentsMargins(0, 0, 0, 0)
self._main_layout.setSpacing(0)
# We add a splitter between the list and the text box
self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical)
self._main_layout.addWidget(self.splitter)
self._setup_list_ui()
def _setup_list_ui(self):
"""Setup the list UI."""
self._list_widget = QtWidgets.QListWidget(self)
self._list_widget.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
self.splitter.addWidget(self._list_widget)
# Connect signals
self._list_widget.currentItemChanged.connect(self._on_current_item_changed)
@SafeSlot(list, bool)
@SafeSlot(list, bool, bool)
def change_device_configs(
self, device_configs: list[dict[str, Any]], added: bool, connect: bool = False
) -> None:
"""Receive an update with device configs.
Args:
device_configs (list[dict[str, Any]]): The updated device configurations.
"""
for cfg in device_configs:
name = cfg.get("name", "<not found>")
if added:
if name in self._device_list_items:
continue
if self.tester:
self._add_device(name, cfg)
self.tester.submit([(name, cfg, connect)])
continue
if name not in self._device_list_items:
continue
self._remove_list_item(name)
def _add_device(self, name, cfg):
item = QtWidgets.QListWidgetItem(self._list_widget)
widget = ValidationListItem(device_name=name, device_config=cfg)
# wrap it in a QListWidgetItem
item.setSizeHint(widget.sizeHint())
self._list_widget.addItem(item)
self._list_widget.setItemWidget(item, widget)
self._device_list_items[name] = item
def _remove_list_item(self, device_name: str):
"""Remove a device from the list."""
# Get the list item
item = self._device_list_items.pop(device_name)
# Retrieve the custom widget attached to the item
widget = self._list_widget.itemWidget(item)
if widget is not None:
widget.deleteLater() # clean up custom widget
# Remove the item from the QListWidget
row = self._list_widget.row(item)
self._list_widget.takeItem(row)
@SafeSlot(str, bool, str)
def _on_device_validated(self, device_name: str, success: bool, message: str):
"""Handle the device validation result.
Args:
device_name (str): The name of the device.
success (bool): Whether the validation was successful.
message (str): The validation message.
"""
logger.info(f"Device {device_name} validation result: {success}, message: {message}")
item = self._device_list_items.get(device_name, None)
if not item:
logger.error(f"Device {device_name} not found in the list.")
return
if success:
self._remove_list_item(device_name=device_name)
self.device_validated.emit(device_name, ValidationStatus.VALID.value)
else:
widget: ValidationListItem = self._list_widget.itemWidget(item)
widget.on_validation_failed(message)
self.device_validated.emit(device_name, ValidationStatus.FAILED.value)
def _on_current_item_changed(
self, current: QtWidgets.QListWidgetItem, previous: QtWidgets.QListWidgetItem
):
"""Handle the current item change in the list widget.
Args:
current (QListWidgetItem): The currently selected item.
previous (QListWidgetItem): The previously selected item.
"""
widget: ValidationListItem = self._list_widget.itemWidget(current)
if widget:
try:
formatted_md = self._format_markdown_text(widget.device_name, widget.validation_msg)
self.validation_msg_md.emit(formatted_md)
except Exception as e:
logger.error(
f"##Error formatting validation message for device {widget.device_name}:\n{e}"
)
self.validation_msg_md.emit(widget.validation_msg)
else:
self.validation_msg_md.emit("")
def _format_markdown_text(self, device_name: str, raw_msg: str) -> str:
"""
Simple HTML formatting for validation messages, wrapping text naturally.
Args:
device_name (str): The name of the device.
raw_msg (str): The raw validation message.
"""
if not raw_msg.strip() or raw_msg.strip() == "Validation in progress...":
return f"### Validation in progress for {device_name}... \n\n"
# Regex to capture repeated ERROR patterns
pat = re.compile(
r"ERROR:\s*(?P<device>[^\s]+)\s+"
r"(?P<status>is not valid|is not connectable|failed):\s*"
r"(?P<detail>.*?)(?=ERROR:|$)",
re.DOTALL,
)
blocks = []
for m in pat.finditer(raw_msg):
dev = m.group("device")
status = m.group("status")
detail = m.group("detail").strip()
lines = [f"## Error for {dev}", f"**{dev} {status}**", f"```\n{detail}\n```"]
blocks.append("\n\n".join(lines))
# Fallback: If no patterns matched, return the raw message
if not blocks:
return f"## Error for {device_name}\n```\n{raw_msg.strip()}\n```"
return "\n\n---\n\n".join(blocks)
def validation_running(self):
return self._device_list_items != {}
@SafeSlot()
def clear_list(self):
"""Clear the device list."""
self._thread_pool.clear()
if self._thread_pool.waitForDone(2000) is False: # Wait for threads to finish
logger.error("Failed to wait for threads to finish. Removing items from the list.")
self._device_list_items.clear()
self._list_widget.clear()
self.validation_msg_md.emit("")
def remove_device(self, device_name: str):
"""Remove a device from the list."""
item = self._device_list_items.pop(device_name, None)
if item:
self._list_widget.removeItemWidget(item)
def cleanup(self):
if self.tester:
self.tester.shutdown_event.set()
return super().cleanup()
if __name__ == "__main__":
import sys
from bec_lib.bec_yaml_loader import yaml_load
# pylint: disable=ungrouped-imports
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
wid = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(wid)
wid.setLayout(layout)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
device_manager_ophyd_test = DMOphydTest()
try:
config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml"
config = [{"name": k, **v} for k, v in yaml_load(config_path).items()]
except Exception as e:
logger.error(f"Error loading config: {e}")
import os
import bec_lib
config_path = os.path.join(os.path.dirname(bec_lib.__file__), "configs", "demo_config.yaml")
config = [{"name": k, **v} for k, v in yaml_load(config_path).items()]
config.append({"name": "non_existing_device", "type": "NonExistingDevice"})
device_manager_ophyd_test.change_device_configs(config, True, True)
layout.addWidget(device_manager_ophyd_test)
device_manager_ophyd_test.setWindowTitle("Device Manager Ophyd Test")
device_manager_ophyd_test.resize(800, 600)
text_box = QtWidgets.QTextEdit()
text_box.setReadOnly(True)
layout.addWidget(text_box)
device_manager_ophyd_test.validation_msg_md.connect(text_box.setMarkdown)
wid.show()
sys.exit(app.exec_())

View File

@@ -1,6 +1,4 @@
import os
import re
from functools import partial
from typing import Callable
import bec_lib
@@ -11,23 +9,17 @@ from bec_lib.logger import bec_logger
from bec_lib.messages import ConfigAction, ScanStatusMessage
from bec_qthemes import material_icon
from pyqtgraph import SignalProxy
from qtpy.QtCore import QSize, QThreadPool, Signal
from qtpy.QtWidgets import (
QFileDialog,
QListWidget,
QListWidgetItem,
QToolButton,
QVBoxLayout,
QWidget,
)
from qtpy.QtCore import QThreadPool, Signal
from qtpy.QtWidgets import QFileDialog, QListWidget, QToolButton, QVBoxLayout, QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.services.device_browser.device_item import DeviceItem
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
DeviceConfigDialog,
DirectUpdateDeviceConfigDialog,
)
from bec_widgets.widgets.services.device_browser.util import map_device_type_to_icon
@@ -61,7 +53,8 @@ class DeviceBrowser(BECWidget, QWidget):
self._q_threadpool = QThreadPool()
self.ui = None
self.init_ui()
self.dev_list: QListWidget = self.ui.device_list
self.dev_list = ListOfExpandableFrames(self, DeviceItem)
self.ui.verticalLayout.addWidget(self.dev_list)
self.dev_list.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel)
self.proxy_device_update = SignalProxy(
self.ui.filter_input.textChanged, rateLimit=500, slot=self.update_device_list
@@ -116,7 +109,7 @@ class DeviceBrowser(BECWidget, QWidget):
)
def _create_add_dialog(self):
dialog = DeviceConfigDialog(parent=self, device=None, action="add")
dialog = DirectUpdateDeviceConfigDialog(parent=self, device=None, action="add")
dialog.open()
def on_device_update(self, action: ConfigAction, content: dict) -> None:
@@ -134,25 +127,15 @@ class DeviceBrowser(BECWidget, QWidget):
def init_device_list(self):
self.dev_list.clear()
self._device_items: dict[str, QListWidgetItem] = {}
with RPCRegister.delayed_broadcast():
for device, device_obj in self.dev.items():
self._add_item_to_list(device, device_obj)
def _add_item_to_list(self, device: str, device_obj):
def _updatesize(item: QListWidgetItem, device_item: DeviceItem):
device_item.adjustSize()
item.setSizeHint(QSize(device_item.width(), device_item.height()))
logger.debug(f"Adjusting {item} size to {device_item.width(), device_item.height()}")
def _remove_item(item: QListWidgetItem):
self.dev_list.takeItem(self.dev_list.row(item))
del self._device_items[device]
self.dev_list.sortItems()
item = QListWidgetItem(self.dev_list)
device_item = DeviceItem(
_, device_item = self.dev_list.add_item(
id=device,
parent=self,
device=device,
devices=self.dev,
@@ -160,18 +143,11 @@ class DeviceBrowser(BECWidget, QWidget):
config_helper=self._config_helper,
q_threadpool=self._q_threadpool,
)
device_item.expansion_state_changed.connect(partial(_updatesize, item, device_item))
device_item.imminent_deletion.connect(partial(_remove_item, item))
self.editing_enabled.connect(device_item.set_editable)
self.device_update.connect(device_item.config_update)
tooltip = self.dev[device]._config.get("description", "")
device_item.setToolTip(tooltip)
device_item.broadcast_size_hint.connect(item.setSizeHint)
item.setSizeHint(device_item.sizeHint())
self.dev_list.setItemWidget(item, device_item)
self.dev_list.addItem(item)
self._device_items[device] = item
@SafeSlot(dict, dict)
def scan_status_changed(self, scan_info: dict, _: dict):
@@ -200,20 +176,11 @@ class DeviceBrowser(BECWidget, QWidget):
Either way, the function will filter the devices based on the filter input text and update the device list.
"""
filter_text = self.ui.filter_input.text()
for device in self.dev:
if device not in self._device_items:
if device not in self.dev_list:
# it is possible the device has just been added to the config
self._add_item_to_list(device, self.dev[device])
try:
self.regex = re.compile(filter_text, re.IGNORECASE)
except re.error:
self.regex = None # Invalid regex, disable filtering
for device in self.dev:
self._device_items[device].setHidden(False)
return
for device in self.dev:
self._device_items[device].setHidden(not self.regex.search(device))
self.dev_list.update_filter(self.ui.filter_input.text())
@SafeSlot()
def _load_from_file(self):

View File

@@ -1,93 +1,90 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>406</width>
<height>500</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QGroupBox" name="browser_group_box">
<property name="title">
<string>Device Browser</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<layout class="QHBoxLayout" name="filter_layout">
<item>
<widget class="QLineEdit" name="filter_input">
<property name="placeholderText">
<string>Filter</string>
</property>
</widget>
</item>
<item>
<widget class="QGroupBox" name="button_box">
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QToolButton" name="add_button">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="save_button">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="import_button">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="scan_running_warning">
<property name="styleSheet">
<string notr="true"/>
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>406</width>
<height>500</height>
</rect>
</property>
<property name="text">
<string>warning</string>
<property name="windowTitle">
<string>Form</string>
</property>
</widget>
</item>
<item>
<widget class="QListWidget" name="device_list"/>
</item>
</layout>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QGroupBox" name="browser_group_box">
<property name="title">
<string>Device Browser</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<layout class="QHBoxLayout" name="filter_layout">
<item>
<widget class="QLineEdit" name="filter_input">
<property name="placeholderText">
<string>Filter</string>
</property>
</widget>
</item>
<item>
<widget class="QGroupBox" name="button_box">
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QToolButton" name="add_button">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="save_button">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="import_button">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="scan_running_warning">
<property name="styleSheet">
<string notr="true" />
</property>
<property name="text">
<string>warning</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
<resources />
<connections />
</ui>

View File

@@ -34,7 +34,11 @@ class CommunicateConfigAction(QRunnable):
@SafeSlot()
def run(self):
try:
if self.action in ["add", "update", "remove"]:
if self.action == "set":
self._process(
{"action": self.action, "config": self.config, "wait_for_response": False}
)
elif self.action in ["add", "update", "remove"]:
if (dev_name := self.device or self.config.get("name")) is None:
raise ValueError(
"Must be updating a device or be supplied a name for a new device"
@@ -57,6 +61,9 @@ class CommunicateConfigAction(QRunnable):
"config": {dev_name: self.config},
"wait_for_response": False,
}
self._process(req_args)
def _process(self, req_args: dict):
timeout = (
self.config_helper.suggested_timeout_s(self.config) if self.config is not None else 20
)

View File

@@ -5,12 +5,14 @@ from bec_lib.atlas_models import Device as DeviceConfigModel
from bec_lib.config_helper import CONF as DEVICE_CONF_KEYS
from bec_lib.config_helper import ConfigHelper
from bec_lib.logger import bec_logger
from pydantic import field_validator
from qtpy.QtCore import QSize, Qt, QThreadPool, Signal
from pydantic import BaseModel, field_validator
from qtpy.QtCore import QSize, Qt, QThreadPool, Signal # type: ignore
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QDialog,
QDialogButtonBox,
QHBoxLayout,
QLabel,
QStackedLayout,
QVBoxLayout,
@@ -19,6 +21,7 @@ from qtpy.QtWidgets import (
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.forms_from_types.items import DynamicFormItem, DynamicFormItemType
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
CommunicateConfigAction,
)
@@ -29,6 +32,8 @@ from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
logger = bec_logger.logger
_StdBtn = QDialogButtonBox.StandardButton
def _try_literal_eval(value: str):
if value == "":
@@ -39,79 +44,36 @@ def _try_literal_eval(value: str):
raise ValueError(f"Entered config value {value} is not a valid python value!") from e
class DeviceConfigDialog(BECWidget, QDialog):
class DeviceConfigDialog(QDialog):
RPC = False
applied = Signal()
accepted_data = Signal(dict)
def __init__(
self,
*,
parent=None,
device: str | None = None,
config_helper: ConfigHelper | None = None,
action: Literal["update", "add"] = "update",
threadpool: QThreadPool | None = None,
**kwargs,
self, *, parent=None, class_deviceconfig_item: type[DynamicFormItem] | None = None, **kwargs
):
"""A dialog to edit the configuration of a device in BEC. Generated from the pydantic model
for device specification in bec_lib.atlas_models.
Args:
parent (QObject): the parent QObject
device (str | None): the name of the device. used with the "update" action to prefill the dialog and validate entries.
config_helper (ConfigHelper | None): a ConfigHelper object for communication with Redis, will be created if necessary.
action (Literal["update", "add"]): the action which the form should perform on application or acceptance.
"""
self._initial_config = {}
self._class_deviceconfig_item = class_deviceconfig_item
super().__init__(parent=parent, **kwargs)
self._config_helper = config_helper or ConfigHelper(
self.client.connector, self.client._service_name, self.client.device_manager
)
self._device = device
self._action: Literal["update", "add"] = action
self._q_threadpool = threadpool or QThreadPool()
self.setWindowTitle(f"Edit config for: {device}")
self._container = QStackedLayout()
self._container.setStackingMode(QStackedLayout.StackAll)
self._container.setStackingMode(QStackedLayout.StackingMode.StackAll)
self._layout = QVBoxLayout()
user_warning = QLabel(
"Warning: edit items here at your own risk - minimal validation is applied to the entered values.\n"
"Items in the deviceConfig dictionary should correspond to python literals, e.g. numbers, lists, strings (including quotes), etc."
)
user_warning.setWordWrap(True)
user_warning.setStyleSheet("QLabel { color: red; }")
self._layout.addWidget(user_warning)
self.get_bec_shortcuts()
self._data = {}
self._add_form()
if self._action == "update":
self._form._validity.setVisible(False)
else:
self._set_schema_to_check_devices()
# TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved
# self._form._validity.setVisible(True)
self._form.validity_proc.connect(self.enable_buttons_for_validity)
self._add_overlay()
self._add_buttons()
self.setWindowTitle("Add new device")
self.setLayout(self._container)
self._form.validate_form()
self._overlay_widget.setVisible(False)
self._form._validity.setVisible(True)
self._connect_form()
def _set_schema_to_check_devices(self):
class _NameValidatedConfigModel(DeviceConfigModel):
@field_validator("name")
@staticmethod
def _validate_name(value: str, *_):
if not value.isidentifier():
raise ValueError(
f"Invalid device name: {value}. Device names must be valid Python identifiers."
)
if value in self.dev:
raise ValueError(f"A device with name {value} already exists!")
return value
self._form.set_schema(_NameValidatedConfigModel)
def _connect_form(self):
self._form.validity_proc.connect(self.enable_buttons_for_validity)
self._form.validate_form()
def _add_form(self):
self._form_widget = QWidget()
@@ -119,16 +81,6 @@ class DeviceConfigDialog(BECWidget, QDialog):
self._form = DeviceConfigForm()
self._layout.addWidget(self._form)
for row in self._form.enumerate_form_widgets():
if (
row.label.property("_model_field_name") in DEVICE_CONF_KEYS.NON_UPDATABLE
and self._action == "update"
):
row.widget._set_pretty_display()
if self._action == "update" and self._device in self.dev:
self._fetch_config()
self._fill_form()
self._container.addWidget(self._form_widget)
def _add_overlay(self):
@@ -145,21 +97,12 @@ class DeviceConfigDialog(BECWidget, QDialog):
self._container.addWidget(self._overlay_widget)
def _add_buttons(self):
self.button_box = QDialogButtonBox(
QDialogButtonBox.Apply | QDialogButtonBox.Ok | QDialogButtonBox.Cancel
)
self.button_box.button(QDialogButtonBox.Apply).clicked.connect(self.apply)
self.button_box = QDialogButtonBox(_StdBtn.Apply | _StdBtn.Ok | _StdBtn.Cancel)
self.button_box.button(_StdBtn.Apply).clicked.connect(self.apply)
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
self._layout.addWidget(self.button_box)
def _fetch_config(self):
if (
self.client.device_manager is not None
and self._device in self.client.device_manager.devices
):
self._initial_config = self.client.device_manager.devices.get(self._device)._config
def _fill_form(self):
self._form.set_data(DeviceConfigModel.model_validate(self._initial_config))
@@ -190,12 +133,16 @@ class DeviceConfigDialog(BECWidget, QDialog):
@SafeSlot(bool)
def enable_buttons_for_validity(self, valid: bool):
# TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved
for button in [
self.button_box.button(b) for b in [QDialogButtonBox.Apply, QDialogButtonBox.Ok]
]:
for button in [self.button_box.button(b) for b in [_StdBtn.Apply, _StdBtn.Ok]]:
button.setEnabled(valid)
button.setToolTip(self._form._validity_message.text())
def _process_action(self):
self.accepted_data.emit(self._form.get_form_data())
def get_data(self):
return self._data
@SafeSlot(popup_error=True)
def apply(self):
self._process_action()
@@ -206,10 +153,138 @@ class DeviceConfigDialog(BECWidget, QDialog):
self._process_action()
return super().accept()
class EpicsMotorConfig(BaseModel):
prefix: str
class EpicsSignalROConfig(BaseModel):
read_pv: str
class EpicsSignalConfig(BaseModel):
read_pv: str
write_pv: str | None = None
class PresetClassDeviceConfigDialog(DeviceConfigDialog):
def __init__(self, *, parent=None, **kwargs):
super().__init__(parent=parent, **kwargs)
self._device_models = {
"EpicsMotor": (EpicsMotorConfig, {"deviceClass": ("ophyd.EpicsMotor", False)}),
"EpicsSignalRO": (EpicsSignalROConfig, {"deviceClass": ("ophyd.EpicsSignalRO", False)}),
"EpicsSignal": (EpicsSignalConfig, {"deviceClass": ("ophyd.EpicsSignal", False)}),
"Custom": (None, {}),
}
self._create_selection_box()
self._selection_box.currentTextChanged.connect(self._replace_form)
def _apply_constraints(self, constraints: dict[str, tuple[DynamicFormItemType, bool]]):
for field_name, (value, editable) in constraints.items():
if (widget := self._form.widget_dict.get(field_name)) is not None:
widget.setValue(value)
if not editable:
widget._set_pretty_display()
def _replace_form(self, deviceconfig_cls_key):
self._form.deleteLater()
if (devmodel_params := self._device_models.get(deviceconfig_cls_key)) is not None:
devmodel, params = devmodel_params
else:
devmodel, params = None, {}
self._form = DeviceConfigForm(class_deviceconfig_item=devmodel)
self._apply_constraints(params)
self._layout.insertWidget(1, self._form)
self._connect_form()
def _create_selection_box(self):
layout = QHBoxLayout()
self._selection_box = QComboBox()
self._selection_box.addItems(list(self._device_models.keys()))
layout.addWidget(QLabel("Choose a device class: "))
layout.addWidget(self._selection_box)
self._layout.insertLayout(0, layout)
class DirectUpdateDeviceConfigDialog(BECWidget, DeviceConfigDialog):
def __init__(
self,
*,
parent=None,
device: str | None = None,
config_helper: ConfigHelper | None = None,
action: Literal["update"] | Literal["add"] = "update",
threadpool: QThreadPool | None = None,
**kwargs,
):
"""A dialog to edit the configuration of a device in BEC. Generated from the pydantic model
for device specification in bec_lib.atlas_models.
Args:
parent (QObject): the parent QObject
device (str | None): the name of the device. used with the "update" action to prefill the dialog and validate entries.
config_helper (ConfigHelper | None): a ConfigHelper object for communication with Redis, will be created if necessary.
action (Literal["update", "add"]): the action which the form should perform on application or acceptance.
"""
self._device = device
self._q_threadpool = threadpool or QThreadPool()
self._config_helper = config_helper or ConfigHelper(
self.client.connector, self.client._service_name
)
super().__init__(parent=parent, **kwargs)
self.get_bec_shortcuts()
self._action: Literal["update", "add"] = action
user_warning = QLabel(
"Warning: edit items here at your own risk - minimal validation is applied to the entered values.\n"
"Items in the deviceConfig dictionary should correspond to python literals, e.g. numbers, lists, strings (including quotes), etc."
)
user_warning.setWordWrap(True)
user_warning.setStyleSheet("QLabel { color: red; }")
self._layout.insertWidget(0, user_warning)
self.setWindowTitle(
f"Edit config for: {device}" if action == "update" else "Add new device"
)
if self._action == "update":
self._modify_for_update()
self._form.validity_proc.disconnect(self.enable_buttons_for_validity)
else:
self._set_schema_to_check_devices()
# TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved
# self._form._validity.setVisible(True)
def _modify_for_update(self):
for row in self._form.enumerate_form_widgets():
if row.label.property("_model_field_name") in DEVICE_CONF_KEYS.NON_UPDATABLE:
row.widget._set_pretty_display()
if self._device in self.dev:
self._fetch_config()
self._fill_form()
self._form._validity.setVisible(False)
def _set_schema_to_check_devices(self):
class _NameValidatedConfigModel(DeviceConfigModel):
@field_validator("name")
@staticmethod
def _validate_name(value: str, *_):
if not value.isidentifier():
raise ValueError(
f"Invalid device name: {value}. Device names must be valid Python identifiers."
)
if value in self.dev:
raise ValueError(f"A device with name {value} already exists!")
return value
self._form.set_schema(_NameValidatedConfigModel)
def _fetch_config(self):
if self.dev is not None and (device := self.dev.get(self._device)) is not None: # type: ignore
self._initial_config = device._config
def _process_action(self):
updated_config = self.updated_config()
if self._action == "add":
if (name := updated_config.get("name")) in self.dev:
if self.dev is not None and (name := updated_config.get("name")) in self.dev:
raise ValueError(
f"Can't create a new device with the same name as already existing device {name}!"
)
@@ -249,12 +324,12 @@ class DeviceConfigDialog(BECWidget, QDialog):
def _start_waiting_display(self):
self._overlay_widget.setVisible(True)
self._spinner.start()
QApplication.processEvents()
QApplication.processEvents() # TODO check if this kills performance and scheduling!
def _stop_waiting_display(self):
self._overlay_widget.setVisible(False)
self._spinner.stop()
QApplication.processEvents()
QApplication.processEvents() # TODO check if this kills performance and scheduling!
def main(): # pragma: no cover
@@ -269,10 +344,10 @@ def main(): # pragma: no cover
app = QApplication(sys.argv)
apply_theme("light")
widget = QWidget()
widget.setLayout(QVBoxLayout())
widget.setLayout(layout := QVBoxLayout())
device = QLineEdit()
widget.layout().addWidget(device)
layout.addWidget(device)
def _destroy_dialog(*_):
nonlocal dialog
@@ -285,14 +360,14 @@ def main(): # pragma: no cover
def _show_dialog(*_):
nonlocal dialog
if dialog is None:
kwargs = {"device": dev} if (dev := device.text()) else {"action": "add"}
dialog = DeviceConfigDialog(**kwargs)
kwargs = {} # kwargs = {"device": dev} if (dev := device.text()) else {"action": "add"}
dialog = PresetClassDeviceConfigDialog(**kwargs) # type: ignore
dialog.accepted.connect(accept)
dialog.rejected.connect(_destroy_dialog)
dialog.open()
button = QPushButton("Show device dialog")
widget.layout().addWidget(button)
layout.addWidget(button)
button.clicked.connect(_show_dialog)
widget.show()
sys.exit(app.exec_())

View File

@@ -1,16 +1,20 @@
from __future__ import annotations
from functools import partial
from bec_lib.atlas_models import Device as DeviceConfigModel
from pydantic import BaseModel
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import get_theme_name
from bec_widgets.utils.forms_from_types import styles
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm, PydanticModelFormItem
from bec_widgets.utils.forms_from_types.items import (
DEFAULT_WIDGET_TYPES,
BoolFormItem,
BoolToggleFormItem,
DictFormItem,
FormItemSpec,
)
@@ -18,7 +22,14 @@ class DeviceConfigForm(PydanticModelForm):
RPC = False
PLUGIN = False
def __init__(self, parent=None, client=None, pretty_display=False, **kwargs):
def __init__(
self,
parent=None,
client=None,
pretty_display=False,
class_deviceconfig_item: type[BaseModel] | None = None,
**kwargs,
):
super().__init__(
parent=parent,
data_model=DeviceConfigModel,
@@ -26,18 +37,28 @@ class DeviceConfigForm(PydanticModelForm):
client=client,
**kwargs,
)
self._class_deviceconfig_item: type[BaseModel] | None = class_deviceconfig_item
self._widget_types = DEFAULT_WIDGET_TYPES.copy()
self._widget_types["bool"] = (lambda spec: spec.item_type is bool, BoolToggleFormItem)
self._widget_types["optional_bool"] = (
lambda spec: spec.item_type == bool | None,
BoolFormItem,
)
self._validity.setVisible(False)
pred, _ = self._widget_types["dict"]
self._widget_types["dict"] = pred, self._custom_device_config_item
self._validity.setVisible(True)
self._connect_to_theme_change()
self.populate()
def _post_init(self): ...
def _custom_device_config_item(self, spec: FormItemSpec):
if spec.name != "deviceConfig":
return DictFormItem
if self._class_deviceconfig_item is not None:
return partial(PydanticModelFormItem, model=self._class_deviceconfig_item)
return DictFormItem
def set_pretty_display_theme(self, theme: str | None = None):
if theme is None:
theme = get_theme_name()

View File

@@ -18,7 +18,7 @@ from bec_widgets.widgets.services.device_browser.device_item.config_communicator
CommunicateConfigAction,
)
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
DeviceConfigDialog,
DirectUpdateDeviceConfigDialog,
)
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
DeviceConfigForm,
@@ -35,9 +35,6 @@ logger = bec_logger.logger
class DeviceItem(ExpandableGroupFrame):
broadcast_size_hint = Signal(QSize)
imminent_deletion = Signal()
RPC = False
def __init__(
@@ -94,7 +91,7 @@ class DeviceItem(ExpandableGroupFrame):
@SafeSlot()
def _create_edit_dialog(self):
dialog = DeviceConfigDialog(
dialog = DirectUpdateDeviceConfigDialog(
parent=self,
device=self.device,
config_helper=self._config_helper,

View File

@@ -37,11 +37,11 @@ def device_browser(qtbot, mocked_client):
yield dev_browser
def test_device_browser_init_with_devices(device_browser):
def test_device_browser_init_with_devices(device_browser: DeviceBrowser):
"""
Test that the device browser is initialized with the correct number of devices.
"""
device_list = device_browser.ui.device_list
device_list = device_browser.dev_list
assert device_list.count() == len(device_browser.dev)
@@ -58,11 +58,11 @@ def test_device_browser_filtering(
expected = expected_num_visible if expected_num_visible >= 0 else len(device_browser.dev)
def num_visible(item_dict):
return len(list(filter(lambda i: not i.isHidden(), item_dict.values())))
return len(list(filter(lambda i: not i.widget.isHidden(), item_dict.values())))
device_browser.ui.filter_input.setText(search_term)
qtbot.wait(100)
assert num_visible(device_browser._device_items) == expected
assert num_visible(device_browser.dev_list._item_dict) == expected
def test_device_item_mouse_press_event(device_browser, qtbot):
@@ -70,8 +70,8 @@ def test_device_item_mouse_press_event(device_browser, qtbot):
Test that the mousePressEvent is triggered correctly.
"""
# Simulate a left mouse press event on the device item
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0)
widget: DeviceItem = device_browser.dev_list.itemWidget(device_item)
qtbot.mouseClick(widget._title, Qt.MouseButton.LeftButton)
@@ -88,8 +88,8 @@ def test_device_item_expansion(device_browser, qtbot):
Test that the form is displayed when the item is expanded, and that the expansion is triggered
by clicking on the expansion button, the title, or the device icon
"""
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0)
widget: DeviceItem = device_browser.dev_list.itemWidget(device_item)
qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton)
tab_widget: QTabWidget = widget._contents.layout().itemAt(0).widget()
qtbot.waitUntil(lambda: tab_widget.widget(0) is not None, timeout=100)
@@ -100,7 +100,7 @@ def test_device_item_expansion(device_browser, qtbot):
form = tab_widget.widget(0).layout().itemAt(0).widget()
assert widget.expanded
assert (name_field := form.widget_dict.get("name")) is not None
qtbot.waitUntil(lambda: name_field.getValue() == "samx", timeout=500)
qtbot.waitUntil(lambda: name_field.getValue() == "aptrx", timeout=500)
qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton)
assert not widget.expanded
@@ -115,8 +115,8 @@ def test_device_item_mouse_press_and_move_events_creates_drag(device_browser, qt
"""
Test that the mousePressEvent is triggered correctly and initiates a drag.
"""
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0)
widget: DeviceItem = device_browser.dev_list.itemWidget(device_item)
device_name = widget.device
with mock.patch("qtpy.QtGui.QDrag.exec_") as mock_exec:
with mock.patch("qtpy.QtGui.QDrag.setMimeData") as mock_set_mimedata:
@@ -133,19 +133,19 @@ def test_device_item_double_click_event(device_browser, qtbot):
Test that the mouseDoubleClickEvent is triggered correctly.
"""
# Simulate a left mouse press event on the device item
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0)
widget: DeviceItem = device_browser.dev_list.itemWidget(device_item)
qtbot.mouseDClick(widget, Qt.LeftButton)
def test_device_deletion(device_browser, qtbot):
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0)
widget: DeviceItem = device_browser.dev_list.itemWidget(device_item)
widget._config_helper = mock.MagicMock()
assert widget.device in device_browser._device_items
assert widget.device in device_browser.dev_list._item_dict
qtbot.mouseClick(widget.delete_button, Qt.LeftButton)
qtbot.waitUntil(lambda: widget.device not in device_browser._device_items, timeout=10000)
qtbot.waitUntil(lambda: widget.device not in device_browser.dev_list._item_dict, timeout=10000)
def test_signal_display(mocked_client, qtbot):

View File

@@ -6,7 +6,7 @@ from qtpy.QtWidgets import QDialogButtonBox, QPushButton
from bec_widgets.utils.forms_from_types.items import StrFormItem
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
DeviceConfigDialog,
DirectUpdateDeviceConfigDialog,
_try_literal_eval,
)
@@ -29,7 +29,7 @@ def mock_client():
@pytest.fixture
def update_dialog(mock_client, qtbot):
"""Fixture to create a DeviceConfigDialog instance."""
update_dialog = DeviceConfigDialog(
update_dialog = DirectUpdateDeviceConfigDialog(
device="test_device", config_helper=MagicMock(), client=mock_client
)
qtbot.addWidget(update_dialog)
@@ -39,7 +39,7 @@ def update_dialog(mock_client, qtbot):
@pytest.fixture
def add_dialog(mock_client, qtbot):
"""Fixture to create a DeviceConfigDialog instance."""
add_dialog = DeviceConfigDialog(
add_dialog = DirectUpdateDeviceConfigDialog(
device=None, config_helper=MagicMock(), client=mock_client, action="add"
)
qtbot.addWidget(add_dialog)

View File

@@ -43,7 +43,7 @@ def test_device_input_base_init(device_input_base):
assert device_input_base.devices == []
def test_device_input_base_init_with_config(mocked_client):
def test_device_input_base_init_with_config(qtbot, mocked_client):
"""Test init with Config"""
config = {
"widget_class": "DeviceInputWidget",
@@ -55,6 +55,10 @@ def test_device_input_base_init_with_config(mocked_client):
widget2 = DeviceInputWidget(
client=mocked_client, config=DeviceInputConfig.model_validate(config)
)
qtbot.addWidget(widget)
qtbot.addWidget(widget2)
qtbot.waitExposed(widget)
qtbot.waitExposed(widget2)
for w in [widget, widget2]:
assert w.config.gui_id == "test_gui_id"
assert w.config.device_filter == ["Positioner"]

View File

@@ -0,0 +1,869 @@
"""Unit tests for device_manager_components module."""
from unittest import mock
import pytest
import yaml
from bec_lib.atlas_models import Device as DeviceModel
from qtpy import QtCore, QtGui, QtWidgets
from bec_widgets.widgets.control.device_manager.components.constants import HEADERS_HELP_MD
from bec_widgets.widgets.control.device_manager.components.device_table_view import (
USER_CHECK_DATA_ROLE,
BECTableView,
CenterCheckBoxDelegate,
CustomDisplayDelegate,
DeviceFilterProxyModel,
DeviceTableModel,
DeviceTableView,
DeviceValidatedDelegate,
DictToolTipDelegate,
WrappingTextDelegate,
)
from bec_widgets.widgets.control.device_manager.components.dm_config_view import DMConfigView
from bec_widgets.widgets.control.device_manager.components.dm_docstring_view import (
DocstringView,
docstring_to_markdown,
)
from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ValidationStatus
### Constants ####
def test_constants_headers_help_md():
"""Test that HEADERS_HELP_MD is a dictionary with expected keys and markdown format."""
assert isinstance(HEADERS_HELP_MD, dict)
expected_keys = {
"status",
"name",
"deviceClass",
"readoutPriority",
"deviceTags",
"enabled",
"readOnly",
"onFailure",
"softwareTrigger",
"description",
}
assert set(HEADERS_HELP_MD.keys()) == expected_keys
for _, value in HEADERS_HELP_MD.items():
assert isinstance(value, str)
assert value.startswith("## ") # Each entry should start with a markdown header
### DM Docstring View ####
@pytest.fixture
def docstring_view(qtbot):
"""Fixture to create a DocstringView instance."""
view = DocstringView()
qtbot.addWidget(view)
qtbot.waitExposed(view)
yield view
class NumPyStyleClass:
"""Perform simple signal operations.
Parameters
----------
data : numpy.ndarray
Input signal data.
Attributes
----------
data : numpy.ndarray
The original signal data.
Returns
-------
SignalProcessor
An initialized signal processor instance.
"""
class GoogleStyleClass:
"""Analyze spectral properties of a signal.
Args:
frequencies (list[float]): Frequency bins.
amplitudes (list[float]): Corresponding amplitude values.
Returns:
dict: A dictionary with spectral analysis results.
Raises:
ValueError: If input lists are of unequal length.
"""
def test_docstring_view_docstring_to_markdown():
"""Test the docstring_to_markdown function with a sample class."""
numpy_md = docstring_to_markdown(NumPyStyleClass)
assert "# NumPyStyleClass" in numpy_md
assert "### Parameters" in numpy_md
assert "### Attributes" in numpy_md
assert "### Returns" in numpy_md
assert "```" in numpy_md # Check for code block formatting
google_md = docstring_to_markdown(GoogleStyleClass)
assert "# GoogleStyleClass" in google_md
assert "### Args" in google_md
assert "### Returns" in google_md
assert "### Raises" in google_md
assert "```" in google_md # Check for code block formatting
def test_docstring_view_on_select_config(docstring_view):
"""Test the DocstringView on_select_config method. Called with single and multiple devices."""
with (
mock.patch.object(docstring_view, "set_device_class") as mock_set_device_class,
mock.patch.object(docstring_view, "_set_text") as mock_set_text,
):
# Test with single device
docstring_view.on_select_config([{"deviceClass": "NumPyStyleClass"}])
mock_set_device_class.assert_called_once_with("NumPyStyleClass")
mock_set_device_class.reset_mock()
# Test with multiple devices, should not show anything
docstring_view.on_select_config(
[{"deviceClass": "NumPyStyleClass"}, {"deviceClass": "GoogleStyleClass"}]
)
mock_set_device_class.assert_not_called()
mock_set_text.assert_called_once_with("")
def test_docstring_view_set_device_class(docstring_view):
"""Test the DocstringView set_device_class method with valid and invalid class names."""
with mock.patch(
"bec_widgets.widgets.control.device_manager.components.dm_docstring_view.get_plugin_class"
) as mock_get_plugin_class:
# Mock a valid class retrieval
mock_get_plugin_class.return_value = NumPyStyleClass
docstring_view.set_device_class("NumPyStyleClass")
assert "NumPyStyleClass" in docstring_view.toPlainText()
assert "Parameters" in docstring_view.toPlainText()
# Mock an invalid class retrieval
mock_get_plugin_class.side_effect = ImportError("Class not found")
docstring_view.set_device_class("NonExistentClass")
assert "Error retrieving docstring for NonExistentClass" == docstring_view.toPlainText()
# Test if READY_TO_VIEW is False
with mock.patch(
"bec_widgets.widgets.control.device_manager.components.dm_docstring_view.READY_TO_VIEW",
False,
):
call_count = mock_get_plugin_class.call_count
docstring_view.set_device_class("NumPyStyleClass") # Should do nothing
assert mock_get_plugin_class.call_count == call_count # No new calls made
#### DM Config View ####
@pytest.fixture
def dm_config_view(qtbot):
"""Fixture to create a DMConfigView instance."""
view = DMConfigView()
qtbot.addWidget(view)
qtbot.waitExposed(view)
yield view
def test_dm_config_view_initialization(dm_config_view):
"""Test DMConfigView proper initialization."""
# Check that the stacked layout is set up correctly
assert dm_config_view.stacked_layout is not None
assert dm_config_view.stacked_layout.count() == 2
# Assert Monaco editor is initialized
assert dm_config_view.monaco_editor.get_language() == "yaml"
assert dm_config_view.monaco_editor.editor._readonly is True
# Check overlay widget
assert dm_config_view._overlay_widget is not None
assert dm_config_view._overlay_widget.text() == "Select single device to show config"
# Check that overlay is initially shown
assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget
def test_dm_config_view_on_select_config(dm_config_view):
"""Test DMConfigView on_select_config with empty selection."""
# Test with empty list of configs
dm_config_view.on_select_config([])
assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget
# Test with a single config
cfgs = [{"name": "test_device", "deviceClass": "TestClass", "enabled": True}]
dm_config_view.on_select_config(cfgs)
assert dm_config_view.stacked_layout.currentWidget() == dm_config_view.monaco_editor
text = yaml.dump(cfgs[0], default_flow_style=False)
assert text.strip("\n") == dm_config_view.monaco_editor.get_text().strip("\n")
# Test with multiple configs
cfgs = 2 * [{"name": "test_device", "deviceClass": "TestClass", "enabled": True}]
dm_config_view.on_select_config(cfgs)
assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget
assert dm_config_view.monaco_editor.get_text() == "" # Should remain unchanged
### Device Table View ####
# Not sure how to nicely test the delegates.
@pytest.fixture
def mock_table_view(qtbot):
"""Create a mock table view for delegate testing."""
table = BECTableView()
qtbot.addWidget(table)
qtbot.waitExposed(table)
yield table
@pytest.fixture
def device_table_model(qtbot, mock_table_view):
"""Fixture to create a DeviceTableModel instance."""
model = DeviceTableModel(mock_table_view)
yield model
@pytest.fixture
def device_proxy_model(qtbot, mock_table_view, device_table_model):
"""Fixture to create a DeviceFilterProxyModel instance."""
model = DeviceFilterProxyModel(mock_table_view)
model.setSourceModel(device_table_model)
mock_table_view.setModel(model)
yield model
@pytest.fixture
def qevent_mock() -> QtCore.QEvent:
"""Create a mock QEvent for testing."""
event = mock.MagicMock(spec=QtCore.QEvent)
yield event
@pytest.fixture
def view_mock() -> QtWidgets.QAbstractItemView:
"""Create a mock QAbstractItemView for testing."""
view = mock.MagicMock(spec=QtWidgets.QAbstractItemView)
yield view
@pytest.fixture
def index_mock(device_proxy_model) -> QtCore.QModelIndex:
"""Create a mock QModelIndex for testing."""
index = mock.MagicMock(spec=QtCore.QModelIndex)
index.model.return_value = device_proxy_model
yield index
@pytest.fixture
def option_mock() -> QtWidgets.QStyleOptionViewItem:
"""Create a mock QStyleOptionViewItem for testing."""
option = mock.MagicMock(spec=QtWidgets.QStyleOptionViewItem)
yield option
@pytest.fixture
def painter_mock() -> QtGui.QPainter:
"""Create a mock QPainter for testing."""
painter = mock.MagicMock(spec=QtGui.QPainter)
yield painter
def test_tooltip_delegate(
mock_table_view, qevent_mock, view_mock, option_mock, index_mock, device_proxy_model
):
"""Test DictToolTipDelegate tooltip generation."""
# No ToolTip event
delegate = DictToolTipDelegate(mock_table_view)
qevent_mock.type.return_value = QtCore.QEvent.Type.TouchCancel
# nothing should happen
with mock.patch.object(
QtWidgets.QStyledItemDelegate, "helpEvent", return_value=False
) as super_mock:
result = delegate.helpEvent(qevent_mock, view_mock, option_mock, index_mock)
super_mock.assert_called_once_with(qevent_mock, view_mock, option_mock, index_mock)
assert result is False
# ToolTip event
qevent_mock.type.return_value = QtCore.QEvent.Type.ToolTip
qevent_mock.globalPos = mock.MagicMock(return_value=QtCore.QPoint(10, 20))
source_model = device_proxy_model.sourceModel()
with (
mock.patch.object(
source_model, "get_row_data", return_value={"description": "Mock description"}
),
mock.patch.object(device_proxy_model, "mapToSource", return_value=index_mock),
mock.patch.object(QtWidgets.QToolTip, "showText") as show_text_mock,
):
result = delegate.helpEvent(qevent_mock, view_mock, option_mock, index_mock)
show_text_mock.assert_called_once_with(QtCore.QPoint(10, 20), "Mock description", view_mock)
assert result is True
def test_custom_display_delegate(qtbot, mock_table_view, painter_mock, option_mock, index_mock):
"""Test CustomDisplayDelegate initialization."""
delegate = CustomDisplayDelegate(mock_table_view)
# Test _test_custom_paint, with None and a value
def _return_data():
yield None
yield "Test Value"
proxy_model = index_mock.model()
with (
mock.patch.object(proxy_model, "data", side_effect=_return_data()),
mock.patch.object(
QtWidgets.QStyledItemDelegate, "paint", return_value=None
) as super_paint_mock,
mock.patch.object(delegate, "_do_custom_paint", return_value=None) as custom_paint_mock,
):
delegate.paint(painter_mock, option_mock, index_mock)
super_paint_mock.assert_called_once_with(painter_mock, option_mock, index_mock)
custom_paint_mock.assert_not_called()
# Call again for the value case
delegate.paint(painter_mock, option_mock, index_mock)
super_paint_mock.assert_called_with(painter_mock, option_mock, index_mock)
assert super_paint_mock.call_count == 2
custom_paint_mock.assert_called_once_with(
painter_mock, option_mock, index_mock, "Test Value"
)
def test_center_checkbox_delegate(
mock_table_view, qevent_mock, painter_mock, option_mock, index_mock
):
"""Test CenterCheckBoxDelegate initialization."""
delegate = CenterCheckBoxDelegate(mock_table_view)
option_mock.rect = QtCore.QRect(0, 0, 100, 20)
delegate._do_custom_paint(painter_mock, option_mock, index_mock, QtCore.Qt.CheckState.Checked)
# Check that the checkbox is centered
pixrect = delegate._icon_checked.rect()
pixrect.moveCenter(option_mock.rect.center())
painter_mock.drawPixmap.assert_called_once_with(pixrect.topLeft(), delegate._icon_checked)
model = index_mock.model()
# Editor event with non-check state role
qevent_mock.type.return_value = QtCore.QEvent.Type.MouseTrackingChange
assert not delegate.editorEvent(qevent_mock, model, option_mock, index_mock)
# Editor event with check state role but not mouse button event
qevent_mock.type.return_value = QtCore.QEvent.Type.MouseButtonRelease
with (
mock.patch.object(model, "data", return_value=QtCore.Qt.CheckState.Checked),
mock.patch.object(model, "setData") as mock_model_set,
):
delegate.editorEvent(qevent_mock, model, option_mock, index_mock)
mock_model_set.assert_called_once_with(
index_mock, QtCore.Qt.CheckState.Unchecked, USER_CHECK_DATA_ROLE
)
def test_device_validated_delegate(
mock_table_view, qevent_mock, painter_mock, option_mock, index_mock
):
"""Test DeviceValidatedDelegate initialization."""
# Invalid value
delegate = DeviceValidatedDelegate(mock_table_view)
delegate._do_custom_paint(painter_mock, option_mock, index_mock, "wrong_value")
painter_mock.drawPixmap.assert_not_called()
# Valid value
option_mock.rect = QtCore.QRect(0, 0, 100, 20)
delegate._do_custom_paint(painter_mock, option_mock, index_mock, ValidationStatus.VALID.value)
icon = delegate._icons[ValidationStatus.VALID.value]
pixrect = icon.rect()
pixrect.moveCenter(option_mock.rect.center())
painter_mock.drawPixmap.assert_called_once_with(pixrect.topLeft(), icon)
def test_wrapping_text_delegate_do_custom_paint(
mock_table_view, painter_mock, option_mock, index_mock
):
"""Test WrappingTextDelegate _do_custom_paint method."""
delegate = WrappingTextDelegate(mock_table_view)
# First case, empty text, nothing should happen
delegate._do_custom_paint(painter_mock, option_mock, index_mock, "")
painter_mock.setPen.assert_not_called()
layout_mock = mock.MagicMock()
def _layout_comput_return(*args, **kwargs):
return layout_mock
layout_mock.draw.return_value = None
with mock.patch.object(delegate, "_compute_layout", side_effect=_layout_comput_return):
delegate._do_custom_paint(painter_mock, option_mock, index_mock, "New Docstring")
layout_mock.draw.assert_called_with(painter_mock, option_mock.rect.topLeft())
TEST_RECT_FOR = QtCore.QRect(0, 0, 100, 20)
TEST_TEXT_WITH_4_LINES = "This is a test string to check text wrapping in the delegate."
def test_wrapping_text_delegate_compute_layout(mock_table_view, option_mock):
"""Test WrappingTextDelegate _compute_layout method."""
delegate = WrappingTextDelegate(mock_table_view)
layout_mock = mock.MagicMock(spec=QtGui.QTextLayout)
# This combination should yield 4 lines
with mock.patch.object(delegate, "_get_layout", return_value=layout_mock):
layout_mock.createLine.return_value = mock_line = mock.MagicMock(spec=QtGui.QTextLine)
mock_line.height.return_value = 10
mock_line.isValid = mock.MagicMock(side_effect=[True, True, True, False])
option_mock.rect = TEST_RECT_FOR
option_mock.font = QtGui.QFont()
layout: QtGui.QTextLayout = delegate._compute_layout(TEST_TEXT_WITH_4_LINES, option_mock)
assert layout.createLine.call_count == 4 # pylint: disable=E1101
assert mock_line.setPosition.call_count == 3
assert mock_line.setPosition.call_args_list[-1] == mock.call(
QtCore.QPointF(delegate.margin / 2, 20) # 0, 10, 20 # Then false and exit
)
def test_wrapping_text_delegate_size_hint(mock_table_view, option_mock, index_mock):
"""Test WrappingTextDelegate sizeHint method. Use the test text that should wrap to 4 lines."""
delegate = WrappingTextDelegate(mock_table_view)
assert delegate.margin == 6
with (
mock.patch.object(mock_table_view, "initViewItemOption"),
mock.patch.object(mock_table_view, "isColumnHidden", side_effect=[False, False]),
mock.patch.object(mock_table_view, "isVisible", side_effect=[True, True]),
):
# Test with empty text, should return height + 2*margin
index_mock.data.return_value = ""
option_mock.rect = TEST_RECT_FOR
font_metrics = option_mock.fontMetrics = QtGui.QFontMetrics(QtGui.QFont())
size = delegate.sizeHint(option_mock, index_mock)
assert size == QtCore.QSize(0, font_metrics.height() + 2 * delegate.margin)
# Now test with the text that should wrap to 4 lines
index_mock.data.return_value = TEST_TEXT_WITH_4_LINES
size = delegate.sizeHint(option_mock, index_mock)
# The estimate goes to 5 lines + 2* margin
expected_lines = 5
assert size == QtCore.QSize(
100, font_metrics.height() * expected_lines + 2 * delegate.margin
)
def test_wrapping_text_delegate_update_row_heights(mock_table_view, device_proxy_model):
"""Test WrappingTextDelegate update_row_heights method."""
device_cfg = DeviceModel(
name="test_device", deviceClass="TestClass", enabled=True, readoutPriority="baseline"
).model_dump()
# Add single device to config
delegate = WrappingTextDelegate(mock_table_view)
row_heights = [25, 40]
with mock.patch.object(
delegate,
"sizeHint",
side_effect=[QtCore.QSize(100, row_heights[0]), QtCore.QSize(100, row_heights[1])],
):
mock_table_view.setItemDelegateForColumn(5, delegate)
mock_table_view.setItemDelegateForColumn(6, delegate)
device_proxy_model.sourceModel().set_device_config([device_cfg])
assert delegate._wrapping_text_columns is None
assert mock_table_view.rowHeight(0) == 30 # Default height
delegate._update_row_heights()
assert delegate._wrapping_text_columns == [5, 6]
assert mock_table_view.rowHeight(0) == max(row_heights)
def test_device_validation_delegate(
mock_table_view, qevent_mock, painter_mock, option_mock, index_mock
):
"""Test DeviceValidatedDelegate initialization."""
delegate = DeviceValidatedDelegate(mock_table_view)
option_mock.rect = QtCore.QRect(0, 0, 100, 20)
delegate._do_custom_paint(painter_mock, option_mock, index_mock, ValidationStatus.VALID)
# Check that the checkbox is centered
pixrect = delegate._icons[ValidationStatus.VALID.value].rect()
pixrect.moveCenter(option_mock.rect.center())
painter_mock.drawPixmap.assert_called_once_with(
pixrect.topLeft(), delegate._icons[ValidationStatus.VALID.value]
)
# Should not be called if invalid value
delegate._do_custom_paint(painter_mock, option_mock, index_mock, 10)
# Check that the checkbox is centered
assert painter_mock.drawPixmap.call_count == 1
###
# Test DeviceTableModel & DeviceFilterProxyModel
###
def test_device_table_model_data(device_proxy_model):
"""Test the device table model data retrieval."""
source_model = device_proxy_model.sourceModel()
test_device = {
"status": ValidationStatus.PENDING,
"name": "test_device",
"deviceClass": "TestClass",
"readoutPriority": "baseline",
"onFailure": "retry",
"enabled": True,
"readOnly": False,
"softwareTrigger": True,
"deviceTags": ["tag1", "tag2"],
"description": "Test device",
}
source_model.add_device_configs([test_device])
assert source_model.rowCount() == 1
assert source_model.columnCount() == 10
# Check data retrieval for each column
expected_data = {
0: ValidationStatus.PENDING, # Default status
1: "test_device", # name
2: "TestClass", # deviceClass
3: "baseline", # readoutPriority
4: "retry", # onFailure
5: "tag1, tag2", # deviceTags
6: "Test device", # description
7: True, # enabled
8: False, # readOnly
9: True, # softwareTrigger
}
for col, expected in expected_data.items():
index = source_model.index(0, col)
data = source_model.data(index, QtCore.Qt.DisplayRole)
assert data == expected
def test_device_table_model_with_data(device_table_model, device_proxy_model):
"""Test (A): DeviceTableModel and DeviceFilterProxyModel with 3 rows of data."""
# Create 3 test devices - names NOT alphabetically sorted
test_devices = [
{
"name": "zebra_device",
"deviceClass": "TestClass1",
"enabled": True,
"readOnly": False,
"readoutPriority": "baseline",
"deviceTags": ["tag1", "tag2"],
"description": "Test device Z",
},
{
"name": "alpha_device",
"deviceClass": "TestClass2",
"enabled": False,
"readOnly": True,
"readoutPriority": "primary",
"deviceTags": ["tag3"],
"description": "Test device A",
},
{
"name": "beta_device",
"deviceClass": "TestClass3",
"enabled": True,
"readOnly": False,
"readoutPriority": "secondary",
"deviceTags": [],
"description": "Test device B",
},
]
# Add devices to source model
device_table_model.add_device_configs(test_devices)
# Check source model has 3 rows and proper columns
assert device_table_model.rowCount() == 3
assert device_table_model.columnCount() == 10
# Check proxy model propagates the data
assert device_proxy_model.rowCount() == 3
assert device_proxy_model.columnCount() == 10
# Verify data propagation through proxy - check names in original order
for i, expected_device in enumerate(test_devices):
proxy_index = device_proxy_model.index(i, 1) # Column 1 is name
source_index = device_proxy_model.mapToSource(proxy_index)
source_data = device_table_model.data(source_index, QtCore.Qt.DisplayRole)
assert source_data == expected_device["name"]
# Check proxy data matches source
proxy_data = device_proxy_model.data(proxy_index, QtCore.Qt.DisplayRole)
assert proxy_data == source_data
# Verify all columns are accessible
headers = device_table_model.headers
for col, header in enumerate(headers):
header_data = device_table_model.headerData(
col, QtCore.Qt.Horizontal, QtCore.Qt.DisplayRole
)
assert header_data is not None
def test_device_table_sorting(qtbot, mock_table_view, device_table_model, device_proxy_model):
"""Test (B): Sorting functionality - original row 2 (alpha) should become row 0 after sort."""
# Use same test data as above - zebra, alpha, beta (not alphabetically sorted)
test_devices = [
{
"status": ValidationStatus.VALID,
"name": "zebra_device",
"deviceClass": "TestClass1",
"enabled": True,
},
{
"status": ValidationStatus.PENDING,
"name": "alpha_device",
"deviceClass": "TestClass2",
"enabled": False,
},
{
"status": ValidationStatus.FAILED,
"name": "beta_device",
"deviceClass": "TestClass3",
"enabled": True,
},
]
device_table_model.add_device_configs(test_devices)
# Verify initial order (unsorted)
assert (
device_proxy_model.data(device_proxy_model.index(0, 1), QtCore.Qt.DisplayRole)
== "zebra_device"
)
assert (
device_proxy_model.data(device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole)
== "alpha_device"
)
assert (
device_proxy_model.data(device_proxy_model.index(2, 1), QtCore.Qt.DisplayRole)
== "beta_device"
)
# Enable sorting and sort by name column (column 1)
mock_table_view.setSortingEnabled(True)
# header = mock_table_view.horizontalHeader()
# qtbot.mouseClick(header.sectionPosition(1), QtCore.Qt.LeftButton)
device_proxy_model.sort(1, QtCore.Qt.AscendingOrder)
# After sorting, verify alphabetical order: alpha, beta, zebra
assert (
device_proxy_model.data(device_proxy_model.index(0, 1), QtCore.Qt.DisplayRole)
== "alpha_device"
)
assert (
device_proxy_model.data(device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole)
== "beta_device"
)
assert (
device_proxy_model.data(device_proxy_model.index(2, 1), QtCore.Qt.DisplayRole)
== "zebra_device"
)
def test_bec_table_view_remove_rows(qtbot, mock_table_view, device_table_model, device_proxy_model):
"""Test (C): Remove rows from BECTableView and verify propagation."""
# Set up test data
test_devices = [
{"name": "device_to_keep", "deviceClass": "KeepClass", "enabled": True},
{"name": "device_to_remove", "deviceClass": "RemoveClass", "enabled": False},
{"name": "another_keeper", "deviceClass": "KeepClass2", "enabled": True},
]
device_table_model.add_device_configs(test_devices)
assert device_table_model.rowCount() == 3
assert device_proxy_model.rowCount() == 3
# Mock the confirmation dialog to first cancel, then confirm
with mock.patch.object(
mock_table_view, "_remove_rows_msg_dialog", side_effect=[False, True]
) as mock_confirm:
# Create mock selection for middle device (device_to_remove at row 1)
selection_model = mock.MagicMock()
proxy_index_to_remove = device_proxy_model.index(1, 0) # Row 1, any column
selection_model.selectedRows.return_value = [proxy_index_to_remove]
mock_table_view.selectionModel = mock.MagicMock(return_value=selection_model)
# Verify the device we're about to remove
device_name_to_remove = device_proxy_model.data(
device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole
)
assert device_name_to_remove == "device_to_remove"
# Call delete_selected method
mock_table_view.delete_selected()
# Verify confirmation was called
mock_confirm.assert_called_once()
assert device_table_model.rowCount() == 3 # No change on first call
assert device_proxy_model.rowCount() == 3
# Call delete_selected again, this time it should confirm
mock_table_view.delete_selected()
# Check that the device was removed from source model
assert device_table_model.rowCount() == 2
assert device_proxy_model.rowCount() == 2
# Verify the remaining devices are correct
remaining_names = []
for i in range(device_proxy_model.rowCount()):
name = device_proxy_model.data(device_proxy_model.index(i, 1), QtCore.Qt.DisplayRole)
remaining_names.append(name)
assert "device_to_remove" not in remaining_names
def test_device_filter_proxy_model_filtering(device_table_model, device_proxy_model):
"""Test DeviceFilterProxyModel text filtering functionality."""
# Set up test data with different device names and classes
test_devices = [
{"name": "motor_x", "deviceClass": "EpicsMotor", "description": "X-axis motor"},
{"name": "detector_main", "deviceClass": "EpicsDetector", "description": "Main detector"},
{"name": "motor_y", "deviceClass": "EpicsMotor", "description": "Y-axis motor"},
]
device_table_model.add_device_configs(test_devices)
assert device_proxy_model.rowCount() == 3
# Test filtering by name
device_proxy_model.setFilterText("motor")
assert device_proxy_model.rowCount() == 2
# Should show 2 rows (motor_x and motor_y)
visible_count = 0
for i in range(device_proxy_model.rowCount()):
if not device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex()):
continue
visible_count += 1
# Test filtering by device class
device_proxy_model.setFilterText("EpicsDetector")
# Should show 1 row (detector_main)
detector_visible = False
assert device_proxy_model.rowCount() == 1
for i in range(device_table_model.rowCount()):
if device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex()):
source_index = device_table_model.index(i, 1) # Name column
name = device_table_model.data(source_index, QtCore.Qt.DisplayRole)
if name == "detector_main":
detector_visible = True
break
assert detector_visible
# Clear filter
device_proxy_model.setFilterText("")
assert device_proxy_model.rowCount() == 3
# Should show all 3 rows again
all_visible = all(
device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex())
for i in range(device_table_model.rowCount())
)
assert all_visible
###
# Test DeviceTableView
###
@pytest.fixture
def device_table_view(qtbot):
"""Fixture to create a DeviceTableView instance."""
view = DeviceTableView()
qtbot.addWidget(view)
qtbot.waitExposed(view)
yield view
def test_device_table_view_initialization(qtbot, device_table_view):
"""Test the DeviceTableView search method."""
# Check that the search input fields are properly initialized and connected
qtbot.keyClicks(device_table_view.search_input, "zebra")
qtbot.waitUntil(lambda: device_table_view.proxy._filter_text == "zebra", timeout=2000)
qtbot.mouseClick(device_table_view.fuzzy_is_disabled, QtCore.Qt.LeftButton)
qtbot.waitUntil(lambda: device_table_view.proxy._enable_fuzzy is True, timeout=2000)
# Check table setup
# header
header = device_table_view.table.horizontalHeader()
assert header.sectionResizeMode(5) == QtWidgets.QHeaderView.ResizeMode.Interactive # tags
assert header.sectionResizeMode(6) == QtWidgets.QHeaderView.ResizeMode.Stretch # description
# table selection
assert (
device_table_view.table.selectionBehavior()
== QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows
)
assert (
device_table_view.table.selectionMode()
== QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection
)
def test_device_table_theme_update(device_table_view):
"""Test DeviceTableView apply_theme method."""
# Check apply theme propagates
with (
mock.patch.object(device_table_view.checkbox_delegate, "apply_theme") as mock_apply,
mock.patch.object(device_table_view.validated_delegate, "apply_theme") as mock_validated,
):
device_table_view.apply_theme("dark")
mock_apply.assert_called_once_with("dark")
mock_validated.assert_called_once_with("dark")
def test_device_table_view_updates(device_table_view):
"""Test DeviceTableView methods that update the view and model."""
# Test theme update triggered..
cfgs = [
{"status": 0, "name": "test_device", "deviceClass": "TestClass", "enabled": True},
{"status": 1, "name": "another_device", "deviceClass": "AnotherClass", "enabled": False},
{"status": 2, "name": "zebra_device", "deviceClass": "ZebraClass", "enabled": True},
]
with mock.patch.object(device_table_view, "_request_autosize_columns") as mock_autosize:
# Should be called once for rowsInserted
device_table_view.set_device_config(cfgs)
assert device_table_view.get_device_config() == cfgs
mock_autosize.assert_called_once()
# Update validation status, should be called again
device_table_view.update_device_validation("test_device", ValidationStatus.VALID)
assert mock_autosize.call_count == 2
# Remove a device, should triggere also a _request_autosize_columns call
device_table_view.remove_device_configs([cfgs[0]])
assert device_table_view.get_device_config() == cfgs[1:]
assert mock_autosize.call_count == 3
# Remove one device manually
device_table_view.remove_device("another_device") # Should remove the last device
assert device_table_view.get_device_config() == cfgs[2:]
assert mock_autosize.call_count == 4
# Reset the model should call it once again
device_table_view.clear_device_configs()
assert mock_autosize.call_count == 5
assert device_table_view.get_device_config() == []
def test_device_table_view_get_help_md(device_table_view):
"""Test DeviceTableView get_help_md method."""
with mock.patch.object(device_table_view.table, "indexAt") as mock_index_at:
mock_index_at.isValid = mock.MagicMock(return_value=True)
with mock.patch.object(device_table_view, "_model") as mock_model:
mock_model.headerData = mock.MagicMock(side_effect=["softTrig"])
# Second call is True, should return the corresponding help md
assert device_table_view.get_help_md() == HEADERS_HELP_MD["softwareTrigger"]

View File

@@ -0,0 +1,224 @@
"""Unit tests for the device manager view"""
# pylint: disable=protected-access,redefined-outer-name
from unittest import mock
import pytest
from qtpy import QtCore
from qtpy.QtWidgets import QFileDialog, QMessageBox
from bec_widgets.applications.views.device_manager_view.device_manager_view import (
ConfigChoiceDialog,
DeviceManagerView,
)
from bec_widgets.utils.help_inspector.help_inspector import HelpInspector
from bec_widgets.widgets.control.device_manager.components import (
DeviceTableView,
DMConfigView,
DMOphydTest,
DocstringView,
)
@pytest.fixture
def dm_view(qtbot):
"""Fixture for DeviceManagerView."""
widget = DeviceManagerView()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@pytest.fixture
def config_choice_dialog(qtbot, dm_view):
"""Fixture for ConfigChoiceDialog."""
dialog = ConfigChoiceDialog(dm_view)
qtbot.addWidget(dialog)
qtbot.waitExposed(dialog)
yield dialog
def test_device_manager_view_config_choice_dialog(qtbot, dm_view, config_choice_dialog):
"""Test the configuration choice dialog."""
assert config_choice_dialog is not None
assert config_choice_dialog.parent() == dm_view
# Test dialog components
with (
mock.patch.object(config_choice_dialog, "accept") as mock_accept,
mock.patch.object(config_choice_dialog, "reject") as mock_reject,
):
# Replace
qtbot.mouseClick(config_choice_dialog.replace_btn, QtCore.Qt.LeftButton)
mock_accept.assert_called_once()
mock_reject.assert_not_called()
mock_accept.reset_mock()
assert config_choice_dialog.result() == config_choice_dialog.REPLACE
# Add
qtbot.mouseClick(config_choice_dialog.add_btn, QtCore.Qt.LeftButton)
mock_accept.assert_called_once()
mock_reject.assert_not_called()
mock_accept.reset_mock()
assert config_choice_dialog.result() == config_choice_dialog.ADD
# Cancel
qtbot.mouseClick(config_choice_dialog.cancel_btn, QtCore.Qt.LeftButton)
mock_accept.assert_not_called()
mock_reject.assert_called_once()
assert config_choice_dialog.result() == config_choice_dialog.CANCEL
class TestDeviceManagerViewInitialization:
"""Test class for DeviceManagerView initialization and basic components."""
def test_dock_manager_initialization(self, dm_view):
"""Test that the QtAds DockManager is properly initialized."""
assert dm_view.dock_manager is not None
assert dm_view.dock_manager.centralWidget() is not None
def test_central_widget_is_device_table_view(self, dm_view):
"""Test that the central widget is DeviceTableView."""
central_widget = dm_view.dock_manager.centralWidget().widget()
assert isinstance(central_widget, DeviceTableView)
assert central_widget is dm_view.device_table_view
def test_dock_widgets_exist(self, dm_view):
"""Test that all required dock widgets are created."""
dock_widgets = dm_view.dock_manager.dockWidgets()
# Check that we have the expected number of dock widgets
assert len(dock_widgets) >= 4
# Check for specific widget types
widget_types = [dock.widget().__class__ for dock in dock_widgets]
assert DMConfigView in widget_types
assert DMOphydTest in widget_types
assert DocstringView in widget_types
def test_toolbar_initialization(self, dm_view):
"""Test that the toolbar is properly initialized with expected bundles."""
assert dm_view.toolbar is not None
assert "IO" in dm_view.toolbar.bundles
assert "Table" in dm_view.toolbar.bundles
def test_toolbar_components_exist(self, dm_view):
"""Test that all expected toolbar components exist."""
expected_components = [
"load",
"save_to_disk",
"load_redis",
"update_config_redis",
"reset_composed",
"add_device",
"remove_device",
"rerun_validation",
]
for component in expected_components:
assert dm_view.toolbar.components.exists(component)
def test_signal_connections(self, dm_view):
"""Test that signals are properly connected between components."""
# Test that device_table_view signals are connected
assert dm_view.device_table_view.selected_devices is not None
assert dm_view.device_table_view.device_configs_changed is not None
# Test that ophyd_test_view signals are connected
assert dm_view.ophyd_test_view.device_validated is not None
class TestDeviceManagerViewIOBundle:
"""Test class for DeviceManagerView IO bundle actions."""
def test_io_bundle_exists(self, dm_view):
"""Test that IO bundle exists and contains expected actions."""
assert "IO" in dm_view.toolbar.bundles
io_actions = ["load", "save_to_disk", "load_redis", "update_config_redis"]
for action in io_actions:
assert dm_view.toolbar.components.exists(action)
def test_load_file_action_triggered(self, tmp_path, dm_view):
"""Test load file action trigger mechanism."""
with (
mock.patch.object(dm_view, "_get_file_path", return_value=tmp_path),
mock.patch(
"bec_widgets.applications.views.device_manager_view.device_manager_view.yaml_load"
) as mock_yaml_load,
mock.patch.object(dm_view, "_open_config_choice_dialog") as mock_open_dialog,
):
mock_yaml_data = {"device1": {"param1": "value1"}}
mock_yaml_load.return_value = mock_yaml_data
# Setup dialog mock
dm_view.toolbar.components._components["load"].action.action.triggered.emit()
mock_yaml_load.assert_called_once_with(tmp_path)
mock_open_dialog.assert_called_once_with([{"name": "device1", "param1": "value1"}])
def test_save_config_to_file(self, tmp_path, dm_view):
"""Test saving config to file."""
yaml_path = tmp_path / "test_save.yaml"
mock_config = [{"name": "device1", "param1": "value1"}]
with (
mock.patch.object(dm_view, "_get_file_path", return_value=tmp_path),
mock.patch.object(dm_view, "_get_recovery_config_path", return_value=tmp_path),
mock.patch.object(dm_view, "_get_file_path", return_value=yaml_path),
mock.patch.object(
dm_view.device_table_view, "get_device_config", return_value=mock_config
),
):
dm_view.toolbar.components._components["save_to_disk"].action.action.triggered.emit()
assert yaml_path.exists()
class TestDeviceManagerViewTableBundle:
"""Test class for DeviceManagerView Table bundle actions."""
def test_table_bundle_exists(self, dm_view):
"""Test that Table bundle exists and contains expected actions."""
assert "Table" in dm_view.toolbar.bundles
table_actions = ["reset_composed", "add_device", "remove_device", "rerun_validation"]
for action in table_actions:
assert dm_view.toolbar.components.exists(action)
@mock.patch(
"bec_widgets.applications.views.device_manager_view.device_manager_view._yes_no_question"
)
def test_reset_composed_view(self, mock_question, dm_view):
"""Test reset composed view when user confirms."""
with mock.patch.object(dm_view.device_table_view, "clear_device_configs") as mock_clear:
mock_question.return_value = QMessageBox.StandardButton.Yes
dm_view.toolbar.components._components["reset_composed"].action.action.triggered.emit()
mock_clear.assert_called_once()
mock_clear.reset_mock()
mock_question.return_value = QMessageBox.StandardButton.No
dm_view.toolbar.components._components["reset_composed"].action.action.triggered.emit()
mock_clear.assert_not_called()
def test_add_device_action_connected(self, dm_view):
"""Test add device action opens dialog correctly."""
with mock.patch.object(dm_view, "_add_device_action") as mock_add:
dm_view.toolbar.components._components["add_device"].action.action.triggered.emit()
mock_add.assert_called_once()
def test_remove_device_action(self, dm_view):
"""Test remove device action."""
with mock.patch.object(dm_view.device_table_view, "remove_selected_rows") as mock_remove:
dm_view.toolbar.components._components["remove_device"].action.action.triggered.emit()
mock_remove.assert_called_once()
def test_rerun_device_validation(self, dm_view):
"""Test rerun device validation action."""
cfgs = [{"name": "device1", "param1": "value1"}]
with (
mock.patch.object(dm_view.ophyd_test_view, "change_device_configs") as mock_change,
mock.patch.object(
dm_view.device_table_view.table, "selected_configs", return_value=cfgs
),
):
dm_view.toolbar.components._components[
"rerun_validation"
].action.action.triggered.emit()
mock_change.assert_called_once_with(cfgs, True, True)

View File

@@ -1,9 +1,12 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
from unittest import mock
import pytest
from qtpy import QtCore, QtWidgets
from bec_widgets.utils.help_inspector.help_inspector import HelpInspector
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton
from .client_mocks import mocked_client
@@ -79,3 +82,51 @@ def test_help_inspector_escape_key(qtbot, help_inspector):
assert not help_inspector._active
assert not help_inspector._button.isChecked()
assert QtWidgets.QApplication.overrideCursor() is None
def test_help_inspector_event_filter(help_inspector, abort_button):
"""Test the event filter of the HelpInspector."""
# Test nothing happens when not active
obj = mock.MagicMock(spec=QtWidgets.QWidget)
event = mock.MagicMock(spec=QtCore.QEvent)
assert help_inspector._active is False
with mock.patch.object(
QtWidgets.QWidget, "eventFilter", return_value=False
) as super_event_filter:
help_inspector.eventFilter(obj, event) # should do nothing and return False
super_event_filter.assert_called_once_with(obj, event)
super_event_filter.reset_mock()
help_inspector._active = True
with mock.patch.object(help_inspector, "_toggle_mode") as mock_toggle:
# Key press Escape
event.type = mock.MagicMock(return_value=QtCore.QEvent.KeyPress)
event.key = mock.MagicMock(return_value=QtCore.Qt.Key.Key_Escape)
help_inspector.eventFilter(obj, event)
mock_toggle.assert_called_once_with(False)
mock_toggle.reset_mock()
# Click on itself
event.type = mock.MagicMock(return_value=QtCore.QEvent.MouseButtonPress)
event.button = mock.MagicMock(return_value=QtCore.Qt.LeftButton)
event.globalPos = mock.MagicMock(return_value=QtCore.QPoint(1, 1))
with mock.patch.object(
help_inspector._app, "widgetAt", side_effect=[help_inspector, abort_button]
):
# Return for self call
help_inspector.eventFilter(obj, event)
mock_toggle.assert_called_once_with(False)
mock_toggle.reset_mock()
# Run Callback for abort_button
callback_data = []
def _my_callback(widget):
callback_data.append(widget)
help_inspector.register_callback(_my_callback)
help_inspector.eventFilter(obj, event)
mock_toggle.assert_not_called()
assert len(callback_data) == 1
assert callback_data[0] == abort_button
callback_data.clear()