1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-15 05:00:55 +02:00

Compare commits

..

14 Commits

Author SHA1 Message Date
70fd1b7be4 feat(figure): added support for async device readbacks as curve data 2024-07-07 12:37:31 +02:00
semantic-release
fc3a69bbb0 0.81.0
Automatically generated by python-semantic-release
2024-07-06 12:24:27 +00:00
9594be2606 feat(color_button): can get colors in RGBA or HEX 2024-07-06 12:31:29 +02:00
semantic-release
96fd239608 0.80.1
Automatically generated by python-semantic-release
2024-07-06 10:24:27 +00:00
61de7e9e22 fix(entry_validator): check for entry == "" 2024-07-06 12:16:29 +02:00
semantic-release
24c4cdc39f 0.80.0
Automatically generated by python-semantic-release
2024-07-06 10:14:47 +00:00
fadbf77866 feat(qt5): dropped support for qt5; pyside2 and pyqt5 2024-07-06 12:00:35 +02:00
03819a3d90 feat(plugins): moved plugin dict to dataclass and container 2024-07-06 12:00:35 +02:00
d6d0777113 feat(plugins): added support for pyqt6 ui files 2024-07-05 22:50:19 +02:00
1aa83e0ef1 feat(plugins): added bec widgets base class 2024-07-05 21:22:51 +02:00
semantic-release
b09c644e01 0.79.3
Automatically generated by python-semantic-release
2024-07-05 08:25:43 +00:00
e403870874 fix: changed inheritance to adress qt designer bug in rendering 2024-07-05 08:06:10 +02:00
1586ce2d6c fix: add designer plugin classes 2024-07-05 08:05:20 +02:00
576353cfe8 refactor: simplify logic in bec_status_box 2024-07-05 08:05:20 +02:00
27 changed files with 593 additions and 361 deletions

View File

@@ -154,7 +154,6 @@ test-matrix:
- "3.12"
QT_PCKG:
- "pyside6"
- "pyqt5"
- "pyqt6"
stage: AdditionalTests

View File

@@ -3,7 +3,7 @@
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code.
extension-pkg-allow-list=PyQt5, pyqtgraph
extension-pkg-allow-list=PyQt6, PySide6, pyqtgraph
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may

View File

