From 12a454f76253f0b221d6dbfe8b71d28efabf7260 Mon Sep 17 00:00:00 2001 From: appel_c Date: Tue, 30 Sep 2025 08:18:16 +0200 Subject: [PATCH] refactor: cleanup --- .../device_manager_view.py | 9 +- .../device_manager_widget.py | 16 ++- .../components/device_table_view.py | 32 ++--- .../components/dm_config_view.py | 21 ++- .../components/dm_docstring_view.py | 122 ++++++++-------- .../components/dm_ophyd_test.py | 135 +++++++++--------- 6 files changed, 181 insertions(+), 154 deletions(-) diff --git a/bec_widgets/examples/device_manager_view/device_manager_view.py b/bec_widgets/examples/device_manager_view/device_manager_view.py index 2f35b3d4..90fd4920 100644 --- a/bec_widgets/examples/device_manager_view/device_manager_view.py +++ b/bec_widgets/examples/device_manager_view/device_manager_view.py @@ -96,6 +96,7 @@ class DeviceManagerView(BECWidget, QWidget): 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) # Available Resources Widget @@ -277,7 +278,9 @@ class DeviceManagerView(BECWidget, QWidget): # Rerun validation rerun_validation = MaterialIconAction( - icon_name="checklist", parent=self, tooltip="Run device validation on selected devices" + icon_name="checklist", + parent=self, + tooltip="Run device validation with 'connect' on selected devices", ) rerun_validation.action.triggered.connect(self._rerun_validation_action) self.toolbar.components.add_safe("rerun_validation", rerun_validation) @@ -433,8 +436,8 @@ class DeviceManagerView(BECWidget, QWidget): @SafeSlot() def _rerun_validation_action(self): """Action for the 'rerun_validation' action to rerun validation on selected devices.""" - # Implement the logic to rerun validation on selected devices - reply = self._coming_soon() + configs = self.device_table_view.table.selected_configs() + self.ophyd_test_view.change_device_configs(configs, True, True) ####### Default view has to be done with setting up splitters ######## def set_default_view(self, horizontal_weights: list, vertical_weights: list): diff --git a/bec_widgets/examples/device_manager_view/device_manager_widget.py b/bec_widgets/examples/device_manager_view/device_manager_widget.py index 9d4c9c80..becd44b7 100644 --- a/bec_widgets/examples/device_manager_view/device_manager_widget.py +++ b/bec_widgets/examples/device_manager_view/device_manager_widget.py @@ -100,10 +100,24 @@ if __name__ == "__main__": 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) - device_manager.show() + 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 diff --git a/bec_widgets/widgets/control/device_manager/components/device_table_view.py b/bec_widgets/widgets/control/device_manager/components/device_table_view.py index 130131f4..3dc804ca 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table_view.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table_view.py @@ -7,22 +7,13 @@ import json from contextlib import contextmanager from functools import partial from typing import TYPE_CHECKING, Any, Iterable, List -from unittest.mock import MagicMock, patch from uuid import uuid4 from bec_lib.logger import bec_logger from bec_qthemes import material_icon from qtpy import QtCore, QtGui, QtWidgets -from qtpy.QtCore import QModelIndex, QPersistentModelIndex, QPoint, QRect, QSize, Qt, QTimer -from qtpy.QtWidgets import ( - QAbstractItemView, - QHeaderView, - QMessageBox, - QStyle, - QStyleOption, - QStyleOptionViewItem, - QWidget, -) +from qtpy.QtCore import QModelIndex, QPersistentModelIndex, Qt, QTimer +from qtpy.QtWidgets import QAbstractItemView, QHeaderView, QMessageBox from thefuzz import fuzz from bec_widgets.utils.bec_signal_proxy import BECSignalProxy @@ -91,8 +82,8 @@ class CenterCheckBoxDelegate(CustomDisplayDelegate): def __init__(self, parent=None, colors=None): super().__init__(parent) - self._colors: AccentColors = colors if colors else get_accent_colors() # type: ignore - _icon = partial(material_icon, size=(16, 16), color=self._colors.default, filled=True) + colors: AccentColors = colors if colors else get_accent_colors() # type: ignore + _icon = partial(material_icon, size=(16, 16), color=colors.default, filled=True) self._icon_checked = _icon("check_box") self._icon_unchecked = _icon("check_box_outline_blank") @@ -122,12 +113,12 @@ class DeviceValidatedDelegate(CustomDisplayDelegate): def __init__(self, parent=None, colors=None): super().__init__(parent) - self._colors = colors if colors else get_accent_colors() + colors = colors if colors else get_accent_colors() _icon = partial(material_icon, icon_name="circle", size=(12, 12), filled=True) self._icons = { - ValidationStatus.PENDING: _icon(color=self._colors.default), - ValidationStatus.VALID: _icon(color=self._colors.success), - ValidationStatus.FAILED: _icon(color=self._colors.emergency), + ValidationStatus.PENDING: _icon(color=colors.default), + ValidationStatus.VALID: _icon(color=colors.success), + ValidationStatus.FAILED: _icon(color=colors.emergency), } def apply_theme(self, theme: str | None = None): @@ -750,6 +741,7 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget): ########### Slot API ################# ###################################### + # TODO RESIZING IS not working as it should be !! @SafeSlot() def _on_table_resized(self, *args): """Handle changes to the table column resizing.""" @@ -870,8 +862,8 @@ if __name__ == "__main__": button.clicked.connect(_button_clicked) # pylint: disable=protected-access config = window.client.device_manager._get_redis_device_config() - names = [cfg.pop("name") for cfg in config] - config_dict = {name: cfg for name, cfg in zip(names, config)} - window.set_device_config(config_dict) + # names = [cfg.pop("name") for cfg in config] + # config_dict = {name: cfg for name, cfg in zip(names, config)} + window.set_device_config(config) widget.show() sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_config_view.py b/bec_widgets/widgets/control/device_manager/components/dm_config_view.py index f13f9a76..245080f3 100644 --- a/bec_widgets/widgets/control/device_manager/components/dm_config_view.py +++ b/bec_widgets/widgets/control/device_manager/components/dm_config_view.py @@ -9,7 +9,6 @@ from bec_lib.logger import bec_logger from qtpy import QtCore, QtWidgets from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.colors import get_accent_colors, get_theme_palette from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget @@ -78,6 +77,24 @@ if __name__ == "__main__": 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() - config_view.show() + 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_()) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py b/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py index a0f135b4..cd617d8f 100644 --- a/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py +++ b/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py @@ -4,6 +4,7 @@ from __future__ import annotations import inspect import re +import textwrap import traceback from bec_lib.logger import bec_logger @@ -26,6 +27,45 @@ except ImportError: 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: + doc = 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) @@ -36,60 +76,9 @@ class DocstringView(QtWidgets.QTextEdit): self.setEnabled(False) return - def _format_docstring(self, doc: str | None) -> str: - if not doc: - return "No docstring available." - - # Escape HTML - doc = doc.replace("&", "&").replace("<", "<").replace(">", ">") - - # Remove leading/trailing blank lines from the entire docstring - lines = [line.rstrip() for line in doc.splitlines()] - while lines and lines[0].strip() == "": - lines.pop(0) - while lines and lines[-1].strip() == "": - lines.pop() - doc = "\n".join(lines) - - # Improved regex: match section header + all following indented lines - section_regex = re.compile( - r"(?m)^(Parameters|Args|Returns|Examples|Attributes|Raises)\b(?:\n([ \t]+.*))*", - re.MULTILINE, - ) - - def strip_section(match: re.Match) -> str: - # Capture all lines in the match - block = match.group(0) - lines = block.splitlines() - # Remove leading/trailing empty lines within the section - lines = [line for line in lines if line.strip() != ""] - return "\n".join(lines) - - doc = section_regex.sub(strip_section, doc) - - # Highlight section titles - doc = re.sub( - r"(?m)^(Parameters|Args|Returns|Examples|Attributes|Raises)\b", r"\1", doc - ) - - # Convert indented blocks to
 and strip leading/trailing newlines
