refactor(device_browser): edit devices with the device manager form dialog

This commit is contained in:
wyzula_j committed 2026-10-16 14:22:02 +00:00
1 parent 83eb8748ae
commit d7ca6cf590
7 files changed
+270 -606

No files matched your search

@@ -9,9 +9,12 @@ 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 QThreadPool, Signal
from qtpy.QtCore import Qt, QThreadPool, Signal
from qtpy.QtWidgets import QFileDialog, QListWidget, QToolButton, QVBoxLayout, QWidget
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs import (
DeviceFormDialog,
)
from bec_widgets.utils.bec_signal_proxy import cleanup_signal_proxy
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
@@ -19,8 +22,8 @@ from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames
from bec_widgets.utils.rpc_register import RPCRegister
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 (
DirectUpdateDeviceConfigDialog,
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
CommunicateConfigAction,
)
from bec_widgets.widgets.services.device_browser.util import map_device_type_to_icon
@@ -109,10 +112,27 @@ class DeviceBrowser(BECWidget, QWidget):
self.ui.import_button, "input", self._load_from_file, "append/merge config from file"
)
@SafeSlot()
def _create_add_dialog(self):
dialog = DirectUpdateDeviceConfigDialog(parent=self, device=None, action="add")
dialog = DeviceFormDialog(parent=self, add_btn_text="Add Device")
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
dialog.setWindowTitle("Add new device")
dialog.accepted_data.connect(self._add_device)
dialog.open()
@SafeSlot(dict, int, int, str, str, popup_error=True)
def _add_device(self, config: dict, *_):
if (name := config.get("name")) in self.dev:
raise ValueError(f"Can't add {name}: a device with this name already exists!")
logger.info(f"Sending request to add device: {config}")
adder = CommunicateConfigAction(self._config_helper, None, config, "add")
adder.signals.error.connect(self._add_device_error)
self._q_threadpool.start(adder)
@SafeSlot(Exception, popup_error=True)
def _add_device_error(self, e: Exception):
raise e
def on_device_update(self, action: ConfigAction, content: dict) -> None:
"""
Callback for device update events. Triggers the device_update signal.
@@ -83,7 +83,8 @@ class CommunicateConfigAction(QRunnable):
logger.info("Done cancelling configuration operation")
def process_remove_readd(self, dev_name: str):
new_name = self.config.get("name") or dev_name
logger.info(f"Removing and readding device: {dev_name}")
self.process_simple_action(dev_name, "remove")
self.process_simple_action(dev_name, "add")
logger.info(f"Reinstated {dev_name} successfully!")
self.process_simple_action(new_name, "add")
logger.info(f"Reinstated {new_name} successfully!")
@@ -1,321 +0,0 @@
from ast import literal_eval
from typing import Literal
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 # type: ignore
from qtpy.QtWidgets import (
QApplication,
QDialog,
QDialogButtonBox,
QLabel,
QStackedLayout,
QVBoxLayout,
QWidget,
)
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
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
CommunicateConfigAction,
)
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
DeviceConfigForm,
)
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
logger = bec_logger.logger
_StdBtn = QDialogButtonBox.StandardButton
def _try_literal_eval(value: str):
if value == "":
return ""
try:
return literal_eval(value)
except SyntaxError as e:
raise ValueError(f"Entered config value {value} is not a valid python value!") from e
class DeviceConfigDialog(QDialog):
RPC = False
applied = Signal()
accepted_data = Signal(dict)
def __init__(
self, *, parent=None, class_deviceconfig_item: type[DynamicFormItem] | None = None, **kwargs
):
self._initial_config = {}
self._class_deviceconfig_item = class_deviceconfig_item
super().__init__(parent=parent, **kwargs)
self._container = QStackedLayout()
self._container.setStackingMode(QStackedLayout.StackingMode.StackAll)
self._layout = QVBoxLayout()
self._data = {}
self._add_form()
self._add_overlay()
self._add_buttons()
self.setWindowTitle("Add new device")
self.setLayout(self._container)
self._overlay_widget.setVisible(False)
self._form._validity.setVisible(True)
self._connect_form()
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()
self._form_widget.setLayout(self._layout)
self._form = DeviceConfigForm()
self._layout.addWidget(self._form)
self._container.addWidget(self._form_widget)
def _add_overlay(self):
self._overlay_widget = QWidget()
self._overlay_widget.setStyleSheet("background-color:rgba(128,128,128,128);")
self._overlay_widget.setAutoFillBackground(True)
self._overlay_layout = QVBoxLayout()
self._overlay_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._overlay_widget.setLayout(self._overlay_layout)
self._spinner = SpinnerWidget(parent=self)
self._spinner.setMinimumSize(QSize(100, 100))
self._overlay_layout.addWidget(self._spinner)
self._container.addWidget(self._overlay_widget)
def _add_buttons(self):
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 _fill_form(self):
self._form.set_data(DeviceConfigModel.model_validate(self._initial_config))
def updated_config(self):
new_config = self._form.get_form_data()
diff = {
k: v for k, v in new_config.items() if self._initial_config.get(k) != new_config.get(k)
}
if self._initial_config.get("deviceConfig") in [{}, None] and new_config.get(
"deviceConfig"
) in [{}, None]:
diff.pop("deviceConfig", None)
if diff.get("deviceConfig") is not None:
# TODO: special cased in some parts of device manager but not others, should
# be removed in config update as with below issue
diff["deviceConfig"].pop("device_access", None)
# TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved
diff["deviceConfig"] = {
k: _try_literal_eval(str(v)) for k, v in diff["deviceConfig"].items() if k != ""
}
# Due to above issues, if deviceConfig changes we must remove and recreate the device - so we need the whole config
if "deviceConfig" in diff:
new_config["deviceConfig"] = diff["deviceConfig"]
return new_config
return diff
@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 [_StdBtn.Apply, _StdBtn.Ok]]:
button.setEnabled(valid)
button.setToolTip(self._form._validity_message.toolTip())
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()
self.applied.emit()
@SafeSlot(popup_error=True)
def accept(self):
self._process_action()
return super().accept()
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 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}!"
)
self._proc_device_config_change(updated_config)
else:
if updated_config == {}:
logger.info("No changes made to device config")
return
self._proc_device_config_change(updated_config)
def _proc_device_config_change(self, config: dict):
logger.info(f"Sending request to {self._action} device config: {config}")
self._start_waiting_display()
communicate_update = CommunicateConfigAction(
self._config_helper, self._device, config, self._action
)
communicate_update.signals.error.connect(self.update_error)
communicate_update.signals.done.connect(self.update_done)
self._q_threadpool.start(communicate_update)
@SafeSlot()
def update_done(self):
self._stop_waiting_display()
if self._action == "update":
self._fetch_config()
self._fill_form()
@SafeSlot(Exception, popup_error=True)
def update_error(self, e: Exception):
self._stop_waiting_display()
if self._action == "update":
self._fetch_config()
self._fill_form()
raise e
def _start_waiting_display(self):
self._overlay_widget.setVisible(True)
self._spinner.start()
def _stop_waiting_display(self):
self._overlay_widget.setVisible(False)
self._spinner.stop()
def main(): # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication, QLineEdit, QPushButton, QWidget
from bec_widgets.utils.colors import apply_theme
dialog = None
app = QApplication(sys.argv)
apply_theme("light")
widget = QWidget()
widget.setLayout(layout := QVBoxLayout())
device = QLineEdit()
layout.addWidget(device)
def _destroy_dialog(*_):
nonlocal dialog
dialog = None
def accept(*args):
logger.success(f"submitted device config form {dialog} {args}")
_destroy_dialog()
def _show_dialog(*_):
nonlocal dialog
if dialog is None:
kwargs = {"device": dev} if (dev := device.text()) else {"action": "add"}
dialog = DirectUpdateDeviceConfigDialog(**kwargs) # type: ignore
dialog.accepted.connect(accept)
dialog.rejected.connect(_destroy_dialog)
dialog.open()
button = QPushButton("Show device dialog")
layout.addWidget(button)
button.clicked.connect(_show_dialog)
widget.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()
@@ -1,76 +0,0 @@
from __future__ import annotations
from functools import partial
from bec_lib.atlas_models import Device as DeviceConfigModel
from pydantic import BaseModel
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, PydanticModelFormItem
from bec_widgets.utils.forms_from_types.items import (
DEFAULT_WIDGET_TYPES,
BoolFormItem,
BoolToggleFormItem,
DictFormItem,
FormItemSpec,
)
class DeviceConfigForm(PydanticModelForm):
RPC = False
PLUGIN = False
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,
pretty_display=pretty_display,
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,
)
pred, _ = self._widget_types["dict"]
self._widget_types["dict"] = pred, self._custom_device_config_item
self._validity.setVisible(True)
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()
self.setStyleSheet(styles.pretty_display_theme(theme))
self._validity.setVisible(False)
def get_form_data(self):
"""Get the entered metadata as a dict."""
return self._md_schema.model_validate(super().get_form_data()).model_dump()
def set_schema(self, schema: type[BaseModel]):
if not issubclass(schema, DeviceConfigModel):
raise TypeError("This class doesn't support changing the schema")
super().set_schema(schema)
def set_data(self, data: DeviceConfigModel): # type: ignore # This class locks the type
super().set_data(data)
@@ -2,6 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import yaml
from bec_lib.atlas_models import Device as DeviceConfigModel
from bec_lib.config_helper import ConfigHelper
from bec_lib.devicemanager import DeviceContainer
@@ -9,20 +10,26 @@ from bec_lib.logger import bec_logger
from bec_lib.messages import ConfigAction
from bec_qthemes import material_icon
from qtpy.QtCore import QMimeData, QSize, Qt, QThreadPool, Signal
from qtpy.QtGui import QDrag
from qtpy.QtWidgets import QApplication, QHBoxLayout, QTabWidget, QToolButton, QVBoxLayout, QWidget
from qtpy.QtGui import QDrag, QFontDatabase
from qtpy.QtWidgets import (
QApplication,
QHBoxLayout,
QLabel,
QSizePolicy,
QTabWidget,
QToolButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs import (
DeviceFormDialog,
)
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
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 (
DirectUpdateDeviceConfigDialog,
)
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
DeviceConfigForm,
)
from bec_widgets.widgets.services.device_browser.device_item.device_signal_display import (
SignalDisplay,
)
@@ -33,6 +40,42 @@ if TYPE_CHECKING: # pragma: no cover
logger = bec_logger.logger
# Changing any of these means the device server has to remove and recreate the device
_RECREATE_KEYS = {"name", "deviceClass", "deviceConfig"}
def config_to_yaml(config: DeviceConfigModel) -> str:
"""Format a device config as YAML for display, with the name and class first.
Args:
config (DeviceConfigModel): the device config to format.
Returns:
str: the YAML text.
"""
data = config.model_dump(mode="json", exclude_none=True)
ordered = {key: data.pop(key) for key in ("name", "deviceClass") if key in data}
return yaml.safe_dump({**ordered, **data}, sort_keys=False, default_flow_style=False)
def config_change(current: dict, edited: dict) -> dict:
"""Work out what to send to BEC for an edit of a device config.
Args:
current (dict): the config of the device as it is in BEC.
edited (dict): the config from the edit dialog. Keys it leaves out keep their current value.
Returns:
dict: an empty dict if nothing changed, the whole config if the device must be recreated,
otherwise only the changed entries.
"""
current_dump = DeviceConfigModel.model_validate(current).model_dump()
new_dump = DeviceConfigModel.model_validate({**current, **edited}).model_dump()
diff = {k: v for k, v in new_dump.items() if current_dump.get(k) != v}
if _RECREATE_KEYS & diff.keys():
return new_dump
return diff
class DeviceItem(ExpandableGroupFrame):
RPC = False
@@ -91,16 +134,35 @@ class DeviceItem(ExpandableGroupFrame):
@SafeSlot()
def _create_edit_dialog(self):
dialog = DirectUpdateDeviceConfigDialog(
parent=self,
device=self.device,
config_helper=self._config_helper,
threadpool=self._q_threadpool,
)
dialog.accepted.connect(self._reload_config)
dialog.applied.connect(self._reload_config)
dialog = DeviceFormDialog(parent=self, add_btn_text="Apply Changes")
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
dialog.setWindowTitle(f"Edit config for: {self.device}")
dialog.set_device_config(self._current_config())
dialog.accepted_data.connect(self._apply_edited_config)
dialog.open()
@SafeSlot(dict, int, int, str, str, popup_error=True)
def _apply_edited_config(self, config: dict, *_):
change = config_change(self._current_config(), config)
if not change:
logger.info(f"No changes made to the config of {self.device}")
return
new_name = change.get("name", self.device)
if new_name != self.device and new_name in self.dev:
raise ValueError(f"Can't rename {self.device}: a device named {new_name} exists!")
logger.info(f"Sending request to update device config of {self.device}: {change}")
updater = CommunicateConfigAction(self._config_helper, self.device, change, "update")
updater.signals.error.connect(self._update_error)
updater.signals.done.connect(
self._deletion_done if new_name != self.device else self._reload_config
)
self._q_threadpool.start(updater)
@SafeSlot(Exception, popup_error=True)
def _update_error(self, e: Exception):
self._reload_config()
raise e
@SafeSlot()
def _delete_device(self):
self.expanded = False
@@ -122,18 +184,21 @@ class DeviceItem(ExpandableGroupFrame):
def switch_expanded_state(self):
if not self.expanded and not self._expanded_first_time:
self._expanded_first_time = True
self.form = DeviceConfigForm(parent=self, pretty_display=True)
self._form_page_layout.addWidget(self.form)
self.config_view = QLabel(parent=self)
self.config_view.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
self.config_view.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.config_view.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
# long values are clipped instead of widening the whole device list
self.config_view.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
self._form_page_layout.addWidget(self.config_view)
self.signals = SignalDisplay(parent=self, device=self.device)
self._signal_page_layout.addWidget(self.signals)
self._reload_config()
self.broadcast_size_hint.emit(self.sizeHint())
super().switch_expanded_state()
if self._expanded_first_time:
self.form.adjustSize()
self.config_view.adjustSize()
self.updateGeometry()
if self._expanded:
self.form.set_pretty_display_theme()
self.adjustSize()
self.broadcast_size_hint.emit(self.sizeHint())
@@ -149,14 +214,17 @@ class DeviceItem(ExpandableGroupFrame):
@SafeSlot(popup_error=True)
def _reload_config(self, *_):
self.set_display_config(self.dev[self.device]._config)
self.set_display_config(self._current_config())
def _current_config(self) -> dict:
return self.dev[self.device]._config # pylint: disable=protected-access
def set_display_config(self, config_dict: dict):
"""Set the displayed information from a device config dict, which must conform to the
bec_lib.atlas_models.Device config model."""
self._data = DeviceConfigModel.model_validate(config_dict)
if self._expanded_first_time:
self.form.set_data(self._data)
self.config_view.setText(config_to_yaml(self._data))
def mousePressEvent(self, event: QMouseEvent) -> None:
super().mousePressEvent(event)
@@ -187,9 +255,6 @@ if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
DeviceConfigForm,
)
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
app = QApplication(sys.argv)
+152 -8
View File
@@ -2,14 +2,19 @@ from typing import TYPE_CHECKING
from unittest import mock
import pytest
from bec_lib.atlas_models import Device as DeviceConfigModel
from bec_lib.device import Device
from qtpy.QtCore import QPoint, Qt
from qtpy.QtWidgets import QTabWidget
from qtpy.QtWidgets import QDialog, QLabel, QTabWidget
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs import (
DeviceFormDialog,
)
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.services.device_browser.device_browser import DeviceBrowser
from bec_widgets.widgets.services.device_browser.device_item.device_config_form import (
DeviceConfigForm,
from bec_widgets.widgets.services.device_browser.device_item.device_item import (
config_change,
config_to_yaml,
)
from bec_widgets.widgets.services.device_browser.device_item.device_signal_display import (
SignalDisplay,
@@ -93,13 +98,12 @@ def test_device_item_expansion(device_browser, qtbot):
tab_widget: QTabWidget = widget._contents.layout().itemAt(0).widget()
qtbot.waitUntil(lambda: tab_widget.widget(0) is not None, timeout=100)
qtbot.waitUntil(
lambda: isinstance(tab_widget.widget(0).layout().itemAt(0).widget(), DeviceConfigForm),
timeout=100,
lambda: isinstance(tab_widget.widget(0).layout().itemAt(0).widget(), QLabel), timeout=100
)
form = tab_widget.widget(0).layout().itemAt(0).widget()
config_view: QLabel = 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() == "aptrx", timeout=500)
qtbot.waitUntil(lambda: config_view.text().startswith("name: aptrx\n"), timeout=500)
assert "deviceClass: ophyd_devices.SimPositioner" in config_view.text()
qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton)
assert not widget.expanded
@@ -199,3 +203,143 @@ def test_device_browser_cleanup_detaches_filter_proxy(device_browser: DeviceBrow
update_filter.assert_not_called()
assert not proxy.timer.timer.isActive()
assert proxy.args is None
_BASIC_CONFIG = {
"name": "test_device",
"enabled": True,
"deviceClass": "TestDevice",
"readoutPriority": "monitored",
"deviceConfig": {"param": 1},
}
@pytest.mark.parametrize(
["edited", "expected_keys"],
[
({}, set()),
(_BASIC_CONFIG, set()),
({"readOnly": False}, set()),
({"readOnly": True}, {"readOnly"}),
({"readOnly": True, "description": "test"}, {"readOnly", "description"}),
({"deviceTags": ["tag"]}, {"deviceTags"}),
({"deviceConfig": {"param": 2}}, "all"),
({"deviceClass": "OtherDevice"}, "all"),
({"name": "renamed"}, "all"),
],
)
def test_config_change(edited, expected_keys):
change = config_change(_BASIC_CONFIG, edited)
if expected_keys == "all":
assert change == DeviceConfigModel.model_validate({**_BASIC_CONFIG, **edited}).model_dump()
else:
assert set(change) == expected_keys
def test_config_to_yaml_puts_name_and_class_first():
text = config_to_yaml(DeviceConfigModel.model_validate(_BASIC_CONFIG))
assert text.splitlines()[:2] == ["name: test_device", "deviceClass: TestDevice"]
assert " param: 1" in text
def _opened_dialog(open_dialog) -> DeviceFormDialog:
opened = []
with mock.patch.object(QDialog, "open", lambda dialog: opened.append(dialog)):
open_dialog()
(dialog,) = opened
assert isinstance(dialog, DeviceFormDialog)
return dialog
def _first_item(device_browser) -> "DeviceItem":
return device_browser.dev_list.itemWidget(device_browser.dev_list.itemAt(0, 0))
def _run_inline(widget):
widget._q_threadpool = mock.MagicMock()
widget._q_threadpool.start.side_effect = lambda runnable: runnable.run()
widget._config_helper = mock.MagicMock()
def test_add_dialog_sends_add_request(device_browser, qtbot):
dialog = _opened_dialog(device_browser._create_add_dialog)
_run_inline(device_browser)
config = {**_BASIC_CONFIG, "name": "new_device"}
dialog.accepted_data.emit(config, 0, 0, "", "")
device_browser._config_helper.send_config_request.assert_called_once_with(
action="add", config={"new_device": config}, wait_for_response=False
)
dialog.close()
def test_add_dialog_refuses_existing_name(device_browser, qtbot):
dialog = _opened_dialog(device_browser._create_add_dialog)
_run_inline(device_browser)
with mock.patch(
"bec_widgets.utils.error_popups._ErrorPopupUtility.custom_exception_hook"
) as mock_hook:
dialog.accepted_data.emit({**_BASIC_CONFIG, "name": "samx"}, 0, 0, "", "")
assert isinstance(mock_hook.call_args[0][1], ValueError)
device_browser._config_helper.send_config_request.assert_not_called()
dialog.close()
def test_edit_dialog_is_prefilled(device_browser, qtbot):
widget = _first_item(device_browser)
dialog = _opened_dialog(widget._create_edit_dialog)
config = dialog._device_config_template.get_config_fields()
assert config["name"] == widget.device
assert config["deviceClass"] == device_browser.dev[widget.device]._config["deviceClass"]
dialog.close()
def test_edit_dialog_sends_only_changes(device_browser, qtbot):
widget = _first_item(device_browser)
dialog = _opened_dialog(widget._create_edit_dialog)
_run_inline(widget)
config = {**dialog._device_config_template.get_config_fields(), "readOnly": True}
dialog.accepted_data.emit(config, 0, 0, "", "")
widget._config_helper.send_config_request.assert_called_once_with(
action="update", config={widget.device: {"readOnly": True}}, wait_for_response=False
)
dialog.close()
def test_edit_dialog_without_changes_sends_nothing(device_browser, qtbot):
widget = _first_item(device_browser)
dialog = _opened_dialog(widget._create_edit_dialog)
_run_inline(widget)
dialog.accepted_data.emit(dialog._device_config_template.get_config_fields(), 0, 0, "", "")
widget._config_helper.send_config_request.assert_not_called()
dialog.close()
def test_edit_dialog_rename_recreates_device(device_browser, qtbot):
widget = _first_item(device_browser)
old_name = widget.device
dialog = _opened_dialog(widget._create_edit_dialog)
_run_inline(widget)
config = {**dialog._device_config_template.get_config_fields(), "name": "renamed_device"}
dialog.accepted_data.emit(config, 0, 0, "", "")
calls = widget._config_helper.send_config_request.call_args_list
assert [(c.kwargs["action"], list(c.kwargs["config"])) for c in calls] == [
("remove", [old_name]),
("add", ["renamed_device"]),
]
qtbot.waitUntil(lambda: old_name not in device_browser.dev_list._item_dict, timeout=1000)
dialog.close()
def test_edit_dialog_rename_refuses_existing_name(device_browser, qtbot):
widget = _first_item(device_browser)
other = next(name for name in device_browser.dev if name != widget.device)
dialog = _opened_dialog(widget._create_edit_dialog)
_run_inline(widget)
config = {**dialog._device_config_template.get_config_fields(), "name": other}
with mock.patch(
"bec_widgets.utils.error_popups._ErrorPopupUtility.custom_exception_hook"
) as mock_hook:
dialog.accepted_data.emit(config, 0, 0, "", "")
assert isinstance(mock_hook.call_args[0][1], ValueError)
widget._config_helper.send_config_request.assert_not_called()
dialog.close()
@@ -1,169 +0,0 @@
from unittest.mock import MagicMock, patch
import pytest
from bec_lib.atlas_models import Device as DeviceConfigModel
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 (
DirectUpdateDeviceConfigDialog,
_try_literal_eval,
)
_BASIC_CONFIG = {
"name": "test_device",
"enabled": True,
"deviceClass": "TestDevice",
"readoutPriority": "monitored",
}
@pytest.fixture
def mock_client():
mock_device = MagicMock(_config=DeviceConfigModel.model_validate(_BASIC_CONFIG).model_dump())
mock_client = MagicMock()
mock_client.device_manager.devices = {"test_device": mock_device}
return mock_client
@pytest.fixture
def update_dialog(mock_client, qtbot):
"""Fixture to create a DeviceConfigDialog instance."""
update_dialog = DirectUpdateDeviceConfigDialog(
device="test_device", config_helper=MagicMock(), client=mock_client
)
qtbot.addWidget(update_dialog)
return update_dialog
@pytest.fixture
def add_dialog(mock_client, qtbot):
"""Fixture to create a DeviceConfigDialog instance."""
add_dialog = DirectUpdateDeviceConfigDialog(
device=None, config_helper=MagicMock(), client=mock_client, action="add"
)
qtbot.addWidget(add_dialog)
return add_dialog
def test_initialization(update_dialog):
assert update_dialog._device == "test_device"
assert update_dialog._container.count() == 2
def test_fill_form(update_dialog):
with patch.object(update_dialog._form, "set_data") as mock_set_data:
update_dialog._fill_form()
mock_set_data.assert_called_once_with(DeviceConfigModel.model_validate(_BASIC_CONFIG))
def test_updated_config(update_dialog):
"""Test that updated_config returns the correct changes."""
update_dialog._initial_config = {"key1": "value1", "key2": "value2"}
with patch.object(
update_dialog._form, "get_form_data", return_value={"key1": "value1", "key2": "new_value"}
):
updated = update_dialog.updated_config()
assert updated == {"key2": "new_value"}
def test_apply(update_dialog):
with patch.object(update_dialog, "_process_action") as mock_process_update:
update_dialog.apply()
mock_process_update.assert_called_once()
def test_accept(update_dialog):
with (
patch.object(update_dialog, "_process_action") as mock_process_update,
patch("qtpy.QtWidgets.QDialog.accept") as mock_parent_accept,
):
update_dialog.accept()
mock_process_update.assert_called_once()
mock_parent_accept.assert_called_once()
def test_waiting_display(update_dialog, qtbot):
with (
patch.object(update_dialog._spinner, "start") as mock_spinner_start,
patch.object(update_dialog._spinner, "stop") as mock_spinner_stop,
):
update_dialog.show()
update_dialog._start_waiting_display()
qtbot.waitUntil(update_dialog._overlay_widget.isVisible, timeout=100)
mock_spinner_start.assert_called_once()
mock_spinner_stop.assert_not_called()
update_dialog._stop_waiting_display()
qtbot.waitUntil(lambda: not update_dialog._overlay_widget.isVisible(), timeout=100)
mock_spinner_stop.assert_called_once()
def test_update_cycle(update_dialog):
update = {"enabled": False, "readoutPriority": "baseline", "deviceTags": {"tag"}}
def _mock_send(action="update", config=None, wait_for_response=True, timeout_s=None):
device = update_dialog.client.device_manager.devices["test_device"]
device._config = {**device._config, **config["test_device"]} # type: ignore
update_dialog._q_threadpool = MagicMock()
update_dialog._q_threadpool.start.side_effect = lambda runnable: runnable.run()
update_dialog._config_helper.send_config_request = MagicMock(side_effect=_mock_send)
for item in update_dialog._form.enumerate_form_widgets():
if (val := update.get(item.label.property("_model_field_name"))) is not None:
item.widget.setValue(val)
assert update_dialog.updated_config() == update
update_dialog.apply()
update_dialog._q_threadpool.start.assert_called_once()
update_dialog._config_helper.send_config_request.assert_called_with(
action="update", config={"test_device": update}, wait_for_response=False
)
@pytest.mark.parametrize(
["changes", "result"],
[
({}, {}),
({"readOnly": True}, {"readOnly": True}),
({"readOnly": False}, {}),
({"readOnly": True, "description": "test"}, {"readOnly": True, "description": "test"}),
(
{"deviceConfig": {"param1": "'val1'"}},
DeviceConfigModel(
enabled=True,
deviceClass="TestDevice",
deviceConfig={"param1": "val1"},
readoutPriority="monitored",
name="test_device",
).model_dump(),
),
({"deviceConfig": {}}, {}),
],
)
def test_update_with_modified_deviceconfig(update_dialog, changes, result):
for k, v in changes.items():
update_dialog._form.widget_dict[k].setValue(v)
assert update_dialog.updated_config() == result
def test_add_form_init_without_name(add_dialog, qtbot):
assert (name_widget := add_dialog._form.widget_dict.get("name")) is not None
assert isinstance(name_widget, StrFormItem)
assert name_widget.getValue() is None
def test_add_form_validates_and_disables_on_init(add_dialog, qtbot):
assert (ok_button := add_dialog.button_box.button(QDialogButtonBox.Ok)) is not None
assert isinstance(ok_button, QPushButton)
assert not ok_button.isEnabled()
def test_try_literal_eval():
assert _try_literal_eval("") == ""
assert _try_literal_eval("[1, 2, 3]") == [1, 2, 3]
assert _try_literal_eval('"[,,]"') == "[,,]"
with pytest.raises(ValueError) as e:
_try_literal_eval("[,,]")
assert e.match("Entered config value [,,]")