@@ -1,5 +1,41 @@
# CHANGELOG
## v0.81.0 (2024-07-06)
### Feature
* feat(color_button): can get colors in RGBA or HEX ([`9594be2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9594be260680d11c8550ff74ffb8d679e5a5b8f6))
## v0.80.1 (2024-07-06)
### Fix
* fix(entry_validator): check for entry == "" ([`61de7e9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/61de7e9e221c766b9fb3ec23246da6a11c96a986))
## v0.80.0 (2024-07-06)
### Feature
* feat(qt5): dropped support for qt5; pyside2 and pyqt5 ([`fadbf77`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fadbf77866903beff6580802bc203d53367fc7e7))
* feat(plugins): moved plugin dict to dataclass and container ([`03819a3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/03819a3d902b4a51f3e882d52aedd971b2a8e127))
* feat(plugins): added support for pyqt6 ui files ([`d6d0777`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d6d07771135335cb78dc648508ce573b8970261a))
* feat(plugins): added bec widgets base class ([`1aa83e0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1aa83e0ef1ffe45b01677b0b4590535cb0ca1cff))
## v0.79.3 (2024-07-05)
### Fix
* fix: changed inheritance to adress qt designer bug in rendering ([`e403870`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e403870874bd5e45840a034d6f1b3dd576d9c846))
* fix: add designer plugin classes ([`1586ce2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1586ce2d6cba2bb086b2ef596e724bb9e40ab4f2))
### Refactor
* refactor: simplify logic in bec_status_box ([`576353c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/576353cfe8c6fd64db561f0b6e2bc951300643d3))
## v0.79.2 (2024-07-04)
### Fix
@@ -105,47 +141,3 @@
### Feature
* feat(widgets): added simple bec queue widget ([`3faee98`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3faee98ec80041a27e4c1f1156178de6f9dcdc63))
### Refactor
* refactor(dispatcher): cleanup ([`ca02132`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ca02132c8d18535b37e9192e00459d2aca6ba5cf))
## v0.74.1 (2024-06-26)
### Build
* build: added missing pytest-bec-e2e dependency; closes #219 ([`56fdae4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/56fdae42757bdb9fa301c1e425a77e98b6eaf92b))
* build: fixed dependency ranges; closes #135 ([`e6a06c9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e6a06c9f43e0ad6bbfcfa550a2f580d2a27aff66))
### Chore
* chore: sorted dependencies alphabetically ([`21c807f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/21c807f35831fdd1ef2e488ab90edae4719f0cb7))
### Documentation
* docs: fixed doc string ([`f979a63`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f979a63d3d1a008f80e500510909750878ff4303))
### Fix
* fix(rings): rings properties updated right after setting ([`c8b7367`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c8b7367815b095f8e4aa8b819481efb701f2e542))
* fix(motor_map): motor map can be removed from BECFigure with .remove() ([`6b25abf`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6b25abff70280271e2eeb70450553c05d4b7c99c))
### Test
* test(bec_figure): tests for removing widgets with rpc e2e ([`a268caa`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a268caaa30711fcc7ece542d24578d74cbf65c77))
## v0.74.0 (2024-06-25)
### Documentation
* docs(becfigure): docs added ([`a51b15d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a51b15da3f5e83e0c897a0342bdb05b9c677a179))
### Feature
* feat(waveform1d): dap LMFit model can be added to plot ([`1866ba6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1866ba66c8e3526661beb13fff3e13af6a0ae562))
### Test
* test(waveform1d): dap e2e test added ([`7271b42`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7271b422f98ef9264970d708811c414b69a644db))

View File

@@ -17,7 +17,7 @@ cd bec_widgets
pip install -e .[dev,pyqt6]
```
BEC Widgets currently supports both PyQt5 and PyQt6, however, no default distribution is specified. As a result, users must install one of the supported
BEC Widgets currently supports both Pyside6 and PyQt6, however, no default distribution is specified. As a result, users must install one of the supported
Python Qt distributions manually.
To select a specific Python Qt distribution, install the package with an additional tag:
@@ -28,7 +28,7 @@ pip install bec_widgets[pyqt6]
or
```bash
pip install bec_widgets[pyqt5]
pip install bec_widgets[pyside6]
```
## Documentation

View File

@@ -13,12 +13,12 @@ class Widgets(str, enum.Enum):
Enum for the available widgets.
"""
BECQueue = "BECQueue"
BECStatusBox = "BECStatusBox"
BECDock = "BECDock"
BECDockArea = "BECDockArea"
BECFigure = "BECFigure"
BECMotorMapWidget = "BECMotorMapWidget"
BECQueue = "BECQueue"
BECStatusBox = "BECStatusBox"
RingProgressBar = "RingProgressBar"
ScanControl = "ScanControl"
TextBox = "TextBox"

View File

@@ -5,13 +5,12 @@ import argparse
import inspect
import os
import sys
from typing import Literal
import black
import isort
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator
from bec_widgets.utils.plugin_utils import get_rpc_classes
from bec_widgets.utils.plugin_utils import BECClassContainer, get_rpc_classes
if sys.version_info >= (3, 11):
from typing import get_overloads
@@ -40,17 +39,20 @@ from bec_widgets.cli.client_utils import RPCBase, rpc_call, BECGuiClientMixin
self.content = ""
def generate_client(
self, published_classes: dict[Literal["connector_classes", "top_level_classes"], list[type]]
):
def generate_client(self, class_container: BECClassContainer):
"""
Generate the client for the published classes.
Args:
published_classes(dict): A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
class_container: The class container with the classes to generate the client for.
"""
self.write_client_enum(published_classes["top_level_classes"])
for cls in published_classes["connector_classes"]:
rpc_top_level_classes = class_container.rpc_top_level_classes
rpc_top_level_classes.sort(key=lambda x: x.__name__)
connector_classes = class_container.connector_classes
connector_classes.sort(key=lambda x: x.__name__)
self.write_client_enum(rpc_top_level_classes)
for cls in connector_classes:
self.content += "\n\n"
self.generate_content_for_class(cls)
@@ -156,13 +158,12 @@ def main():
client_path = os.path.join(current_path, "client.py")
rpc_classes = get_rpc_classes("bec_widgets")
rpc_classes["connector_classes"].sort(key=lambda x: x.__name__)
generator = ClientGenerator()
generator.generate_client(rpc_classes)
generator.write(client_path)
for cls in rpc_classes["top_level_classes"]:
for cls in rpc_classes.plugins:
plugin = DesignerPluginGenerator(cls)
if not hasattr(plugin, "info"):
continue

View File

@@ -29,7 +29,7 @@ class RPCWidgetHandler:
from bec_widgets.utils.plugin_utils import get_rpc_classes
clss = get_rpc_classes("bec_widgets")
self._widget_classes = {cls.__name__: cls for cls in clss["top_level_classes"]}
self._widget_classes = {cls.__name__: cls for cls in clss.top_level_classes}
def create_widget(self, widget_type, **kwargs) -> BECConnector:
"""

View File

@@ -13,6 +13,7 @@ from qtpy.QtCore import QObject, QRunnable, QThreadPool, Signal
from qtpy.QtCore import Slot as pyqtSlot
from bec_widgets.cli.rpc_register import RPCRegister
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
@@ -63,7 +64,7 @@ class Worker(QRunnable):
self.signals.completed.emit()
class BECConnector:
class BECConnector(BECWidget):
"""Connection mixin class for all BEC widgets, to handle BEC client and device manager"""
USER_ACCESS = ["_config_dict", "_get_all_rpc"]

View File

@@ -8,7 +8,7 @@ import redis
from bec_lib.client import BECClient
from bec_lib.redis_connector import MessageObject, RedisConnector
from bec_lib.service_config import ServiceConfig
from qtpy.QtCore import PYQT5, PYQT6, PYSIDE2, PYSIDE6, QCoreApplication, QObject
from qtpy.QtCore import PYQT6, PYSIDE6, QCoreApplication, QObject
from qtpy.QtCore import Signal as pyqtSignal
if TYPE_CHECKING:
@@ -127,14 +127,17 @@ class BECDispatcher:
return
# shutdown QCoreApp if it exists
if PYQT5 or PYQT6:
if PYQT6:
cls.qapp.exit()
elif PYSIDE2 or PYSIDE6:
elif PYSIDE6:
cls.qapp.shutdown()
cls.qapp = None
def connect_slot(
self, slot: Callable, topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]]
self,
slot: Callable,
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
**kwargs,
) -> None:
"""Connect widget's pyqt slot, so that it is called on new pub/sub topic message.
@@ -144,7 +147,7 @@ class BECDispatcher:
topics (EndpointInfo | str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
"""
slot = QtThreadSafeCallback(slot)
self.client.connector.register(topics, cb=slot)
self.client.connector.register(topics, cb=slot, **kwargs)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
self._slots[slot].update(set(topics_str))

View File

@@ -0,0 +1,2 @@
class BECWidget:
"""Base class for all BEC widgets."""

View File

@@ -19,7 +19,7 @@ class EntryValidator:
device = self.devices[name]
description = device.describe()
if entry is None:
if entry is None or entry == "":
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
if entry not in description:
raise ValueError(f"Entry '{entry}' not found in device '{name}' signals")

View File