-        def pre_block(match: re.Match) -> str:
-            text = match.group(0).strip("\n")
-            return f"
{text}
" - - doc = re.sub(r"(?m)(?:\n[ \t]+.*)+", pre_block, doc) - - # Replace remaining newlines with
and collapse multiple
- doc = doc.replace("\n", "
") - doc = re.sub(r"(
)+", r"
", doc) - doc = doc.strip("
") - - return f"
{doc}
" - def _set_text(self, text: str): self.setReadOnly(False) self.setMarkdown(text) - # self.setHtml(self._format_docstring(text)) self.setReadOnly(True) @SafeSlot(list) @@ -102,17 +91,15 @@ class DocstringView(QtWidgets.QTextEdit): @SafeSlot(str) def set_device_class(self, device_class_str: str) -> None: - docstring = "" if not READY_TO_VIEW: return try: module_cls = get_plugin_class(device_class_str, [ophyd_devices, ophyd]) - docstring = inspect.getdoc(module_cls) - self._set_text(docstring or "No docstring available.") + markdown = docstring_to_markdown(module_cls) + self._set_text(markdown) except Exception: - content = traceback.format_exc() - logger.error(f"Error retrieving docstring for {device_class_str}: {content}") - self._set_text(f"Error retrieving docstring for {device_class_str}") + logger.exception("Error retrieving docstring") + self._set_text(f"*Error retrieving docstring for `{device_class_str}`*") if __name__ == "__main__": @@ -121,7 +108,26 @@ if __name__ == "__main__": 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") - config_view.show() + 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_()) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py index a8e4ee9a..fe40cfaa 100644 --- a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py +++ b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py @@ -83,23 +83,23 @@ class DeviceTester(QtCore.QRunnable): continue with self._lock: if len(self._pending_queue) > 0: - item, cfg = self._pending_queue.pop() + item, cfg, connect = self._pending_queue.pop() self._active.add(item) - fut = self._test_executor.submit(self._run_test, item, {item: cfg}) + 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]]): + 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) -> tuple[str, bool, str]: + 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=False) + results = tester.run_with_list_output(connect=connect) return name, results[0].success, results[0].message def _safe_check_and_clear(self): @@ -164,7 +164,6 @@ class ValidationListItem(QtWidgets.QWidget): def _start_spinner(self): """Start the spinner animation.""" self._spinner.start() - QtWidgets.QApplication.processEvents() def _stop_spinner(self): """Stop the spinner animation.""" @@ -197,6 +196,8 @@ class DMOphydTest(BECWidget, QtWidgets.QWidget): # 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) @@ -208,18 +209,18 @@ class DMOphydTest(BECWidget, QtWidgets.QWidget): self.tester.signals.device_validated.connect(self._on_device_validated) QtCore.QThreadPool.globalInstance().start(self.tester) self._device_list_items: dict[str, QtWidgets.QListWidgetItem] = {} - self._thread_pool = QtCore.QThreadPool(maxThreadCount=1) + # 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(4) + 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() - self._setup_textbox_ui() def _setup_list_ui(self): """Setup the list UI.""" @@ -229,15 +230,11 @@ class DMOphydTest(BECWidget, QtWidgets.QWidget): # Connect signals self._list_widget.currentItemChanged.connect(self._on_current_item_changed) - def _setup_textbox_ui(self): - """Setup the text box UI.""" - self._text_box = QtWidgets.QTextEdit(self) - self._text_box.setReadOnly(True) - self._text_box.setFocusPolicy(QtCore.Qt.NoFocus) - self.splitter.addWidget(self._text_box) - - @SafeSlot(dict) - def change_device_configs(self, device_configs: list[dict[str, Any]], added: bool) -> None: + @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: @@ -250,7 +247,7 @@ class DMOphydTest(BECWidget, QtWidgets.QWidget): continue if self.tester: self._add_device(name, cfg) - self.tester.submit([(name, cfg)]) + self.tester.submit([(name, cfg, connect)]) continue if name not in self._device_list_items: continue @@ -314,62 +311,39 @@ class DMOphydTest(BECWidget, QtWidgets.QWidget): widget: ValidationListItem = self._list_widget.itemWidget(current) if widget: try: - formatted_html = self._format_validation_message(widget.validation_msg) - self._text_box.setHtml(formatted_html) + 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: {e}") - self._text_box.setPlainText(widget.validation_msg) + 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_validation_message(self, raw_msg: str) -> str: + def _format_markdown_text(self, device_name: str, raw_msg: str) -> str: """Simple HTML formatting for validation messages, wrapping text naturally.""" if not raw_msg.strip(): - return "Validation in progress..." + return f"### Validation in progress for {device_name}... \n\n" if raw_msg == "Validation in progress...": - return "Validation in progress..." + return f"### Validation in progress for {device_name}... \n\n" - raw_msg = escape(raw_msg) + m = re.search(r"ERROR:\s*([^\s]+)\s+is not valid:\s*(.+?errors?)", raw_msg) + device, summary = m.group(1), m.group(2) + lines = [f"## Error for '{device}'", f"'{device}' is not valid: {summary}"] - # Split into lines - lines = raw_msg.splitlines() - summary = lines[0] if lines else "Validation Result" - rest = "\n".join(lines[1:]).strip() - - # Split traceback / final ERROR - tb_match = re.search(r"(Traceback.*|ERROR:.*)$", rest, re.DOTALL | re.MULTILINE) - if tb_match: - main_text = rest[: tb_match.start()].strip() - error_detail = tb_match.group().strip() - else: - main_text = rest - error_detail = "" - - # Highlight field names in orange (simple regex for word: Field) - main_text_html = re.sub( - r"(\b\w+\b)(?=: Field required)", - r'\1', - main_text, - ) - # Wrap in div for monospace, allowing wrapping - main_text_html = ( - f'
{main_text_html}
' if main_text_html else "" + # Find each field block: \n\n Field required ... + field_pat = re.compile( + r"\n(?P\w+)\n\s+(?PField required.*?(?=\n\w+\n|$))", re.DOTALL ) - # Traceback / error in red - error_html = ( - f'
{error_detail}
' - if error_detail - else "" - ) + for m in field_pat.finditer(raw_msg): + field = m.group("field") + rest = m.group("rest").rstrip() + lines.append(f"### {field}") + lines.append(rest) - # Summary at top, dark red - html = ( - f'
' - f'
{summary}
' - f"{main_text_html}" - f"{error_html}" - f"
" - ) - return html + return "\n".join(lines) def validation_running(self): return self._device_list_items != {} @@ -382,6 +356,7 @@ class DMOphydTest(BECWidget, QtWidgets.QWidget): 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.""" @@ -404,12 +379,32 @@ if __name__ == "__main__": 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() - config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/endstation.yaml" - cfg = yaml_load(config_path) - cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}}) - device_manager_ophyd_test.add_device_configs(cfg) - device_manager_ophyd_test.show() + try: + config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/endstation.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_())