mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-31 16:41:27 +01:00
refactor(device_browser): edit devices with the device manager form dialog
This commit is contained in:
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)
|
||||
|
||||
@@ -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 [,,]")
|
||||
Reference in new issue
Block a user