@@ -1,12 +1,13 @@
import importlib
import inspect
import os
from typing import Literal
from dataclasses import dataclass
from bec_lib.plugin_helper import _get_available_plugins
from qtpy.QtWidgets import QGraphicsWidget, QWidget
from bec_widgets.utils import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
def get_plugin_widgets() -> dict[str, BECConnector]:
@@ -44,9 +45,74 @@ def _filter_plugins(obj):
return inspect.isclass(obj) and issubclass(obj, BECConnector)
def get_rpc_classes(
repo_name: str,
) -> dict[Literal["connector_classes", "top_level_classes"], list[type]]:
@dataclass
class BECClassInfo:
name: str
module: str
file: str
obj: type
is_connector: bool = False
is_widget: bool = False
is_top_level: bool = False
class BECClassContainer:
def __init__(self):
self._collection = []
def add_class(self, class_info: BECClassInfo):
"""
Add a class to the collection.
Args:
class_info(BECClassInfo): The class information
"""
self.collection.append(class_info)
@property
def collection(self):
"""
Get the collection of classes.
"""
return self._collection
@property
def connector_classes(self):
"""
Get all connector classes.
"""
return [info.obj for info in self.collection if info.is_connector]
@property
def top_level_classes(self):
"""
Get all top-level classes.
"""
return [info.obj for info in self.collection if info.is_top_level]
@property
def plugins(self):
"""
Get all plugins. These are all classes that are on the top level and are widgets.
"""
return [info.obj for info in self.collection if info.is_widget and info.is_top_level]
@property
def widgets(self):
"""
Get all widgets. These are all classes inheriting from BECWidget.
"""
return [info.obj for info in self.collection if info.is_widget]
@property
def rpc_top_level_classes(self):
"""
Get all top-level classes that are RPC-enabled. These are all classes that users can choose from.
"""
return [info.obj for info in self.collection if info.is_top_level and info.is_connector]
def get_rpc_classes(repo_name: str) -> BECClassContainer:
"""
Get all RPC-enabled classes in the specified repository.
@@ -56,8 +122,7 @@ def get_rpc_classes(
Returns:
dict: A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
"""
connector_classes = []
top_level_classes = []
collection = BECClassContainer()
anchor_module = importlib.import_module(f"{repo_name}.widgets")
directory = os.path.dirname(anchor_module.__file__)
for root, _, files in sorted(os.walk(directory)):
@@ -78,11 +143,16 @@ def get_rpc_classes(
obj = getattr(module, name)
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
continue
if isinstance(obj, type) and issubclass(obj, BECConnector):
connector_classes.append(obj)
if isinstance(obj, type):
class_info = BECClassInfo(name=name, module=module_name, file=path, obj=obj)
if issubclass(obj, BECConnector):
class_info.is_connector = True
if issubclass(obj, BECWidget):
class_info.is_widget = True
if len(subs) == 1 and (
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
):
top_level_classes.append(obj)
class_info.is_top_level = True
collection.add_class(class_info)
return {"connector_classes": connector_classes, "top_level_classes": top_level_classes}
return collection

View File

@@ -1,20 +1,18 @@
import os
from qtpy import PYQT6, PYSIDE6, QT_VERSION
from qtpy.QtCore import QFile, QIODevice
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
from bec_widgets.utils.plugin_utils import get_rpc_classes
if PYSIDE6:
from PySide6.QtUiTools import QUiLoader
from bec_widgets.utils.plugin_utils import get_rpc_classes
from bec_widgets.widgets.buttons.color_button.color_button import ColorButton
class CustomUiLoader(QUiLoader):
def __init__(self, baseinstance):
def __init__(self, baseinstance, custom_widgets: dict = None):
super().__init__(baseinstance)
widgets = get_rpc_classes("bec_widgets").get("top_level_classes", [])
widgets.append(ColorButton)
self.custom_widgets = {widget.__name__: widget for widget in widgets}
self.custom_widgets = custom_widgets or {}
self.baseinstance = baseinstance
@@ -27,25 +25,21 @@ if PYSIDE6:
class UILoader:
"""Universal UI loader for PyQt5, PyQt6, PySide2, and PySide6."""
"""Universal UI loader for PyQt6 and PySide6."""
def __init__(self, parent=None):
self.parent = parent
if QT_VERSION.startswith("5"):
# PyQt5 or PySide2
from qtpy import uic
self.loader = uic.loadUi
elif QT_VERSION.startswith("6"):
# PyQt6 or PySide6
if PYSIDE6:
self.loader = self.load_ui_pyside6
elif PYQT6:
from PyQt6.uic import loadUi
widgets = get_rpc_classes("bec_widgets").top_level_classes
self.loader = loadUi
else:
raise ImportError("No compatible Qt bindings found.")
self.custom_widgets = {widget.__name__: widget for widget in widgets}
if PYSIDE6:
self.loader = self.load_ui_pyside6
elif PYQT6:
self.loader = self.load_ui_pyqt6
else:
raise ImportError("No compatible Qt bindings found.")
def load_ui_pyside6(self, ui_file, parent=None):
"""
@@ -58,7 +52,7 @@ class UILoader:
QWidget: The loaded widget.
"""
loader = CustomUiLoader(parent)
loader = CustomUiLoader(parent, self.custom_widgets)
file = QFile(ui_file)
if not file.open(QIODevice.ReadOnly):
raise IOError(f"Cannot open file: {ui_file}")
@@ -66,6 +60,71 @@ class UILoader:
file.close()
return widget
def load_ui_pyqt6(self, ui_file, parent=None):
"""
Specific loader for PyQt6 using loadUi.
Args:
ui_file(str): Path to the .ui file.
parent(QWidget): Parent widget.
Returns:
QWidget: The loaded widget.
"""
from PyQt6.uic.Loader.loader import DynamicUILoader
class CustomDynamicUILoader(DynamicUILoader):
def __init__(self, package, custom_widgets: dict = None):
super().__init__(package)
self.custom_widgets = custom_widgets or {}
def _handle_custom_widgets(self, el):
"""Handle the <customwidgets> element."""
def header2module(header):
"""header2module(header) -> string
Convert paths to C++ header files to according Python modules
>>> header2module("foo/bar/baz.h")
'foo.bar.baz'
"""
if header.endswith(".h"):
header = header[:-2]
mpath = []
for part in header.split("/"):
# Ignore any empty parts or those that refer to the current
# directory.
if part not in ("", "."):
if part == "..":
# We should allow this for Python3.
raise SyntaxError(
"custom widget header file name may not contain '..'."
)
mpath.append(part)
return ".".join(mpath)
for custom_widget in el:
classname = custom_widget.findtext("class")
header = custom_widget.findtext("header")
if header:
header = self._translate_bec_widgets_header(header)
self.factory.addCustomWidget(
classname,
custom_widget.findtext("extends") or "QWidget",
header2module(header),
)
def _translate_bec_widgets_header(self, header):
for name, value in self.custom_widgets.items():
if header == DesignerPluginInfo.pascal_to_snake(name):
return value.__module__
return header
return CustomDynamicUILoader("", self.custom_widgets).loadUi(ui_file, parent)
def load_ui(self, ui_file, parent=None):
"""
Universal UI loader method.

View File

@@ -5,15 +5,16 @@ The widget automatically updates the status of all running BEC services, and dis
from __future__ import annotations
import sys
from collections import defaultdict
from dataclasses import dataclass
from typing import TYPE_CHECKING
import qdarktheme
from bec_lib.utils.import_utils import lazy_import_from
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import QObject, QTimer, Signal, Slot
from qtpy.QtWidgets import QTreeWidget, QTreeWidgetItem
from qtpy.QtWidgets import QHBoxLayout, QTreeWidget, QTreeWidgetItem, QWidget
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.widgets.bec_status_box.status_item import StatusItem
if TYPE_CHECKING:
@@ -23,45 +24,18 @@ if TYPE_CHECKING:
BECStatus = lazy_import_from("bec_lib.messages", ("BECStatus",))
class BECStatusBoxConfig(ConnectionConfig):
pass
class BECServiceInfoContainer(BaseModel):
@dataclass
class BECServiceInfoContainer:
"""Container to store information about the BEC services."""
service_name: str
status: BECStatus | str = Field(
default="NOTCONNECTED",
description="The status of the service. Can be any of the BECStatus names, or NOTCONNECTED.",
)
status: str
info: dict
metrics: dict | None
model_config: dict = {"validate_assignment": True}
@field_validator("status")
@classmethod
def validate_status(cls, v):
"""Validate input for status. Accept BECStatus and NOTCONNECTED.
Args:
v (BECStatus | str): The input value.
Returns:
str: The validated status.
"""
if v in list(BECStatus.__members__.values()):
return v.name
if v in list(BECStatus.__members__.keys()) or v == "NOTCONNECTED":
return v
raise ValueError(
f"Status must be one of {BECStatus.__members__.values()} or 'NOTCONNECTED'. Input {v}"
)
class BECServiceStatusMixin(QObject):
"""A mixin class to update the service status, and metrics.
It emits a signal 'services_update' when the service status is updated.
"""Mixin to receive the latest service status from the BEC server and emit it via services_update signal.
Args:
client (BECClient): The client object to connect to the BEC server.
@@ -69,29 +43,26 @@ class BECServiceStatusMixin(QObject):
services_update = Signal(dict, dict)
def __init__(self, client: BECClient):
super().__init__()
def __init__(self, parent, client: BECClient):
super().__init__(parent)
self.client = client
self._service_update_timer = QTimer()
self._service_update_timer.timeout.connect(self._get_service_status)
self._service_update_timer.start(1000)
def _get_service_status(self):
"""Pull latest service and metrics updates from REDIS for all services, and emit both via 'services_update' signal."""
"""Get the latest service status from the BEC server."""
# pylint: disable=protected-access
self.client._update_existing_services()
self.services_update.emit(self.client._services_info, self.client._services_metric)
class BECStatusBox(BECConnector, QTreeWidget):
"""A widget to display the status of different BEC services.
This widget automatically updates the status of all running BEC services, and displays their status.
Information about the individual services is collapsible, and double clicking on
the individual service will display the metrics about the service.
class BECStatusBox(BECConnector, QWidget):
"""An autonomous widget to display the status of BEC services.
Args:
parent Optional : The parent widget for the BECStatusBox. Defaults to None.
service_name Optional(str): The name of the top service label. Defaults to "BEC Server".
box_name Optional(str): The name of the top service label. Defaults to "BEC Server".
client Optional(BECClient): The client object to connect to the BEC server. Defaults to None
config Optional(BECStatusBoxConfig | dict): The configuration for the status box. Defaults to None.
gui_id Optional(str): The unique id for the widget. Defaults to None.
@@ -99,58 +70,67 @@ class BECStatusBox(BECConnector, QTreeWidget):
CORE_SERVICES = ["DeviceServer", "ScanServer", "SciHub", "ScanBundler", "FileWriterManager"]
service_update = Signal(dict)
service_update = Signal(BECServiceInfoContainer)
bec_core_state = Signal(str)
def __init__(
self,
parent=None,
service_name: str = "BEC Server",
box_name: str = "BEC Server",
client: BECClient = None,
config: BECStatusBoxConfig | dict = None,
bec_service_status_mixin: BECServiceStatusMixin = None,
gui_id: str = None,
):
if config is None:
config = BECStatusBoxConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = BECStatusBoxConfig(**config)
super().__init__(client=client, config=config, gui_id=gui_id)
QTreeWidget.__init__(self, parent=parent)
super().__init__(client=client, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
self.tree = QTreeWidget(self)
self.layout = QHBoxLayout(self)
self.service_name = service_name
self.config = config
self.bec_service_info_container = {}
self.tree_items = {}
self.tree_top_item = None
self.box_name = box_name
self.status_container = defaultdict(lambda: {"info": None, "item": None, "widget": None})
if not bec_service_status_mixin:
bec_service_status_mixin = BECServiceStatusMixin(client=self.client)
bec_service_status_mixin = BECServiceStatusMixin(self, client=self.client)
self.bec_service_status = bec_service_status_mixin
self.init_ui()
self.bec_service_status.services_update.connect(self.update_service_status)
self.bec_core_state.connect(self.update_top_item_status)
self.itemDoubleClicked.connect(self.on_tree_item_double_clicked)
self.tree.itemDoubleClicked.connect(self.on_tree_item_double_clicked)
self.layout.addWidget(self.tree)
def init_ui(self) -> None:
"""Initialize the UI for the status box, and add QTreeWidget as the basis for the status box."""
"""Init the UI for the BECStatusBox widget, should only take place once."""
self.init_ui_tree_widget()
top_label = self._create_status_widget(self.service_name, status=BECStatus.IDLE)
self.tree_top_item = QTreeWidgetItem()
self.tree_top_item.setExpanded(True)
self.tree_top_item.setDisabled(True)
self.addTopLevelItem(self.tree_top_item)
self.setItemWidget(self.tree_top_item, 0, top_label)
top_label = self._create_status_widget(self.box_name, status=BECStatus.IDLE)
tree_item = QTreeWidgetItem()
tree_item.setExpanded(True)
tree_item.setDisabled(True)
self.status_container[self.box_name].update({"item": tree_item, "widget": top_label})
self.tree.addTopLevelItem(tree_item)
self.tree.setItemWidget(tree_item, 0, top_label)
self.service_update.connect(top_label.update_config)
self._initialized = True
def init_ui_tree_widget(self) -> None:
"""Initialise the tree widget for the status box."""
self.tree.setHeaderHidden(True)
# TODO probably here is a problem still with setting the stylesheet
self.tree.setStyleSheet(
"QTreeWidget::item:!selected "
"{ "
"border: 1px solid gainsboro; "
"border-left: none; "
"border-top: none; "
"}"
"QTreeWidget::item:selected {}"
)
def _create_status_widget(
self, service_name: str, status=BECStatus, info: dict = None, metrics: dict = None
) -> StatusItem:
"""Creates a StatusItem (QWidget) for the given service, and stores all relevant
information about the service in the bec_service_info_container.
information about the service in the status_container.
Args:
service_name (str): The name of the service.
@@ -163,16 +143,8 @@ class BECStatusBox(BECConnector, QTreeWidget):
"""
if info is None:
info = {}
self._update_bec_service_container(service_name, status, info, metrics)
item = StatusItem(
parent=self,
config={
"service_name": service_name,
"status": status.name,
"info": info,
"metrics": metrics,
},
)
self._update_status_container(service_name, status, info, metrics)
item = StatusItem(parent=self, config=self.status_container[service_name]["info"])
return item
@Slot(str)
@@ -183,30 +155,35 @@ class BECStatusBox(BECConnector, QTreeWidget):
Args:
status (BECStatus): The state of the core services.
"""
self.bec_service_info_container[self.service_name].status = status
self.service_update.emit(self.bec_service_info_container[self.service_name].model_dump())
self.status_container[self.box_name]["info"].status = status
self.service_update.emit(self.status_container[self.box_name]["info"])
def _update_bec_service_container(
def _update_status_container(
self, service_name: str, status: BECStatus, info: dict, metrics: dict = None
) -> None:
"""Update the bec_service_info_container with the newest status and metrics for the BEC service.
"""Update the status_container with the newest status and metrics for the BEC service.
If information about the service already exists, it will create a new entry.
Args:
service_name (str): The name of the service.
service_info (StatusMessage): A class containing the service status.
service_metric (ServiceMetricMessage): A class containing the service metrics.
status (BECStatus): The status of the service.
info (dict): The information about the service.
metrics (dict): The metrics of the service.
"""
container = self.bec_service_info_container.get(service_name, None)
container = self.status_container[service_name].get("info", None)
if container:
container.status = status
container.status = status.name
container.info = info
container.metrics = metrics
return
service_info_item = BECServiceInfoContainer(
service_name=service_name, status=status, info=info, metrics=metrics
service_name=service_name,
status=status.name if isinstance(status, BECStatus) else status,
info=info,
metrics=metrics,
)
self.bec_service_info_container.update({service_name: service_info_item})
self.status_container[service_name].update({"info": service_info_item})
@Slot(dict, dict)
def update_service_status(self, services_info: dict, services_metric: dict) -> None:
@@ -217,7 +194,7 @@ class BECStatusBox(BECConnector, QTreeWidget):
services_info (dict): A dictionary containing the service status for all running BEC services.
services_metric (dict): A dictionary containing the service metrics for all running BEC services.
"""
checked = []
checked = [self.box_name]
services_info = self.update_core_services(services_info, services_metric)
checked.extend(self.CORE_SERVICES)
@@ -225,28 +202,19 @@ class BECStatusBox(BECConnector, QTreeWidget):
checked.append(service_name)
metric_msg = services_metric.get(service_name, None)
metrics = metric_msg.metrics if metric_msg else None
if service_name in self.tree_items:
self._update_bec_service_container(
service_name=service_name, status=msg.status, info=msg.info, metrics=metrics
)
self.service_update.emit(self.bec_service_info_container[service_name].model_dump())
if service_name in self.status_container:
if not msg:
self.add_tree_item(service_name, "NOTCONNECTED", {}, metrics)
continue
self._update_status_container(service_name, msg.status, msg.info, metrics)
self.service_update.emit(self.status_container[service_name]["info"])
continue
item_widget = self._create_status_widget(
service_name=service_name, status=msg.status, info=msg.info, metrics=metrics
)
item = QTreeWidgetItem()
item.setDisabled(True)
self.service_update.connect(item_widget.update_config)
self.tree_top_item.addChild(item)
self.setItemWidget(item, 0, item_widget)
self.tree_items.update({service_name: (item, item_widget)})
self.add_tree_item(service_name, msg.status, msg.info, metrics)
self.check_redundant_tree_items(checked)
def update_core_services(self, services_info: dict, services_metric: dict) -> dict:
"""Method to process status and metrics updates of core services (stored in CORE_SERVICES).
If a core services is not connected, it should not be removed from the status widget
"""Update the core services of BEC, and emit the updated status to the BECStatusBox.
Args:
services_info (dict): A dictionary containing the service status of different services.
@@ -255,28 +223,28 @@ class BECStatusBox(BECConnector, QTreeWidget):
Returns:
dict: The services_info dictionary after removing the info updates related to the CORE_SERVICES
"""
bec_core_state = "RUNNING"
core_state = BECStatus.RUNNING
for service_name in sorted(self.CORE_SERVICES):
metric_msg = services_metric.get(service_name, None)
metrics = metric_msg.metrics if metric_msg else None
if service_name not in services_info:
self.bec_service_info_container[service_name].status = "NOTCONNECTED"
bec_core_state = "ERROR"
else:
msg = services_info.pop(service_name)
self._update_bec_service_container(
service_name=service_name, status=msg.status, info=msg.info, metrics=metrics
)
bec_core_state = (
"RUNNING" if (msg.status.value > 1 and bec_core_state == "RUNNING") else "ERROR"
)
if service_name in self.tree_items:
self.service_update.emit(self.bec_service_info_container[service_name].model_dump())
msg = services_info.pop(service_name, None)
if service_name not in self.status_container:
if not msg:
self.add_tree_item(service_name, "NOTCONNECTED", {}, metrics)
continue
self.add_tree_item(service_name, msg.status, msg.info, metrics)
continue
self.add_tree_item(service_name, msg.status, msg.info, metrics)
if not msg:
self.status_container[service_name]["info"].status = "NOTCONNECTED"
core_state = None
else:
self._update_status_container(service_name, msg.status, msg.info, metrics)
if core_state:
core_state = msg.status if msg.status.value < core_state.value else core_state
self.bec_core_state.emit(bec_core_state)
self.service_update.emit(self.status_container[service_name]["info"])
self.bec_core_state.emit(core_state.name if core_state else "NOTCONNECTED")
return services_info
def check_redundant_tree_items(self, checked: list) -> None:
@@ -285,11 +253,12 @@ class BECStatusBox(BECConnector, QTreeWidget):
Args:
checked (list): A list of services that are currently running.
"""
to_be_deleted = [key for key in self.tree_items if key not in checked]
to_be_deleted = [key for key in self.status_container if key not in checked]
for key in to_be_deleted:
item, _ = self.tree_items.pop(key)
self.tree_top_item.removeChild(item)
obj = self.status_container.pop(key)
item = obj["item"]
self.status_container[self.box_name]["item"].removeChild(item)
def add_tree_item(
self, service_name: str, status: BECStatus, info: dict = None, metrics: dict = None
@@ -298,30 +267,16 @@ class BECStatusBox(BECConnector, QTreeWidget):
Args:
service_name (str): The name of the service.
service_status_msg (StatusMessage): The status of the service.
status (BECStatus): The status of the service.
info (dict): The information about the service.
metrics (dict): The metrics of the service.
"""
item_widget = self._create_status_widget(
service_name=service_name, status=status, info=info, metrics=metrics
)
item_widget = self._create_status_widget(service_name, status, info, metrics)
item = QTreeWidgetItem()
self.service_update.connect(item_widget.update_config)
self.tree_top_item.addChild(item)
self.setItemWidget(item, 0, item_widget)
self.tree_items.update({service_name: (item, item_widget)})
def init_ui_tree_widget(self) -> None:
"""Initialise the tree widget for the status box."""
self.setHeaderHidden(True)
self.setStyleSheet(
"QTreeWidget::item:!selected "
"{ "
"border: 1px solid gainsboro; "
"border-left: none; "
"border-top: none; "
"}"
"QTreeWidget::item:selected {}"
)
self.status_container[self.box_name]["item"].addChild(item)
self.tree.setItemWidget(item, 0, item_widget)
self.status_container[service_name].update({"item": item, "widget": item_widget})
@Slot(QTreeWidgetItem, int)
def on_tree_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None:
@@ -331,13 +286,18 @@ class BECStatusBox(BECConnector, QTreeWidget):
item (QTreeWidgetItem): The item that was double clicked.
column (int): The column that was double clicked.
"""
for _, (tree_item, status_widget) in self.tree_items.items():
if tree_item == item:
status_widget.show_popup()
for _, objects in self.status_container.items():
if objects["item"] == item:
objects["widget"].show_popup()
def closeEvent(self, event):
"""Upon closing the widget, clean up the BECStatusBox and the QWidget.
Args:
event: The close event.
"""
super().cleanup()
return QTreeWidget.closeEvent(self, event)
super().closeEvent(event)
def main():

View File

@@ -0,0 +1 @@
{'files': ['bec_status_box.py']}

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
from bec_widgets.widgets.bec_status_box.bec_status_box import BECStatusBox
DOM_XML = """
<ui language='c++'>
<widget class='BECStatusBox' name='bec_status_box'>
</widget>
</ui>
"""
class BECStatusBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = BECStatusBox(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return QIcon()
def includeFile(self):
return "bec_status_box"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "BECStatusBox"
def toolTip(self):
return "An autonomous widget to display the status of BEC services."
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.bec_status_box.bec_status_box_plugin import BECStatusBoxPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECStatusBoxPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -2,17 +2,12 @@
The widget is bound to be used with the BECStatusBox widget."""
import enum
import sys
from datetime import datetime
import qdarktheme
from bec_lib.utils.import_utils import lazy_import_from
from pydantic import Field
from qtpy.QtCore import Qt, Slot
from qtpy.QtWidgets import QDialog, QHBoxLayout, QLabel, QStyle, QVBoxLayout, QWidget
from bec_widgets.utils.bec_connector import ConnectionConfig
# TODO : Put normal imports back when Pydantic gets faster
BECStatus = lazy_import_from("bec_lib.messages", ("BECStatus",))
@@ -27,39 +22,29 @@ class IconsEnum(enum.Enum):
NOTCONNECTED = "SP_TitleBarContextHelpButton"
class StatusWidgetConfig(ConnectionConfig):
"""Configuration class for the status item widget."""
service_name: str
status: str
info: dict
metrics: dict | None
icon_size: tuple = Field(default=(24, 24), description="The size of the icon in the widget.")
font_size: int = Field(16, description="The font size of the text in the widget.")
class StatusItem(QWidget):
"""A widget to display the status of a service.
Args:
parent: The parent widget.
config (dict): The configuration for the service.
config (dict): The configuration for the service, must be a BECServiceInfoContainer.
"""
def __init__(self, parent=None, config: dict = None):
if config is None:
config = StatusWidgetConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = StatusWidgetConfig(**config)
self.config = config
def __init__(self, parent: QWidget = None, config=None):
QWidget.__init__(self, parent=parent)
if config is None:
# needed because we need parent to be the first argument for QT Designer
raise ValueError(
"Please initialize the StatusItem with a BECServiceInfoContainer for config, received None."
)
self.config = config
self.parent = parent
self.layout = None
self.config = config
self._popup_label_ref = {}
self._label = None
self._icon = None
self.icon_size = (24, 24)
self._popup_label_ref = {}
self.init_ui()
def init_ui(self) -> None:
@@ -74,23 +59,21 @@ class StatusItem(QWidget):
self.update_ui()
@Slot(dict)
def update_config(self, config: dict) -> None:
"""Update the configuration of the status item widget.
This method is invoked from the parent widget.
The UI values are later updated based on the new configuration.
def update_config(self, config) -> None:
"""Update the config of the status item widget.
Args:
config (dict): Config updates from parent widget.
config (dict): Config updates from parent widget, must be a BECServiceInfoContainer.
"""
if config["service_name"] != self.config.service_name:
if self.config is None or config.service_name != self.config.service_name:
return
self.config.status = config["status"]
self.config.info = config["info"]
self.config.metrics = config["metrics"]
self.config = config
self.update_ui()
def update_ui(self) -> None:
"""Update the UI of the labels, and popup dialog."""
if self.config is None:
return
self.set_text()
self.set_status()
self._set_popup_text()
@@ -99,8 +82,8 @@ class StatusItem(QWidget):
"""Set the text of the QLabel basae on the config."""
service = self.config.service_name
status = self.config.status
if "BECClient" in service.split("/"):
service = service.split("/")[0] + "/..." + service.split("/")[1][-4:]
if len(service.split("/")) > 1 and service.split("/")[0].startswith("BEC"):
service = service.split("/", maxsplit=1)[0] + "/..." + service.split("/")[1][-4:]
if status == "NOTCONNECTED":
status = "NOT CONNECTED"
text = f"{service} is {status}"
@@ -110,7 +93,7 @@ class StatusItem(QWidget):
"""Set the status icon for the status item widget."""
icon_name = IconsEnum[self.config.status].value
icon = self.style().standardIcon(getattr(QStyle.StandardPixmap, icon_name))
self._icon.setPixmap(icon.pixmap(*self.config.icon_size))
self._icon.setPixmap(icon.pixmap(*self.icon_size))
self._icon.setAlignment(Qt.AlignmentFlag.AlignRight)
def show_popup(self) -> None:
@@ -153,19 +136,3 @@ class StatusItem(QWidget):
def _cleanup_popup_label(self) -> None:
"""Cleanup the popup label."""
self._popup_label_ref.clear()
def main():
"""Run the status item widget."""
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
qdarktheme.setup_theme("auto")
main_window = StatusItem()
main_window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()

View File

@@ -1,3 +1,7 @@
from __future__ import annotations
from typing import Literal
import pyqtgraph as pg
@@ -15,3 +19,18 @@ class ColorButton(pg.ColorButton):
self.colorDialog.setCurrentColor(self.color())
self.colorDialog.open()
self.colorDialog.exec()
def get_color(self, format: Literal["RGBA", "HEX"] = "RGBA") -> tuple | str:
"""
Get the color of the button in the specified format.
Args:
format(Literal["RGBA", "HEX"]): The format of the returned color.
Returns:
tuple|str: The color in the specified format.
"""
if format == "RGBA":
return self.color().getRgb()
if format == "HEX":
return self.color().name()

View File

@@ -7,6 +7,7 @@ from typing import Any, Literal, Optional
import numpy as np
import pyqtgraph as pg
from bec_lib import messages
from bec_lib.device import ReadoutPriority
from bec_lib.endpoints import MessageEndpoints
from bec_lib.scan_data import ScanData
from pydantic import Field, ValidationError
@@ -327,7 +328,7 @@ class BECWaveform(BECPlotBase):
color_map_z: Optional[str] = "plasma",
label: Optional[str] = None,
validate_bec: bool = True,
source: str = "scan_segment",
source: Optional[str] = None,
dap: Optional[str] = None,
**kwargs,
) -> BECCurve:
@@ -349,8 +350,6 @@ class BECWaveform(BECPlotBase):
Returns:
BECCurve: The curve object.
"""
# Check if curve already exists
curve_source = source
# Get entry if not provided and validate
x_entry, y_entry, z_entry = self._validate_signal_entries(
@@ -362,6 +361,7 @@ class BECWaveform(BECPlotBase):
else:
label = label or f"{y_name}-{y_entry}"
# Check if curve already exists
curve_exits = self._check_curve_id(label, self._curves_data)
if curve_exits:
raise ValueError(f"Curve with ID '{label}' already exists in widget '{self.gui_id}'.")
@@ -373,6 +373,18 @@ class BECWaveform(BECPlotBase):
)[-1]
)
if source is None:
if validate_bec:
curve_source = (
"async_readback"
if self.dev[y_name].readout_priority == ReadoutPriority.ASYNC
else "scan_segment"
)
else:
curve_source = "scan_segment"
else:
curve_source = source
# Create curve by config
curve_config = CurveConfig(
widget_class="BECCurve",

View File

@@ -1,15 +1,17 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal, Optional
from typing import TYPE_CHECKING, Literal, Optional
import pyqtgraph as pg
from bec_lib.endpoints import MessageEndpoints
from pydantic import BaseModel, Field, field_validator
from pydantic_core import PydanticCustomError
from qtpy import QtCore
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
if TYPE_CHECKING:
import numpy as np
from bec_widgets.widgets.figure.plots.waveform import BECWaveform1D
@@ -103,6 +105,14 @@ class BECCurve(BECConnector, pg.PlotDataItem):
if kwargs:
self.set(**kwargs)
self._async_info = {}
if self.config.source == "async_readback":
# get updates on new scans in order to change the subscription to the correct async readback
self.bec_dispatcher.connect_slot(
self.on_scan_status_update, MessageEndpoints.scan_status()
)
def apply_config(self):
pen_style_map = {
"solid": QtCore.Qt.SolidLine,
@@ -137,6 +147,49 @@ class BECCurve(BECConnector, pg.PlotDataItem):
else:
raise ValueError(f"Source {self.config.source} do not allow custom data setting.")
def on_scan_status_update(self, content: dict, metadata: dict):
"""
Update the async info with the latest scan status.
"""
scan_id = content.get("scan_id")
if scan_id == self._async_info.get("scan_id"):
return
active_subscription = self._async_info.get("active_subscription")
if active_subscription:
self.bec_dispatcher.disconnect_slot(self.on_async_update, active_subscription)
self._async_info["scan_id"] = scan_id
self._async_info["active_subscription"] = MessageEndpoints.device_async_readback(
scan_id, self.config.signals.y.name
)
self._async_info["data"] = []
self.bec_dispatcher.connect_slot(
self.on_async_update,
MessageEndpoints.device_async_readback(scan_id, self.config.signals.y.name),
from_start=True,
)
def on_async_update(self, content: dict, metadata: dict):
"""
Update the curve with the latest async readback data.
"""
async_update = metadata.get("async_update")
if not async_update:
return
signals = content.get("signals")
if not signals:
return
y_data = signals.get(self.config.signals.y.name, {}).get("value", [])
if y_data is None:
return
if async_update == "extend":
self._async_info["data"].extend(y_data)
elif async_update == "replace":
self._async_info["data"] = y_data
else:
print(f"Warning: Unsupported async update type {async_update}.")
self.setData(self._async_info["data"])
def set(self, **kwargs):
"""
Set the properties of the curve.

View File

@@ -10,7 +10,7 @@ Targeting the unique needs of beamline scientists, BEC Widgets stands out with i
**Key Features**:
- **Integration:** Seamlessly integrates with [BEC (Beamline Experiment Control)](https://gitlab.psi.ch/bec/bec), ensuring a cohesive and efficient experiment control experience.
- **Support for PyQt5 and PyQt6:** Provides compatibility with both PyQt5 and PyQt6, offering versatility in your development environment.
- **Support for Pyside6 and PyQt6:** Provides compatibility with both Pyside6 and PyQt6, offering versatility in your development environment.
- **Widget Modularity:** Features modular widgets that can be easily combined to create customized applications, perfectly aligning with the diverse needs of beamline experiments.
## Getting Started

View File

@@ -16,10 +16,10 @@ To install BEC Widgets using the pip package manager, execute the following comm
pip install 'bec_widgets[pyqt6]'
```
In case you want to use PyQt5, you can install it by using the following command:
In case you want to use Pyside6, you can install it by using the following command:
```bash
pip install 'bec_widgets[pyqt5]'
pip install 'bec_widgets[pyside6]'
```
**Troubleshooting**

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "0.79.2"
version = "0.81.0"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [
@@ -37,7 +37,6 @@ dev = [
"pytest-xvfb~=3.0",
"pytest~=8.0",
]
pyqt5 = ["PyQt5>=5.9", "PyQtWebEngine>=5.9"]
pyqt6 = ["PyQt6>=6.7", "PyQt6-WebEngine>=6.7"]
pyside6 = ["PySide6>=6.7"]

View File

@@ -1,3 +1,4 @@
# pylint: skip-file
from unittest import mock
import pytest
@@ -15,19 +16,18 @@ def service_status_fixture():
@pytest.fixture
def status_box(qtbot, mocked_client, service_status_fixture):
widget = BECStatusBox(
client=mocked_client, service_name="test", bec_service_status_mixin=service_status_fixture
)
widget = BECStatusBox(client=mocked_client, bec_service_status_mixin=service_status_fixture)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_update_top_item(status_box):
assert status_box.children()[0].children()[0].config.status == "IDLE"
assert status_box.tree.children()[0].children()[0].config.status == "IDLE"
name = status_box.box_name
status_box.update_top_item_status(status="RUNNING")
assert status_box.bec_service_info_container["test"].status == "RUNNING"
assert status_box.children()[0].children()[0].config.status == "RUNNING"
assert status_box.status_container[name]["info"].status == "RUNNING"
assert status_box.tree.children()[0].children()[0].config.status == "RUNNING"
def test_create_status_widget(status_box):
@@ -48,13 +48,13 @@ def test_bec_service_container(status_box):
info = {"test": "test"}
metrics = {"metric": "test_metric"}
expected_return = BECServiceInfoContainer(
service_name=name, status=status, info=info, metrics=metrics
service_name=name, status=status.name, info=info, metrics=metrics
)
assert status_box.service_name in status_box.bec_service_info_container
assert len(status_box.bec_service_info_container) == 1
status_box._update_bec_service_container(name, status, info, metrics)
assert len(status_box.bec_service_info_container) == 2
assert status_box.bec_service_info_container[name] == expected_return
assert status_box.box_name in status_box.status_container
assert len(status_box.status_container) == 1
status_box._update_status_container(name, status, info, metrics)
assert len(status_box.status_container) == 2
assert status_box.status_container[name]["info"] == expected_return
def test_add_tree_item(status_box):
@@ -62,10 +62,10 @@ def test_add_tree_item(status_box):
status = BECStatus.IDLE
info = {"test": "test"}
metrics = {"metric": "test_metric"}
assert len(status_box.children()[0].children()) == 1
assert len(status_box.tree.children()[0].children()) == 1
status_box.add_tree_item(name, status, info, metrics)
assert len(status_box.children()[0].children()) == 2
assert name in status_box.tree_items
assert len(status_box.tree.children()[0].children()) == 2
assert name in status_box.status_container
def test_update_service_status(status_box):
@@ -82,10 +82,10 @@ def test_update_service_status(status_box):
services_metrics = {name: ServiceMetricMessage(name=name, metrics=metrics)}
with mock.patch.object(status_box, "update_core_services", return_value=services_status):
assert not_connected_name in status_box.tree_items
assert not_connected_name in status_box.status_container
status_box.update_service_status(services_status, services_metrics)
assert status_box.tree_items[name][1].config.metrics == metrics
assert not_connected_name not in status_box.tree_items
assert status_box.status_container[name]["widget"].config.metrics == metrics
assert not_connected_name not in status_box.status_container
def test_update_core_services(status_box):
@@ -98,15 +98,15 @@ def test_update_core_services(status_box):
services_metrics = {name: ServiceMetricMessage(name=name, metrics=metrics)}
status_box.update_core_services(services_status, services_metrics)
assert status_box.children()[0].children()[0].config.status == "RUNNING"
assert status_box.tree_items[name][1].config.metrics == metrics
assert status_box.tree.children()[0].children()[0].config.status == "RUNNING"
assert status_box.status_container[name]["widget"].config.metrics == metrics
status = BECStatus.IDLE
services_status = {name: StatusMessage(name=name, status=status, info=info)}
services_metrics = {name: ServiceMetricMessage(name=name, metrics=metrics)}
status_box.update_core_services(services_status, services_metrics)
assert status_box.children()[0].children()[0].config.status == "ERROR"
assert status_box.tree_items[name][1].config.metrics == metrics
assert status_box.tree.children()[0].children()[0].config.status == status.name
assert status_box.status_container[name]["widget"].config.metrics == metrics
def test_double_click_item(status_box):
@@ -115,7 +115,9 @@ def test_double_click_item(status_box):
info = {"test": "test"}
metrics = {"MyData": "This should be shown nicely"}
status_box.add_tree_item(name, status, info, metrics)
item, status_item = status_box.tree_items[name]
container = status_box.status_container[name]
item = container["item"]
status_item = container["widget"]
with mock.patch.object(status_item, "show_popup") as mock_show_popup:
status_box.itemDoubleClicked.emit(item, 0)
status_box.tree.itemDoubleClicked.emit(item, 0)
assert mock_show_popup.call_count == 1

View File

@@ -4,6 +4,7 @@ import black
import isort
from bec_widgets.cli.generate_cli import ClientGenerator
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
# pylint: disable=missing-function-docstring
@@ -33,11 +34,31 @@ class MockBECFigure:
def test_client_generator_with_black_formatting():
generator = ClientGenerator()
rpc_classes = {
"connector_classes": [MockBECWaveform1D, MockBECFigure],
"top_level_classes": [MockBECFigure],
}
generator.generate_client(rpc_classes)
container = BECClassContainer()
container.add_class(
BECClassInfo(
name="MockBECWaveform1D",
module="test_module",
file="test_file",
obj=MockBECWaveform1D,
is_connector=True,
is_widget=True,
is_top_level=False,
)
)
container.add_class(
BECClassInfo(
name="MockBECFigure",
module="test_module",
file="test_file",
obj=MockBECFigure,
is_connector=True,
is_widget=True,
is_top_level=True,
)
)
generator.generate_client(container)
# Format the expected output with black to ensure it matches the generator output
expected_output = dedent(
@@ -51,6 +72,7 @@ def test_client_generator_with_black_formatting():
# pylint: skip-file
class Widgets(str, enum.Enum):
"""
Enum for the available widgets.
@@ -59,18 +81,6 @@ def test_client_generator_with_black_formatting():
MockBECFigure = "MockBECFigure"
class MockBECWaveform1D(RPCBase):
@rpc_call
def set_frequency(self, frequency: float) -> list:
"""
Set the frequency of the waveform.
"""
@rpc_call
def set_amplitude(self, amplitude: float) -> tuple[float, float]:
"""
Set the amplitude of the waveform.
"""
class MockBECFigure(RPCBase):
@rpc_call
def add_plot(self, plot_id: str):
@@ -83,6 +93,20 @@ def test_client_generator_with_black_formatting():
"""
Remove a plot from the figure.
"""
class MockBECWaveform1D(RPCBase):
@rpc_call
def set_frequency(self, frequency: float) -> list:
"""
Set the frequency of the waveform.
"""
@rpc_call
def set_amplitude(self, amplitude: float) -> tuple[float, float]:
"""
Set the amplitude of the waveform.
"""
'''
)

View File

@@ -3,9 +3,8 @@ from bec_widgets.utils.plugin_utils import get_rpc_classes
def test_client_generator_classes():
out = get_rpc_classes("bec_widgets")
assert list(out.keys()) == ["connector_classes", "top_level_classes"]
connector_cls_names = [cls.__name__ for cls in out["connector_classes"]]
top_level_cls_names = [cls.__name__ for cls in out["top_level_classes"]]
connector_cls_names = [cls.__name__ for cls in out.connector_classes]
top_level_cls_names = [cls.__name__ for cls in out.top_level_classes]
assert "BECFigure" in connector_cls_names
assert "BECWaveform" in connector_cls_names