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

Compare commits

..

1 Commits

Author SHA1 Message Date
bdbc2b903d docs: add tutorial on how to add StartScan Button 2024-06-14 14:30:40 +02:00
50 changed files with 585 additions and 1769 deletions

View File

@@ -1,110 +1,6 @@
# CHANGELOG
## v0.67.0 (2024-06-21)
### Documentation
* docs: add widget to documentation ([`6fa1c06`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6fa1c06053131dabd084bb3cf13c853b5d3ce833))
### Feature
* feat: introduce BECStatusBox Widget ([`443b6c1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/443b6c1d7b02c772fda02e2d1eefd5bd40249e0c))
### Refactor
* refactor: Change inheritance to QTreeWidget from QWidget ([`d2f2b20`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d2f2b206bb0eab60b8a9b0d0ac60a6b7887fa6fb))
### Test
* test: add test suite for bec_status_box and status_item ([`5d4ca81`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5d4ca816cdedec4c88aba9eb326f85392504ea1c))
### Unknown
* Update file requirements.txt ([`505a5ec`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/505a5ec8334ff4422913b3a7b79d39bcb42ad535))
## v0.66.1 (2024-06-20)
### Fix
* fix: fixed shutdown for pyside ([`2718bc6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2718bc624731301756df524d0d5beef6cb1c1430))
## v0.66.0 (2024-06-20)
### Feature
* feat(rpc): discover widgets automatically ([`ef25f56`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ef25f5638032f931ceb292540ada618508bb2aed))
## v0.65.2 (2024-06-20)
### Fix
* fix(pyqt): webengine must be imported before qcoreapplication ([`cbbd23a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cbbd23aa33095141e4c265719d176c4aa8c25996))
## v0.65.1 (2024-06-20)
### Fix
* fix: prevent segfault by closing the QCoreApplication, if any ([`fa344a5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fa344a5799b07a2d8ace63cc7010b69bc4ed6f1d))
## v0.65.0 (2024-06-20)
### Feature
* feat(device_input): DeviceLineEdit with QCompleter added ([`50e41ff`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/50e41ff26160ec26d77feb6d519e4dad902a9b9b))
* feat(device_combobox): DeviceInputBase and DeviceComboBox added ([`430b282`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/430b282039806e3fbc6cf98e958861a065760620))
### Fix
* fix(device_input_base): bug with setting config and overwriting default device and filter ([`d79f7e9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d79f7e9ccde03dc77819ca556c79736d30f7821a))
### Test
* test(device_input): tests added ([`1a0a98a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1a0a98a45367db414bed813bbd346b3e1ae8d550))
## v0.64.2 (2024-06-19)
### Fix
* fix(client_utils): added close rpc command to shutdown of gui from bec_ipython_client ([`e5a7d47`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e5a7d47b21cbf066f740f1d11d7c9ea7c70f3080))
## v0.64.1 (2024-06-19)
### Fix
* fix(widgets): removed widget module import of sub widgets ([`216511b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/216511b951ff0e15b6d7c70133095f3ac45c23f4))
### Refactor
* refactor(utils): moved get_rpc_widgets to plugin_utils ([`6dabbf8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6dabbf874fbbdde89c34a7885bf95aa9c895a28b))
### Test
* test: moved rpc_classes test ([`b3575eb`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b3575eb06852b456cde915dfda281a3e778e3aeb))
## v0.64.0 (2024-06-19)
### Ci
* ci: add job optional dependency check ([`27426ce`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/27426ce7a52b4cbad7f3bef114d6efe6ad73bd7f))
### Documentation
* docs: fix links in developer section ([`9e16f2f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9e16f2faf9c59a5d36ae878512c5a910cca31e69))
* docs: refactor developer section, add widget tutorial ([`2a36d93`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2a36d9364f242bf42e4cda4b50e6f46aa3833bbd))
### Feature
* feat: add option to change size of the fonts ([`ea805d1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ea805d1362fc084d3b703b6f81b0180072f0825d))
### Fix
* fix(plot_base): font size is set with setScale which is scaling the whole legend window ([`5d66720`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5d6672069ea1cbceb62104f66c127e4e3c23e4a4))
### Test
* test: add tests ([`140ad83`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/140ad83380808928edf7953e23c762ab72a0a1e9))
## v0.63.2 (2024-06-14)
@@ -120,6 +16,7 @@ Like with QtWebEngine ([`6f96498`](https://gitlab.psi.ch/bec/bec_widgets/-/commi
This reverts commit fe04dd80e59a0e74f7fdea603e0642707ecc7c2a. ([`836b6e6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/836b6e64f694916d6b6f909dedf11a4a6d2c86a4))
## v0.63.1 (2024-06-13)
### Fix
@@ -129,6 +26,7 @@ This reverts commit fe04dd80e59a0e74f7fdea603e0642707ecc7c2a. ([`836b6e6`](https
The proper finalization sequence will be executed by the remote process
on SIGTERM ([`9263f8e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9263f8ef5c17ae7a007a1a564baf787b39061756))
## v0.63.0 (2024-06-13)
### Documentation
@@ -153,6 +51,7 @@ on SIGTERM ([`9263f8e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9263f8ef5
This reverts commit abc6caa2d0b6141dfbe1f3d025f78ae14deddcb3 ([`fe04dd8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fe04dd80e59a0e74f7fdea603e0642707ecc7c2a))
## v0.62.0 (2024-06-12)
### Feature
@@ -163,6 +62,7 @@ This reverts commit abc6caa2d0b6141dfbe1f3d025f78ae14deddcb3 ([`fe04dd8`](https:
* doc: add documentation about creating custom GUI applications embedding BEC Widgets ([`17a0068`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/17a00687579f5efab1990cd83862ec0e78198633))
## v0.61.0 (2024-06-12)
### Feature
@@ -173,8 +73,104 @@ This reverts commit abc6caa2d0b6141dfbe1f3d025f78ae14deddcb3 ([`fe04dd8`](https:
* refactor: improve labe of auto_update script ([`40b5688`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/40b568815893cd41af3531bb2e647ca1e2e315f4))
## v0.60.0 (2024-06-08)
### Ci
* ci: added git fetch for target branch ([`fc4f4f8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fc4f4f81ad1be99cf5112f2188a46c5bed2679ee))
* ci: fixed pylint-check ([`6b1d582`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6b1d5827d6599f06a3acd316060a8d25f0686d54))
* ci: cleanup ([`11173b9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/11173b9c0a7dc4b36e35962042e5b86407da49f1))
### Feature
* feat: added isort to bw-generate-cli ([`f0391f5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f0391f59c9eb0a51b693fccfe2e399e869d35dda))
* feat: added entry point for bw-generate-cli ([`1c7f491`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1c7f4912ce5998e666276969bf4af8656d619a91))
* feat(cli): auto-discover rpc-enabled widgets ([`df1be10`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/df1be10057a5e85a3f35bef1c1b27366b6727276))
### Fix
* fix: removed BECConnector from rpc client interface ([`6428e38`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6428e38ab94c15a2c904e75cc6404bb6d0394e04))
* fix: added bec_ipython_client as dependency; needed for jupyter widget ([`006a089`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/006a0894b85cba3b2773737ed6fe3e92c81cdee0))
* fix(BECFigure): removed duplicated user access for plot ([`954c576`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/954c576131f7deac669ddf9f51eeb1d41b6f92b7))
* fix(bec_connector): field validator should be a classmethod ([`867720a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/867720a897b6713bd0df9af71ffdd11a6a380f7d))
### Refactor
* refactor: minor cleanup ([`3adf6cf`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3adf6cfd586355c8b8ce7fdc9722f868e22287c5))
* refactor: disabled pylint for auto-gen client ([`b15816c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b15816ca9fd3e4ae87cca5fcfe029b4dfca570ca))
* refactor(isort): added bec_widgets as known first party package ([`9c5a471`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9c5a471234ed2928e4527b079436db2a807c5f6f))
* refactor(dock): parent_dock_area changed to orig_area (native for pyqtgraph) ([`2b40602`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2b40602bdc593ece0447ec926c2100414bd5cf67))
### Test
* test: added missing pylint statement to header ([`f662985`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f6629852ebc2b4ee239fa560cc310a5ae2627cf7))
## v0.59.1 (2024-06-07)
### Fix
* fix(curve): set_color_map_z typo fixed in user access ([`e7838b0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e7838b0f2fc23b0a232ed7d68fbd7f3493a91b9e))
## v0.59.0 (2024-06-07)
### Build
* build: added webengine dependency ([`d56c549`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d56c5493cd28f379d04a79d90b01c73b0760da1b))
### Ci
* ci: merged additional tests to parallel matrix job ([`178fe4d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/178fe4d2da3a959f7cd90e7ea0f47314dc1ef4ed))
* ci: added webengine dependencies ([`2d79ef8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2d79ef8fe5e52c61f4a78782770377cd6b41958b))
### Documentation
* docs: added website docs ([`cf6e5a4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cf6e5a40fc8320e9898a446a5bf14b77e94ef013))
### Feature
* feat(widget): added simple website widget with rpc ([`64abd67`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/64abd67b9b416bff9c89880b248d6e8639aa1e70))
## v0.58.1 (2024-06-07)
### Fix
* fix(dock): new dock can be detached upon creation ([`02a2608`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/02a26086c4540127a11c235cba30afc4fd712007))
## v0.58.0 (2024-06-07)
### Feature
* feat(utils.colors): general color validators ([`3094632`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/30946321348abc349fb4003dc39d0232dc19606c))
### Fix
* fix: bar colormap dynamic setting ([`67fd5e8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/67fd5e8581f60fe64027ac57f1f12cefa4d28343))
* fix: formatting isort ([`bf699ec`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/bf699ec1fbe2aacd31854e84fb0438c336840fcf))
* fix(curve): 2D scatter updated if color_map_z is changed ([`6985ff0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6985ff0fcef9791b53198206ec8cbccd1d65ef99))
* fix(curve): color_map_z setting works ([`33f7be4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/33f7be42c512402dab3fdd9781a8234e3ec5f4ba))
### Test
* test(color): validation tests added ([`c0ddece`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c0ddeceeeabacbf33019a8f24b18821926dc17ac))
## v0.57.7 (2024-06-07)

View File

@@ -13,7 +13,6 @@ class Widgets(str, enum.Enum):
Enum for the available widgets.
"""
BECStatusBox = "BECStatusBox"
BECDock = "BECDock"
BECDockArea = "BECDockArea"
BECFigure = "BECFigure"
@@ -1045,37 +1044,33 @@ class BECImageShow(RPCBase):
- y_scale: Literal["linear", "log"]
- x_lim: tuple
- y_lim: tuple
- legend_label_size: int
"""
@rpc_call
def set_title(self, title: "str", size: "int" = None):
def set_title(self, title: "str"):
"""
Set the title of the plot widget.
Args:
title(str): Title of the plot widget.
size(int): Font size of the title.
"""
@rpc_call
def set_x_label(self, label: "str", size: "int" = None):
def set_x_label(self, label: "str"):
"""
Set the label of the x-axis.
Args:
label(str): Label of the x-axis.
size(int): Font size of the label.
"""
@rpc_call
def set_y_label(self, label: "str", size: "int" = None):
def set_y_label(self, label: "str"):
"""
Set the label of the y-axis.
Args:
label(str): Label of the y-axis.
size(int): Font size of the label.
"""
@rpc_call
@@ -1273,37 +1268,33 @@ class BECPlotBase(RPCBase):
- y_scale: Literal["linear", "log"]
- x_lim: tuple
- y_lim: tuple
- legend_label_size: int
"""
@rpc_call
def set_title(self, title: "str", size: "int" = None):
def set_title(self, title: "str"):
"""
Set the title of the plot widget.
Args:
title(str): Title of the plot widget.
size(int): Font size of the title.
"""
@rpc_call
def set_x_label(self, label: "str", size: "int" = None):
def set_x_label(self, label: "str"):
"""
Set the label of the x-axis.
Args:
label(str): Label of the x-axis.
size(int): Font size of the label.
"""
@rpc_call
def set_y_label(self, label: "str", size: "int" = None):
def set_y_label(self, label: "str"):
"""
Set the label of the y-axis.
Args:
label(str): Label of the y-axis.
size(int): Font size of the label.
"""
@rpc_call
@@ -1379,33 +1370,6 @@ class BECPlotBase(RPCBase):
Remove the plot widget from the figure.
"""
@rpc_call
def set_legend_label_size(self, size: "int" = None):
"""
Set the font size of the legend.
Args:
size(int): Font size of the legend.
"""
class BECStatusBox(RPCBase):
@property
@rpc_call
def config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
class BECWaveform(RPCBase):
@property
@@ -1552,37 +1516,33 @@ class BECWaveform(RPCBase):
- y_scale: Literal["linear", "log"]
- x_lim: tuple
- y_lim: tuple
- legend_label_size: int
"""
@rpc_call
def set_title(self, title: "str", size: "int" = None):
def set_title(self, title: "str"):
"""
Set the title of the plot widget.
Args:
title(str): Title of the plot widget.
size(int): Font size of the title.
"""
@rpc_call
def set_x_label(self, label: "str", size: "int" = None):
def set_x_label(self, label: "str"):
"""
Set the label of the x-axis.
Args:
label(str): Label of the x-axis.
size(int): Font size of the label.
"""
@rpc_call
def set_y_label(self, label: "str", size: "int" = None):
def set_y_label(self, label: "str"):
"""
Set the label of the y-axis.
Args:
label(str): Label of the y-axis.
size(int): Font size of the label.
"""
@rpc_call
@@ -1658,69 +1618,6 @@ class BECWaveform(RPCBase):
Remove the plot widget from the figure.
"""
@rpc_call
def set_legend_label_size(self, size: "int" = None):
"""
Set the font size of the legend.
Args:
size(int): Font size of the legend.
"""
class DeviceComboBox(RPCBase):
@property
@rpc_call
def config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
class DeviceInputBase(RPCBase):
@property
@rpc_call
def config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
class DeviceLineEdit(RPCBase):
@property
@rpc_call
def config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
class Ring(RPCBase):
@rpc_call
@@ -2023,7 +1920,7 @@ class TextBox(RPCBase):
@rpc_call
def set_color(self, background_color: str, font_color: str) -> None:
"""
Set the background color of the widget.
Set the background color of the Widget.
Args:
background_color (str): The color to set the background in HEX.
@@ -2033,19 +1930,13 @@ class TextBox(RPCBase):
@rpc_call
def set_text(self, text: str) -> None:
"""
Set the text of the widget.
Args:
text (str): The text to set.
Set the text of the Widget
"""
@rpc_call
def set_font_size(self, size: int) -> None:
"""
Set the font size of the text in the widget.
Args:
size (int): The font size to set.
Set the font size of the text in the Widget.
"""

View File

@@ -173,15 +173,8 @@ class BECGuiClientMixin:
def close(self) -> None:
"""
Close the gui window.
Close the figure.
"""
if self._process is None:
return
self._run_rpc("close", (), wait_for_rpc_response=False)
while self.gui_is_alive():
time.sleep(0.2)
self._client.shutdown()
if self._process:
self._process.terminate()

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import argparse
import importlib
import inspect
import os
import sys
@@ -9,8 +10,9 @@ from typing import Literal
import black
import isort
from qtpy.QtWidgets import QGraphicsWidget, QWidget
from bec_widgets.utils.plugin_utils import get_rpc_classes
from bec_widgets.utils import BECConnector
if sys.version_info >= (3, 11):
from typing import get_overloads
@@ -136,6 +138,50 @@ class {class_name}(RPCBase):"""
with open(file_name, "w", encoding="utf-8") as file:
file.write(formatted_content)
@staticmethod
def get_rpc_classes(
repo_name: str,
) -> dict[Literal["connector_classes", "top_level_classes"], list[type]]:
"""
Get all RPC-enabled classes in the specified repository.
Args:
repo_name(str): The name of the repository.
Returns:
dict: A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
"""
connector_classes = []
top_level_classes = []
anchor_module = importlib.import_module(f"{repo_name}.widgets")
directory = os.path.dirname(anchor_module.__file__)
for root, _, files in sorted(os.walk(directory)):
for file in files:
if not file.endswith(".py") or file.startswith("__"):
continue
path = os.path.join(root, file)
subs = os.path.dirname(os.path.relpath(path, directory)).split("/")
if len(subs) == 1 and not subs[0]:
module_name = file.split(".")[0]
else:
module_name = ".".join(subs + [file.split(".")[0]])
module = importlib.import_module(f"{repo_name}.widgets.{module_name}")
for name in dir(module):
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 len(subs) == 1 and (
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
):
top_level_classes.append(obj)
return {"connector_classes": connector_classes, "top_level_classes": top_level_classes}
def main():
"""
@@ -151,7 +197,7 @@ def main():
current_path = os.path.dirname(__file__)
client_path = os.path.join(current_path, "client.py")
rpc_classes = get_rpc_classes("bec_widgets")
rpc_classes = ClientGenerator.get_rpc_classes("bec_widgets")
rpc_classes["connector_classes"].sort(key=lambda x: x.__name__)
generator = ClientGenerator()

View File

@@ -1,37 +1,22 @@
from bec_widgets.utils import BECConnector
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.spiral_progress_bar.spiral_progress_bar import SpiralProgressBar
from bec_widgets.widgets.text_box.text_box import TextBox
from bec_widgets.widgets.website.website import WebsiteWidget
class RPCWidgetHandler:
"""Handler class for creating widgets from RPC messages."""
def __init__(self):
self._widget_classes = None
widget_classes = {
"BECFigure": BECFigure,
"SpiralProgressBar": SpiralProgressBar,
"Website": WebsiteWidget,
"TextBox": TextBox,
}
@property
def widget_classes(self):
"""
Get the available widget classes.
Returns:
dict: The available widget classes.
"""
if self._widget_classes is None:
self.update_available_widgets()
return self._widget_classes
def update_available_widgets(self):
"""
Update the available widgets.
Returns:
None
"""
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"]}
def create_widget(self, widget_type, **kwargs) -> BECConnector:
@staticmethod
def create_widget(widget_type, **kwargs) -> BECConnector:
"""
Create a widget from an RPC message.
@@ -42,12 +27,7 @@ class RPCWidgetHandler:
Returns:
widget(BECConnector): The created widget.
"""
if self._widget_classes is None:
self.update_available_widgets()
widget_class = self._widget_classes.get(widget_type)
widget_class = RPCWidgetHandler.widget_classes.get(widget_type)
if widget_class:
return widget_class(**kwargs)
raise ValueError(f"Unknown widget type: {widget_type}")
widget_handler = RPCWidgetHandler()

View File

@@ -40,7 +40,7 @@ class BECWidgetsCLIServer:
self._shutdown_event = False
self._heartbeat_timer = QTimer()
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
self._heartbeat_timer.start(200)
self._heartbeat_timer.start(200) # Emit heartbeat every 1 seconds
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
@@ -105,7 +105,7 @@ class BECWidgetsCLIServer:
self.client.connector.set(
MessageEndpoints.gui_heartbeat(self.gui_id),
messages.StatusMessage(name=self.gui_id, status=1, info={}),
expire=1,
expire=10,
)
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector

View File

@@ -10,8 +10,8 @@ from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils import BECDispatcher, UILoader
from bec_widgets.widgets import BECFigure
from bec_widgets.widgets.dock.dock_area import BECDockArea
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.jupyter_console.jupyter_console import BECJupyterConsole
# class JupyterConsoleWidget(RichJupyterWidget): # pragma: no cover:

View File

@@ -1,5 +1,3 @@
from qtpy.QtWebEngineWidgets import QWebEngineView
from .bec_connector import BECConnector, ConnectionConfig
from .bec_dispatcher import BECDispatcher
from .bec_table import BECTable

View File

@@ -9,7 +9,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 QCoreApplication, QObject
from qtpy.QtCore import Signal as pyqtSignal
if TYPE_CHECKING:
@@ -116,16 +116,6 @@ class BECDispatcher:
cls._instance = None
cls._initialized = False
if not cls.qapp:
return
# shutdown QCoreApp if it exists
if PYQT5 or PYQT6:
cls.qapp.exit()
elif PYSIDE2 or PYSIDE6:
cls.qapp.shutdown()
cls.qapp = None
def connect_slot(
self, slot: Callable, topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]]
) -> None:

View File

@@ -1,10 +1,6 @@
import importlib
import inspect
import os
from typing import Literal
from bec_lib.plugin_helper import _get_available_plugins
from qtpy.QtWidgets import QGraphicsWidget, QWidget
from bec_widgets.utils import BECConnector
@@ -42,47 +38,3 @@ def get_plugin_widgets() -> dict[str, BECConnector]:
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]]:
"""
Get all RPC-enabled classes in the specified repository.
Args:
repo_name(str): The name of the repository.
Returns:
dict: A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
"""
connector_classes = []
top_level_classes = []
anchor_module = importlib.import_module(f"{repo_name}.widgets")
directory = os.path.dirname(anchor_module.__file__)
for root, _, files in sorted(os.walk(directory)):
for file in files:
if not file.endswith(".py") or file.startswith("__"):
continue
path = os.path.join(root, file)
subs = os.path.dirname(os.path.relpath(path, directory)).split("/")
if len(subs) == 1 and not subs[0]:
module_name = file.split(".")[0]
else:
module_name = ".".join(subs + [file.split(".")[0]])
module = importlib.import_module(f"{repo_name}.widgets.{module_name}")
for name in dir(module):
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 len(subs) == 1 and (
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
):
top_level_classes.append(obj)
return {"connector_classes": connector_classes, "top_level_classes": top_level_classes}

View File

@@ -1,5 +1,5 @@
# from .buttons import StopButton
# from .dock import BECDock, BECDockArea
# from .figure import BECFigure, FigureConfig
# from .scan_control import ScanControl
# from .spiral_progress_bar import SpiralProgressBar
from .buttons import StopButton
from .dock import BECDock, BECDockArea
from .figure import BECFigure, FigureConfig
from .scan_control import ScanControl
from .spiral_progress_bar import SpiralProgressBar

View File

@@ -1,352 +0,0 @@
"""This module contains the BECStatusBox widget, which displays the status of different BEC services in a collapsible tree widget.
The widget automatically updates the status of all running BEC services, and displays their status.
"""
from __future__ import annotations
import sys
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, QVBoxLayout, QWidget
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.widgets.bec_status_box.status_item import StatusItem
if TYPE_CHECKING:
from bec_lib.client import BECClient
# TODO : Put normal imports back when Pydantic gets faster
BECStatus = lazy_import_from("bec_lib.messages", ("BECStatus",))
class BECStatusBoxConfig(ConnectionConfig):
pass
class BECServiceInfoContainer(BaseModel):
"""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.",
)
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.
Args:
client (BECClient): The client object to connect to the BEC server.
"""
services_update = Signal(dict, dict)
def __init__(self, client: BECClient):
super().__init__()
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."""
# 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.
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".
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.
"""
CORE_SERVICES = ["DeviceServer", "ScanServer", "SciHub", "ScanBundler", "FileWriterManager"]
service_update = Signal(dict)
bec_core_state = Signal(str)
def __init__(
self,
parent=None,
service_name: str = "BEC Server",
client: BECClient = None,
config: BECStatusBoxConfig | dict = 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)
self.service_name = service_name
self.config = config
self.bec_service_info_container = {}
self.tree_items = {}
self.tree_top_item = None
self.bec_service_status = BECServiceStatusMixin(client=self.client)
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)
def init_ui(self) -> None:
"""Initialize the UI for the status box, and add QTreeWidget as the basis for the status box."""
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)
self.service_update.connect(top_label.update_config)
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.
Args:
service_name (str): The name of the service.
status (BECStatus): The status of the service.
info Optional(dict): The information about the service. Default is {}
metric Optional(dict): Metrics for the respective service. Default is None
Returns:
StatusItem: The status item widget.
"""
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,
},
)
return item
@Slot(str)
def update_top_item_status(self, status: BECStatus) -> None:
"""Method to update the status of the top item in the tree widget.
Gets the status from the Signal 'bec_core_state' and updates the StatusItem via the signal 'service_update'.
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())
def _update_bec_service_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.
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.
"""
container = self.bec_service_info_container.get(service_name, None)
if container:
container.status = status
container.info = info
container.metrics = metrics
return
service_info_item = BECServiceInfoContainer(
service_name=service_name, status=status, info=info, metrics=metrics
)
self.bec_service_info_container.update({service_name: service_info_item})
@Slot(dict, dict)
def update_service_status(self, services_info: dict, services_metric: dict) -> None:
"""Callback function services_metric from BECServiceStatusMixin.
It updates the status of all services.
Args:
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 = []
services_info = self.update_core_services(services_info, services_metric)
checked.extend(self.CORE_SERVICES)
for service_name, msg in sorted(services_info.items()):
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())
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.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
Args:
services_info (dict): A dictionary containing the service status of different services.
services_metric (dict): A dictionary containing the service metrics of different services.
Returns:
dict: The services_info dictionary after removing the info updates related to the CORE_SERVICES
"""
bec_core_state = "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())
continue
self.add_tree_item(service_name, msg.status, msg.info, metrics)
self.bec_core_state.emit(bec_core_state)
return services_info
def check_redundant_tree_items(self, checked: list) -> None:
"""Utility method to check and remove redundant objects from the BECStatusBox.
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]
for key in to_be_deleted:
item, _ = self.tree_items.pop(key)
self.tree_top_item.removeChild(item)
def add_tree_item(
self, service_name: str, status: BECStatus, info: dict = None, metrics: dict = None
) -> None:
"""Method to add a new QTreeWidgetItem together with a StatusItem to the tree widget.
Args:
service_name (str): The name of the service.
service_status_msg (StatusMessage): The status of 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 = 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 {}"
)
@Slot(QTreeWidgetItem, int)
def on_tree_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None:
"""Callback function for double clicks on individual QTreeWidgetItems in the collapsed section.
Args:
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()
def closeEvent(self, event):
super().cleanup()
QTreeWidget().closeEvent(event)
def main():
"""Main method to run the BECStatusBox widget."""
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
qdarktheme.setup_theme("auto")
main_window = BECStatusBox()
main_window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()

View File

@@ -1,171 +0,0 @@
""" Module for a StatusItem widget to display status and metrics for a BEC service.
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",))
class IconsEnum(enum.Enum):
"""Enum class for icons in the status item widget."""
RUNNING = "SP_DialogApplyButton"
BUSY = "SP_BrowserReload"
IDLE = "SP_MessageBoxWarning"
ERROR = "SP_DialogCancelButton"
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.
"""
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
QWidget.__init__(self, parent=parent)
self.parent = parent
self.layout = None
self.config = config
self._popup_label_ref = {}
self._label = None
self._icon = None
self.init_ui()
def init_ui(self) -> None:
"""Init the UI for the status item widget."""
self.layout = QHBoxLayout()
self.layout.setContentsMargins(5, 5, 5, 5)
self.setLayout(self.layout)
self._label = QLabel()
self._icon = QLabel()
self.layout.addWidget(self._label)
self.layout.addWidget(self._icon)
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.
Args:
config (dict): Config updates from parent widget.
"""
if config["service_name"] != self.config.service_name:
return
self.config.status = config["status"]
self.config.info = config["info"]
self.config.metrics = config["metrics"]
self.update_ui()
def update_ui(self) -> None:
"""Update the UI of the labels, and popup dialog."""
self.set_text()
self.set_status()
self._set_popup_text()
def set_text(self) -> None:
"""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 status == "NOTCONNECTED":
status = "NOT CONNECTED"
text = f"{service} is {status}"
self._label.setText(text)
def set_status(self) -> None:
"""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.setAlignment(Qt.AlignmentFlag.AlignRight)
def show_popup(self) -> None:
"""Method that is invoked when the user double clicks on the StatusItem widget."""
dialog = QDialog(self)
dialog.setWindowTitle(f"{self.config.service_name} Details")
layout = QVBoxLayout()
popup_label = self._make_popup_label()
self._set_popup_text()
layout.addWidget(popup_label)
dialog.setLayout(layout)
dialog.finished.connect(self._cleanup_popup_label)
dialog.exec()
def _make_popup_label(self) -> QLabel:
"""Create a QLabel for the popup dialog.
Returns:
QLabel: The label for the popup dialog.
"""
label = QLabel()
label.setWordWrap(True)
self._popup_label_ref.update({"label": label})
return label
def _set_popup_text(self) -> None:
"""Compile the metrics text for the status item widget."""
if self._popup_label_ref.get("label") is None:
return
metrics_text = (
f"<b>SERVICE:</b> {self.config.service_name}<br><b>STATUS:</b> {self.config.status}<br>"
)
if self.config.metrics:
for key, value in self.config.metrics.items():
if key == "create_time":
value = datetime.fromtimestamp(value).strftime("%Y-%m-%d %H:%M:%S")
metrics_text += f"<b>{key.upper()}:</b> {value}<br>"
self._popup_label_ref["label"].setText(metrics_text)
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,2 +0,0 @@
from .device_combobox.device_combobox import DeviceComboBox
from .device_line_edit.device_line_edit import DeviceLineEdit

View File

@@ -1,95 +0,0 @@
from typing import TYPE_CHECKING
from qtpy.QtWidgets import QComboBox
from bec_widgets.widgets.device_inputs.device_input_base import DeviceInputBase, DeviceInputConfig
if TYPE_CHECKING:
from bec_widgets.widgets.device_inputs.device_input_base import DeviceInputConfig
class DeviceComboBox(DeviceInputBase, QComboBox):
"""
Line edit widget for device input with autocomplete for device names.
Args:
parent: Parent widget.
client: BEC client object.
config: Device input configuration.
gui_id: GUI ID.
device_filter: Device filter, name of the device class.
default_device: Default device name.
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
def __init__(
self,
parent=None,
client=None,
config: DeviceInputConfig = None,
gui_id: str | None = None,
device_filter: str | None = None,
default_device: str | None = None,
arg_name: str | None = None,
):
super().__init__(client=client, config=config, gui_id=gui_id)
QComboBox.__init__(self, parent=parent)
self.populate_combobox()
if arg_name is not None:
self.config.arg_name = arg_name
if device_filter is not None:
self.set_device_filter(device_filter)
if default_device is not None:
self.set_default_device(default_device)
def set_device_filter(self, device_filter: str):
"""
Set the device filter.
Args:
device_filter(str): Device filter, name of the device class.
"""
super().set_device_filter(device_filter)
self.populate_combobox()
def set_default_device(self, default_device: str):
"""
Set the default device.
Args:
default_device(str): Default device name.
"""
super().set_default_device(default_device)
self.setCurrentText(default_device)
def populate_combobox(self):
"""Populate the combobox with the devices."""
self.devices = self.get_device_list(self.config.device_filter)
self.clear()
self.addItems(self.devices)
def get_device(self) -> object:
"""
Get the selected device object.
Returns:
object: Device object.
"""
device_name = self.currentText()
device_obj = getattr(self.dev, device_name.lower(), None)
if device_obj is None:
raise ValueError(f"Device {device_name} is not found.")
return device_obj
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
w = DeviceComboBox(default_device="samx")
w.show()
sys.exit(app.exec_())

View File

@@ -1,120 +0,0 @@
from __future__ import annotations
from bec_widgets.utils import BECConnector, ConnectionConfig
class DeviceInputConfig(ConnectionConfig):
device_filter: str | list[str] | None = None
default_device: str | None = None
arg_name: str | None = None
class DeviceInputBase(BECConnector):
"""
Mixin class for device input widgets. This class provides methods to get the device list and device object based
on the current text of the widget.
"""
def __init__(self, client=None, config=None, gui_id=None):
if config is None:
config = DeviceInputConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = DeviceInputConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
self.get_bec_shortcuts()
self._devices = []
@property
def devices(self) -> list[str]:
"""
Get the list of devices.
Returns:
list[str]: List of devices.
"""
return self._devices
@devices.setter
def devices(self, value: list[str]):
"""
Set the list of devices.
Args:
value: List of devices.
"""
self._devices = value
def set_device_filter(self, device_filter: str | list[str]):
"""
Set the device filter.
Args:
device_filter(str): Device filter, name of the device class.
"""
self.validate_device_filter(device_filter)
self.config.device_filter = device_filter
def set_default_device(self, default_device: str):
"""
Set the default device.
Args:
default_device(str): Default device name.
"""
self.validate_device(default_device)
self.config.default_device = default_device
def get_device_list(self, filter: str | list[str] | None = None) -> list[str]:
"""
Get the list of device names based on the filter of current BEC client.
Args:
filter(str|None): Class name filter to apply on the device list.
Returns:
devices(list[str]): List of device names.
"""
all_devices = self.dev.enabled_devices
if filter is not None:
self.validate_device_filter(filter)
if isinstance(filter, str):
filter = [filter]
devices = [device.name for device in all_devices if device.__class__.__name__ in filter]
else:
devices = [device.name for device in all_devices]
return devices
def get_available_filters(self):
"""
Get the available device classes which can be used as filters.
"""
all_devices = self.dev.enabled_devices
filters = {device.__class__.__name__ for device in all_devices}
return filters
def validate_device_filter(self, filter: str | list[str]) -> None:
"""
Validate the device filter if the class name is present in the current BEC instance.
Args:
filter(str|list[str]): Class name to use as a device filter.
"""
if isinstance(filter, str):
filter = [filter]
available_filters = self.get_available_filters()
for f in filter:
if f not in available_filters:
raise ValueError(f"Device filter {f} is not valid.")
def validate_device(self, device: str) -> None:
"""
Validate the device if it is present in current BEC instance.
Args:
device(str): Device to validate.
"""
if device not in self.get_device_list(self.config.device_filter):
raise ValueError(f"Device {device} is not valid.")

View File

@@ -1,102 +0,0 @@
from typing import TYPE_CHECKING
from qtpy.QtWidgets import QCompleter, QLineEdit
from bec_widgets.widgets.device_inputs.device_input_base import DeviceInputBase, DeviceInputConfig
if TYPE_CHECKING:
from bec_widgets.widgets.device_inputs.device_input_base import DeviceInputConfig
class DeviceLineEdit(DeviceInputBase, QLineEdit):
"""
Line edit widget for device input with autocomplete for device names.
Args:
parent: Parent widget.
client: BEC client object.
config: Device input configuration.
gui_id: GUI ID.
device_filter: Device filter, name of the device class.
default_device: Default device name.
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
def __init__(
self,
parent=None,
client=None,
config: DeviceInputConfig = None,
gui_id: str | None = None,
device_filter: str | list[str] | None = None,
default_device: str | None = None,
arg_name: str | None = None,
):
QLineEdit.__init__(self, parent=parent)
DeviceInputBase.__init__(self, client=client, config=config, gui_id=gui_id)
self.completer = QCompleter(self)
self.setCompleter(self.completer)
self.populate_completer()
if arg_name is not None:
self.config.arg_name = arg_name
if device_filter is not None:
self.set_device_filter(device_filter)
if default_device is not None:
self.set_default_device(default_device)
def set_device_filter(self, device_filter: str | list[str]):
"""
Set the device filter.
Args:
device_filter (str | list[str]): Device filter, name of the device class.
"""
super().set_device_filter(device_filter)
self.populate_completer()
def set_default_device(self, default_device: str):
"""
Set the default device.
Args:
default_device (str): Default device name.
"""
super().set_default_device(default_device)
self.setText(default_device)
def populate_completer(self):
"""Populate the completer with the devices."""
self.devices = self.get_device_list(self.config.device_filter)
self.completer.setModel(self.create_completer_model(self.devices))
def create_completer_model(self, devices: list[str]):
"""Create a model for the completer."""
from qtpy.QtCore import QStringListModel
return QStringListModel(devices, self)
def get_device(self) -> object:
"""
Get the selected device object.
Returns:
object: Device object.
"""
device_name = self.text()
device_obj = getattr(self.dev, device_name.lower(), None)
if device_obj is None:
raise ValueError(f"Device {device_name} is not found.")
return device_obj
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
w = DeviceLineEdit(default_device="samx")
w.show()
sys.exit(app.exec_())

View File

@@ -5,13 +5,13 @@ from typing import TYPE_CHECKING, Any, Literal, Optional
from pydantic import Field
from pyqtgraph.dockarea import Dock
from bec_widgets.cli.rpc_wigdet_handler import widget_handler
from bec_widgets.cli.rpc_wigdet_handler import RPCWidgetHandler
from bec_widgets.utils import BECConnector, ConnectionConfig, GridLayoutManager
if TYPE_CHECKING:
from qtpy.QtWidgets import QWidget
from bec_widgets.widgets.dock import BECDockArea
from bec_widgets.widgets import BECDockArea
class DockConfig(ConnectionConfig):
@@ -149,7 +149,7 @@ class BECDock(BECConnector, Dock):
Returns:
list: The list of eligible widgets.
"""
return list(widget_handler.widget_classes.keys())
return list(RPCWidgetHandler.widget_classes.keys())
def add_widget(
self,
@@ -178,7 +178,7 @@ class BECDock(BECConnector, Dock):
self.layout_manager.shift_widgets(shift, start_row=row)
if isinstance(widget, str):
widget = widget_handler.create_widget(widget)
widget = RPCWidgetHandler.create_widget(widget)
else:
widget = widget

View File

@@ -711,12 +711,6 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
qdarktheme.setup_theme(theme)
self.setBackground("k" if theme == "dark" else "w")
self.config.theme = theme
for plot in self.widget_list:
plot.set_x_label(plot.plot_item.getAxis("bottom").label.toPlainText())
plot.set_y_label(plot.plot_item.getAxis("left").label.toPlainText())
if plot.plot_item.titleLabel.text:
plot.set_title(plot.plot_item.titleLabel.text)
plot.set_legend_label_size()
def _remove_by_coordinates(self, row: int, col: int) -> None:
"""

View File

@@ -5,8 +5,6 @@ from typing import Literal, Optional
import numpy as np
import pyqtgraph as pg
from pydantic import BaseModel, Field
from qtpy import QT_VERSION
from qtpy.QtGui import QFont, QFontDatabase, QFontInfo
from qtpy.QtWidgets import QWidget
from bec_widgets.utils import BECConnector, ConnectionConfig
@@ -14,14 +12,8 @@ from bec_widgets.utils import BECConnector, ConnectionConfig
class AxisConfig(BaseModel):
title: Optional[str] = Field(None, description="The title of the axes.")
title_size: Optional[int] = Field(None, description="The font size of the title.")
x_label: Optional[str] = Field(None, description="The label for the x-axis.")
x_label_size: Optional[int] = Field(None, description="The font size of the x-axis label.")
y_label: Optional[str] = Field(None, description="The label for the y-axis.")
y_label_size: Optional[int] = Field(None, description="The font size of the y-axis label.")
legend_label_size: Optional[int] = Field(
None, description="The font size of the legend labels."
)
x_scale: Literal["linear", "log"] = Field("linear", description="The scale of the x-axis.")
y_scale: Literal["linear", "log"] = Field("linear", description="The scale of the y-axis.")
x_lim: Optional[tuple] = Field(None, description="The limits of the x-axis.")
@@ -58,7 +50,6 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
"set_grid",
"lock_aspect_ratio",
"remove",
"set_legend_label_size",
]
def __init__(
@@ -94,7 +85,6 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
- y_scale: Literal["linear", "log"]
- x_lim: tuple
- y_lim: tuple
- legend_label_size: int
"""
# Mapping of keywords to setter methods
method_map = {
@@ -105,7 +95,6 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
"y_scale": self.set_y_scale,
"x_lim": self.set_x_lim,
"y_lim": self.set_y_lim,
"legend_label_size": self.set_legend_label_size,
}
for key, value in kwargs.items():
if key in method_map:
@@ -127,79 +116,34 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
self.set(**{k: v for k, v in config_mappings.items() if v is not None})
def set_legend_label_size(self, size: int = None):
"""
Set the font size of the legend.
Args:
size(int): Font size of the legend.
"""
if not self.plot_item.legend:
return
if self.config.axis.legend_label_size or size:
if size:
self.config.axis.legend_label_size = size
scale = (
size / 9
) # 9 is the default font size of the legend, so we always scale it against 9
self.plot_item.legend.setScale(scale)
def get_text_color(self):
return "#FFF" if self.figure.config.theme == "dark" else "#000"
def set_title(self, title: str, size: int = None):
def set_title(self, title: str):
"""
Set the title of the plot widget.
Args:
title(str): Title of the plot widget.
size(int): Font size of the title.
"""
if self.config.axis.title_size or size:
if size:
self.config.axis.title_size = size
style = {"color": self.get_text_color(), "size": f"{self.config.axis.title_size}pt"}
else:
style = {}
self.plot_item.setTitle(title, **style)
self.plot_item.setTitle(title)
self.config.axis.title = title
def set_x_label(self, label: str, size: int = None):
def set_x_label(self, label: str):
"""
Set the label of the x-axis.
Args:
label(str): Label of the x-axis.
size(int): Font size of the label.
"""
if self.config.axis.x_label_size or size:
if size:
self.config.axis.x_label_size = size
style = {
"color": self.get_text_color(),
"font-size": f"{self.config.axis.x_label_size}pt",
}
else:
style = {}
self.plot_item.setLabel("bottom", label, **style)
self.plot_item.setLabel("bottom", label)
self.config.axis.x_label = label
def set_y_label(self, label: str, size: int = None):
def set_y_label(self, label: str):
"""
Set the label of the y-axis.
Args:
label(str): Label of the y-axis.
size(int): Font size of the label.
"""
if self.config.axis.y_label_size or size:
if size:
self.config.axis.y_label_size = size
color = self.get_text_color()
style = {"color": color, "font-size": f"{self.config.axis.y_label_size}pt"}
else:
style = {}
self.plot_item.setLabel("left", label, **style)
self.plot_item.setLabel("left", label)
self.config.axis.y_label = label
def set_x_scale(self, scale: Literal["linear", "log"] = "linear"):

View File

@@ -54,7 +54,6 @@ class BECWaveform(BECPlotBase):
"set_grid",
"lock_aspect_ratio",
"remove",
"set_legend_label_size",
]
scan_signal_update = pyqtSignal()
@@ -402,7 +401,6 @@ class BECWaveform(BECPlotBase):
self.config.curves[name] = curve.config
if data is not None:
curve.setData(data[0], data[1])
self.set_legend_label_size()
return curve
def _validate_signal_entries(

View File

@@ -21,7 +21,7 @@ api_reference/api_reference.md
:gutter: 5
```{grid-item-card}
:link: developer.getting_started
:link: user.getting_started
:link-type: ref
:img-top: /assets/rocket_launch_48dp.svg
:text-align: center
@@ -32,7 +32,7 @@ Learn how to install BEC Widgets and get started with the framework.
```
```{grid-item-card}
:link: developer.widgets
:link: user.widgets
:link-type: ref
:img-top: /assets/apps_48dp.svg
:text-align: center

View File

@@ -0,0 +1,353 @@
(developer.widgets.how_to_develop_a_widget)=
# How to Develop a Widget
This section provides a step-by-step guide on how to develop a new widget for BEC Widgets. We will develop a simple widget that allows you to press a button and specify a user-defined action. The general widget will be based on a [QPushButton](https://doc.qt.io/qt-6/qpushbutton.html) which we will extend to be capable of communicating with BEC through the interface provided by BEC Widgets.
## Button to start a scan
Developing a new widget in BEC Widgets is straightforward. Let's create a widget that allows a user to press a button and execute a `line_scan` in BEC. The proper location to create a new widget is either in the `bec_widgets/widgets` directory, or the beamline plugin widget direction, i.e. `csaxs_bec/bec_widgets`, depending on where your development takes place.
### Step 1: Create a new widget class
We first create a simple class that inherits from the `QPushButton` class.
The following code snippet demonstrates how to create a new widget:
``` python
from qtpy.QtWidgets import QPushButton
class StartScanButton(QPushButton):
def __init__(self, parent=None):
QPushButton.__init__(self, parent=parent)
# Connect the button to the on_click method
self.clicked.connect(self.on_click)
def on_click(self):
pass
```
So far we have created the button, but we have not yet put any logic to the `on_click` event of the button.
Adding the functionality to be able to execute a scans will be tackled in the next step.
````{note}
To make the button work as a standalone application, you can simply add the following lines at the end.
``` python
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = StartScanButton()
widget.show()
sys.exit(app.exec_())
```
````
### Step 2: Connect with BEC, implement *on_click* functionality
To be able to start a scan, we need to communicate with BEC. This can be facilitated easily by inheriting additionally from [`BECConnector`](../../api_reference/_autosummary/bec_widgets.utils.bec_connector.BECConnector).
With the *BECConnector*, we will also have to pass the *client* ([BECClient](https://bec.readthedocs.io/en/latest/api_reference/_autosummary/bec_lib.client.BECClient.html)) and the *gui_id* (str) to init function of both, our *StartScanButton* widget and the `super().__init__(client=client, gui_id=gui_id)` call.
In the init of *BECConnector*, the client will be initialised and stored in `self.client`, which gives us access to the available scan objects via `self.client.scans`.
``` python
from qtpy.QtWidgets import QPushButton
from bec_widgets.utils import BECConnector
class StartScanButton(BECConnector, QPushButton):
def __init__(self, parent=None, client:=None, gui_id=None):
super().__init__(client=client, gui_id=gui_id)
QPushButton.__init__(self, parent=parent)
# Set a default scan command, args and kwargs
self.scan_name = "line_scan"
self.scan_args = (dev.samx, -5, 5)
self.scan_kwargs = {"steps": 50, "exp_time": 0.1, "relative": True}
# Set the text of the button to display the current scan name
self.set_button_text()
# Connect the button to the on_click method
self.clicked.connect(self.on_click)
def set_button_text(self):
"""Set the text of the button"""
self.setText(f"Start {self.scan_name}")
def run_command(self):
"""Run the scan command."""
# Get the scan command from the scans library
scan_command = getattr(self.client.scans, self.scan_name)
# Run the scan command
scan_report = scan_command(*self.scan_args, **self.scan_kwargs)
# Wait for the scan to finish
scan_report.wait()
def on_click(self):
"""Start a line scan"""
self.run_command()
```
```{note}
For the args and kwargs of the scan command, we are using the same syntax as in the client: `dev.samx` is not a string but the same object as in the client.
```
In the *run_command* method, we retrieve the scan object from the client by its name, and execute the method with all *args* and *kwargs* that we have set.
The current implementation of *run_command* is a blocking call due to `scan_report.wait()`, which is not ideal for a GUI application since it freezes the GUI. We will adress this in the next step.
### Step 3: Improving the widget interactivity
To not freeze the GUI, we need to run the scan command in a separate thread. We can either use [QThreads](https://doc.qt.io/qtforpython-6/PySide6/QtCore/QThread.html) or the Python [threading module](https://docs.python.org/3/library/threading.html#thread-objects). In this example, we will use the Python threading module. In addition, we add a method `update_style` to change the style of the button to indicate to the user that the scan is running. We also extend the cleanup procedure of `BECConnector` to ensure that the thread is stopped when the widget is closed. This is good practice to avoid having threads running in the background when the widget is closed.
``` python
def update_style(self, mode: Literal["ready", "running"]):
"""Update the style of the button based on the mode.
Args:
mode (Literal["ready", "running"): The mode of the button.
"""
if mode == "ready":
self.setStyleSheet(
"background-color: #4CAF50; color: white; font-size: 16px; padding: 10px 24px;"
)
elif mode == "running":
self.setStyleSheet(
"background-color: #808080; color: white; font-size: 16px; padding: 10px 24px;"
)
def run_command(self):
"""Run the scan command."""
# Switch the style of the button
self.update_style("running")
# Disable the buttom while the scan is running
self.setEnabled(False)
# Get the scan command from the scans library
scan_command = getattr(self.scans, self.scan_name)
# Run the scan command
scan_report = scan_command(*self.scan_args, **self.scan_kwargs)
# Wait for the scan to finish
scan_report.wait()
# Reactivate the button
self.setEnabled(True)
# Switch the style of the button back to ready
self.update_style("ready")
def on_click(self):
"""Start a line scan"""
thread = threading.Thread(target=self.run_command)
thread.start()
def cleanup(self):
"""Cleanup the widget"""
# stop thread
# stop the thread or if this is implemented via QThread, ensure stopping of QThread.
# Ideally, the BECConnector should take care of this automatically.
# Important to call super().cleanup() to ensure that the cleanup of the BECConnector is also called
super().cleanup()
```
We now added started the scan in a separate thread, which allows the GUI to remain responsive. We also added a method to change the style of the button to indicate to the user that the scan is running. The cleanup method ensures that the thread is stopped when the widget is closed. In a last step, we know like to make the scan command configurable.
### Step 4: Make the scan command configurable
In order to make the scan comman configurable, we implement a method `set_scan_command` which allows the user to set the scan command, arguments and keyword arguments.
This method should also become available through the RPC interface of BEC Widgets, so we add the class attribute `USER_ACCESS` which is a list of strings with functions that should become available for the CLI.
``` python
def set_scan_command(
self, scan_name: str, args: tuple, kwargs: dict
):
"""Set the scan command to run.
Args:
scan_name (str): The name of the scan command.
args (tuple): The arguments for the scan command.
kwargs (dict): The keyword arguments for the scan command.
"""
# check if scan_command starts with scans.
if not getattr(self.client.scans, scan_name):
raise ValueError(
f"The scan type must be implemented in the scan library of BEC, received {scan_name}"
)
self.scan_name = scan_name
self.scan_args = args
self.scan_kwargs = kwargs
self.set_button_text()
```
### Step 5: Generate client interface for RPC
We have now prepared the widget which is fully functional as a standalone widget. But we also want to make it available to the BEC command-line-interface (CLI), for which we prepared the **USER_ACCESS** class attribute.
The communication between the BEC IPythonClient and the widget is done vie the RPC interface of BEC Widgets.
For this, we need to run the `bec_widgets.cli.generate_cli` script to generate the CLI interface.
``` bash
python bec_widgets.cli.generate_cli --core
# alternatively use the entry point from BEC Widgets
bw-generate-cli
```
This will generate a new client with all relevant methods in [`bec_widgets.cli.client.py`](../../api_reference/_autosummary/bec_widgets.bec_widgets.cli.client.rst).
The last step is to make the RPCWidgetHandler class aware of the widget, which means to add the name of the widget to the widgets list in the [`RPCWidgetHandler`](../../api_reference/_autosummary/bec_widgets.bec_widgets.cli.rpc_widget_handler.RPCWidgetHandler.rst) class.
````{dropdown} View code: RPCWidgetHandler class
:icon: code-square
:animate: fade-in-slide-down
```{literalinclude} ../../../bec_widgets/cli/rpc_widget_handler.py
:language: python
:pyobject: RPCWidgetHandler
```
````
With this, we have a fully functional widget that allows the user to start a scan with a button. The scan command, arguments and keyword arguments can be set by the user.
The full code is shown once again below:
````{dropdown} View code: Full code of the StartScanButton widget
:icon: code-square
:animate: fade-in-slide-down
```
import threading
from typing import Literal
from qtpy.QtWidgets import QPushButton
from bec_widgets.utils import BECConnector
class StartScanButton(BECConnector, QPushButton):
"""A button to start a line scan.
Args:
parent: The parent widget.
client (BECClient): The BEC client.
gui_id (str): The unique ID of the widget.
"""
USER_ACCESS = ["set_scan_command"]
def __init__(self, parent=None, client=None, gui_id=None):
super().__init__(client=client, gui_id=gui_id)
QPushButton.__init__(self, parent=parent)
# Set the scan command to None
self.scan_command = None
# Set default scan command
self.scan_name = "line_scan"
self.scan_args = (dev.samx, -5, 5)
self.scan_kwargs = {"steps": 50, "exp_time": 0.1, "relative": True}
# Set the text of the button
self.set_button_text()
# Set the style of the button
self.update_style("ready")
# Connect the button to the on_click method
self.clicked.connect(self.on_click)
def update_style(self, mode: Literal["ready", "running"]):
"""Update the style of the button based on the mode.
Args:
mode (Literal["ready", "running"): The mode of the button.
"""
if mode == "ready":
self.setStyleSheet(
"background-color: #4CAF50; color: white; font-size: 16px; padding: 10px 24px;"
)
elif mode == "running":
self.setStyleSheet(
"background-color: #808080; color: white; font-size: 16px; padding: 10px 24px;"
)
def set_button_text(self):
"""Set the text of the button."""
self.setText(f"Start {self.scan_name}")
def set_scan_command(self, scan_name: str, args: tuple, kwargs: dict):
"""Set the scan command to run.
Args:
scan_name (str): The name of the scan command.
args (tuple): The arguments for the scan command.
kwargs (dict): The keyword arguments for the scan command.
"""
# check if scan_command starts with scans.
if not getattr(self.client.scans, scan_name):
raise ValueError(
f"The scan type must be implemented in the scan library of BEC, received {scan_name}"
)
self.scan_name = scan_name
self.scan_args = args
self.scan_kwargs = kwargs
self.set_button_text()
def run_command(self):
"""Run the scan command."""
# Switch the style of the button
self.update_style("running")
# Disable the buttom while the scan is running
self.setEnabled(False)
# Get the scan command from the scans library
scan_command = getattr(self.scans, self.scan_name)
# Run the scan command
scan_report = scan_command(*self.scan_args, **self.scan_kwargs)
# Wait for the scan to finish
scan_report.wait()
# Reactivate the button
self.setEnabled(True)
# Switch the style of the button back to ready
self.update_style("ready")
def on_click(self):
"""Start a line scan"""
thread = threading.Thread(target=self.run_command)
thread.start()
def cleanup(self):
"""Cleanup the widget"""
# stop thread
# stop the thread or if this is implemented via QThread, ensure stopping of QThread.
# Ideally, the BECConnector should take care of this automatically.
# Important to call super().cleanup() to ensure that the cleanup of the BECConnector is also called
super().cleanup()
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = StartScanButton()
widget.show()
sys.exit(app.exec_())
```
````
### Step 6: Write a test for the widget
We highly recommend writing tests for the widget to ensure that they work as expected. This allows to run the tests automatically in a CI/CD pipeline and to ensure that the widget works as expected not only now but als in the future.
The following code snippet shows an example to test the set_scan_command from the `StartScanButton` widget.
``` python
import pytest
from bec_widgets.widgets.start_scan_button import StartScanButton
from .client_mocks import mocked_client
@pytest.fixture
def test_scan_button(qtbot, mocked_client):
widget = StartScanButton(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.close()
def test_set_scan_command(test_scan_button):
"""Test the set_scan_command function."""
test_scan_button.set_scan_command(
scan_name="grid_scan",
args=(dev.samx, -5, 5, 10, dev.samy, -5, 5, 20),
kwargs={"exp_time": 0.1, "relative": True},
)
# Check first if all parameter have been properly set
assert test_scan_button.scan_name == "grid_scan"
assert test_scan_button.scan_args == (dev.samx, -5, 5, 10, dev.samy, -5, 5, 20)
assert test_scan_button.scan_kwargs == {"exp_time": 0.1, "relative": True}
# Next, we check if the displayed text of the button has been updated
# We use the .text() method from the QPushButton class to retrieve the text displayed
assert test_scan_button.text() == "Start grid_scan"
```

View File

@@ -8,4 +8,5 @@ maxdepth: 2
hidden: false
---
how_to_develop_a_widget/
```

View File

@@ -7,6 +7,5 @@ sphinx-copybutton
myst-parser
sphinx-design
PyQt6
PyQt6-WebEngine
bec-widgets
tomli

View File

@@ -48,7 +48,7 @@ users to interact. BEC Widgets must be placed in the window:
```
from qtpy.QWidgets import QMainWindow
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets import BECFigure
window = QMainWindow()
bec_figure = BECFigure(gui_id="my_gui_app_id")
@@ -78,7 +78,7 @@ Final example:
```
import sys
from qtpy.QtWidgets import QMainWindow, QApplication
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets import BECFigure
from bec_widgets.utils.bec_dispatcher import BECDispatcher
# creation of the Qt application

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -1,30 +0,0 @@
(user.widgets.bec_status_box)=
# BEC Status Box
**Purpose:**
The [BECStatusBox]](/api_reference/_autosummary/bec_widgets.cli.client.BECStatusBox) Widget is a widget that allows you to monitor the status/health of the all running BEC processes. The widget generates the view automatically and updates the status of the processes in real-time. The top level indicates the overall state of the BEC core services (DeviceServer, ScanServer, SciHub, ScanBundler and FileWriter), but you can also see the status of each individual process by opening the collapsed view. In the collapsed view, you can double click on each process to get a popup window with live updates of the metrics for each process in real-time.
**Key Features:**
- monitor the state of individual BEC services.
- automatically track BEC services, i.e. additional clients connecting.
- live-updates of the metrics for each process.
**Example of Use:**
![BECStatus](./bec_status_box.gif)
**Code example:**
The following code snipped demonstrates how to create a `BECStatusBox` widget using BEC Widgets within BEC.
```python
bec_status_box = gui.add_dock().add_widget("BECStatusBox")
```

View File

@@ -1,4 +1,5 @@
(user.widgets.buttons)=
# Buttons Widgets
This section consolidates various custom buttons used within the BEC GUIs, facilitating the integration of these
@@ -23,7 +24,7 @@ a `StopButton` within a GUI layout:
```python
from qtpy.QtWidgets import QWidget, QVBoxLayout
from bec_widgets.widgets.buttons import StopButton
from bec_widgets.widgets import StopButton
class MyGui(QWidget):

View File

@@ -13,8 +13,6 @@ spiral_progress_bar/
website/
buttons/
text_box/
bec_status_box/
```

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "0.67.0"
version = "0.63.2"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [

View File

@@ -8,8 +8,7 @@ from bec_lib.endpoints import MessageEndpoints
from bec_widgets.cli.client_utils import _start_plot_process
from bec_widgets.cli.rpc_register import RPCRegister
from bec_widgets.utils import BECDispatcher
from bec_widgets.widgets.dock import BECDockArea
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets import BECDockArea, BECFigure
# make threads check in autouse, **will be executed at the end**; better than

View File

@@ -47,7 +47,6 @@ class FakePositioner(FakeDevice):
super().__init__(name, enabled)
self.limits = limits if limits is not None else [0, 0]
self.read_value = read_value
self.name = name
def set_read_value(self, value):
self.read_value = value

View File

@@ -2,7 +2,7 @@
import pytest
from bec_widgets.widgets.dock import BECDock, BECDockArea
from bec_widgets.widgets import BECDock, BECDockArea
from .client_mocks import mocked_client

View File

@@ -3,7 +3,7 @@
import numpy as np
import pytest
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets import BECFigure
from bec_widgets.widgets.figure.plots.image.image import BECImageShow
from bec_widgets.widgets.figure.plots.motor_map.motor_map import BECMotorMap
from bec_widgets.widgets.figure.plots.waveform.waveform import BECWaveform

View File

@@ -1,152 +0,0 @@
import re
from unittest import mock
import pytest
from bec_lib.messages import BECStatus, ServiceMetricMessage, StatusMessage
from qtpy.QtCore import QMetaMethod
from bec_widgets.widgets.bec_status_box.bec_status_box import BECServiceInfoContainer, BECStatusBox
from .client_mocks import mocked_client
@pytest.fixture
def status_box(qtbot, mocked_client):
with mock.patch(
"bec_widgets.widgets.bec_status_box.bec_status_box.BECServiceStatusMixin"
) as mock_service_status_mixin:
widget = BECStatusBox(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_status_box_init(qtbot, mocked_client):
with mock.patch(
"bec_widgets.widgets.bec_status_box.bec_status_box.BECServiceStatusMixin"
) as mock_service_status_mixin:
name = "my test"
widget = BECStatusBox(parent=None, service_name=name, client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
assert widget.headerItem().DontShowIndicator.value == 1
assert widget.children()[0].children()[0].config.service_name == name
def test_update_top_item(qtbot, mocked_client):
with (
mock.patch(
"bec_widgets.widgets.bec_status_box.bec_status_box.BECServiceStatusMixin"
) as mock_service_status_mixin,
mock.patch(
"bec_widgets.widgets.bec_status_box.status_item.StatusItem.update_config"
) as mock_update,
):
name = "my test"
widget = BECStatusBox(parent=None, service_name=name, client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
widget.update_top_item_status(status="RUNNING")
assert widget.bec_service_info_container[name].status == "RUNNING"
assert mock_update.call_args == mock.call(widget.bec_service_info_container[name].dict())
def test_create_status_widget(status_box):
name = "test_service"
status = BECStatus.IDLE
info = {"test": "test"}
metrics = {"metric": "test_metric"}
item = status_box._create_status_widget(name, status, info, metrics)
assert item.config.service_name == name
assert item.config.status == status.name
assert item.config.info == info
assert item.config.metrics == metrics
def test_bec_service_container(status_box):
name = "test_service"
status = BECStatus.IDLE
info = {"test": "test"}
metrics = {"metric": "test_metric"}
expected_return = BECServiceInfoContainer(
service_name=name, status=status, 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
def test_add_tree_item(status_box):
name = "test_service"
status = BECStatus.IDLE
info = {"test": "test"}
metrics = {"metric": "test_metric"}
assert len(status_box.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
def test_update_service_status(status_box):
"""Also checks check redundant tree items"""
name = "test_service"
status = BECStatus.IDLE
info = {"test": "test"}
metrics = {"metric": "test_metric"}
status_box.add_tree_item(name, status, info, {})
not_connected_name = "invalid_service"
status_box.add_tree_item(not_connected_name, status, info, metrics)
services_status = {name: StatusMessage(name=name, status=status, info=info)}
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
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
def test_update_core_services(qtbot, mocked_client):
with (
mock.patch(
"bec_widgets.widgets.bec_status_box.bec_status_box.BECServiceStatusMixin"
) as mock_service_status_mixin,
mock.patch(
"bec_widgets.widgets.bec_status_box.bec_status_box.BECStatusBox.update_top_item_status"
) as mock_update,
):
name = "my test"
status_box = BECStatusBox(parent=None, service_name=name, client=mocked_client)
qtbot.addWidget(status_box)
qtbot.waitExposed(status_box)
status_box.CORE_SERVICES = ["test_service"]
name = "test_service"
status = BECStatus.RUNNING
info = {"test": "test"}
metrics = {"metric": "test_metric"}
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 mock_update.call_args == mock.call(status.name)
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 mock_update.call_args == mock.call("ERROR")
def test_double_click_item(status_box):
name = "test_service"
status = BECStatus.IDLE
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]
with mock.patch.object(status_item, "show_popup") as mock_show_popup:
status_box.itemDoubleClicked.emit(item, 0)
assert mock_show_popup.call_count == 1

View File

@@ -1,66 +0,0 @@
import pytest
from bec_widgets.widgets.device_inputs.device_input_base import DeviceInputBase
from .client_mocks import mocked_client
@pytest.fixture
def device_input_base(mocked_client):
widget = DeviceInputBase(client=mocked_client)
yield widget
def test_device_input_base_init(device_input_base):
assert device_input_base is not None
assert device_input_base.client is not None
assert isinstance(device_input_base, DeviceInputBase)
assert device_input_base.config.widget_class == "DeviceInputBase"
assert device_input_base.config.device_filter is None
assert device_input_base.config.default_device is None
assert device_input_base.devices == []
def test_device_input_base_init_with_config(mocked_client):
config = {
"widget_class": "DeviceInputBase",
"gui_id": "test_gui_id",
"device_filter": "FakePositioner",
"default_device": "samx",
}
widget = DeviceInputBase(client=mocked_client, config=config)
assert widget.config.gui_id == "test_gui_id"
assert widget.config.device_filter == "FakePositioner"
assert widget.config.default_device == "samx"
def test_device_input_base_set_device_filter(device_input_base):
device_input_base.set_device_filter("FakePositioner")
assert device_input_base.config.device_filter == "FakePositioner"
def test_device_input_base_set_device_filter_error(device_input_base):
with pytest.raises(ValueError) as excinfo:
device_input_base.set_device_filter("NonExistingClass")
assert "Device filter NonExistingClass is not in the device list." in str(excinfo.value)
def test_device_input_base_set_default_device(device_input_base):
device_input_base.set_default_device("samx")
assert device_input_base.config.default_device == "samx"
def test_device_input_base_set_default_device_error(device_input_base):
with pytest.raises(ValueError) as excinfo:
device_input_base.set_default_device("NonExistingDevice")
assert "Default device NonExistingDevice is not in the device list." in str(excinfo.value)
def test_device_input_base_get_device_list(device_input_base):
devices = device_input_base.get_device_list("FakePositioner")
assert devices == ["samx", "samy", "aptrx", "aptry"]
def test_device_input_base_get_filters(device_input_base):
filters = device_input_base.get_available_filters()
assert filters == {"FakePositioner", "FakeDevice"}

View File

@@ -1,176 +0,0 @@
import pytest
from bec_widgets.widgets.device_inputs.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.device_inputs.device_line_edit.device_line_edit import DeviceLineEdit
from .client_mocks import mocked_client
@pytest.fixture
def device_input_combobox(qtbot, mocked_client):
widget = DeviceComboBox(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.close()
@pytest.fixture
def device_input_combobox_with_config(qtbot, mocked_client):
config = {
"widget_class": "DeviceComboBox",
"gui_id": "test_gui_id",
"device_filter": "FakePositioner",
"default_device": "samx",
"arg_name": "test_arg_name",
}
widget = DeviceComboBox(client=mocked_client, config=config)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.close()
@pytest.fixture
def device_input_combobox_with_kwargs(qtbot, mocked_client):
widget = DeviceComboBox(
client=mocked_client,
gui_id="test_gui_id",
device_filter="FakePositioner",
default_device="samx",
arg_name="test_arg_name",
)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.close()
def test_device_input_combobox_init(device_input_combobox):
assert device_input_combobox is not None
assert device_input_combobox.client is not None
assert isinstance(device_input_combobox, DeviceComboBox)
assert device_input_combobox.config.widget_class == "DeviceComboBox"
assert device_input_combobox.config.device_filter is None
assert device_input_combobox.config.default_device is None
assert device_input_combobox.devices == [
"samx",
"samy",
"aptrx",
"aptry",
"gauss_bpm",
"gauss_adc1",
"gauss_adc2",
"gauss_adc3",
"bpm4i",
"bpm3a",
"bpm3i",
"eiger",
]
def test_device_input_combobox_init_with_config(device_input_combobox_with_config):
assert device_input_combobox_with_config.config.gui_id == "test_gui_id"
assert device_input_combobox_with_config.config.device_filter == "FakePositioner"
assert device_input_combobox_with_config.config.default_device == "samx"
assert device_input_combobox_with_config.config.arg_name == "test_arg_name"
def test_device_input_combobox_init_with_kwargs(device_input_combobox_with_kwargs):
assert device_input_combobox_with_kwargs.config.gui_id == "test_gui_id"
assert device_input_combobox_with_kwargs.config.device_filter == "FakePositioner"
assert device_input_combobox_with_kwargs.config.default_device == "samx"
assert device_input_combobox_with_kwargs.config.arg_name == "test_arg_name"
def test_get_device_from_input_combobox_init(device_input_combobox):
device_input_combobox.setCurrentIndex(0)
device_text = device_input_combobox.currentText()
current_device = device_input_combobox.get_device()
assert current_device.name == device_text
@pytest.fixture
def device_input_line_edit(qtbot, mocked_client):
widget = DeviceLineEdit(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.close()
@pytest.fixture
def device_input_line_edit_with_config(qtbot, mocked_client):
config = {
"widget_class": "DeviceLineEdit",
"gui_id": "test_gui_id",
"device_filter": "FakePositioner",
"default_device": "samx",
"arg_name": "test_arg_name",
}
widget = DeviceLineEdit(client=mocked_client, config=config)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.close()
@pytest.fixture
def device_input_line_edit_with_kwargs(qtbot, mocked_client):
widget = DeviceLineEdit(
client=mocked_client,
gui_id="test_gui_id",
device_filter="FakePositioner",
default_device="samx",
arg_name="test_arg_name",
)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.close()
def test_device_input_line_edit_init(device_input_line_edit):
assert device_input_line_edit is not None
assert device_input_line_edit.client is not None
assert isinstance(device_input_line_edit, DeviceLineEdit)
assert device_input_line_edit.config.widget_class == "DeviceLineEdit"
assert device_input_line_edit.config.device_filter is None
assert device_input_line_edit.config.default_device is None
assert device_input_line_edit.devices == [
"samx",
"samy",
"aptrx",
"aptry",
"gauss_bpm",
"gauss_adc1",
"gauss_adc2",
"gauss_adc3",
"bpm4i",
"bpm3a",
"bpm3i",
"eiger",
]
def test_device_input_line_edit_init_with_config(device_input_line_edit_with_config):
assert device_input_line_edit_with_config.config.gui_id == "test_gui_id"
assert device_input_line_edit_with_config.config.device_filter == "FakePositioner"
assert device_input_line_edit_with_config.config.default_device == "samx"
assert device_input_line_edit_with_config.config.arg_name == "test_arg_name"
def test_device_input_line_edit_init_with_kwargs(device_input_line_edit_with_kwargs):
assert device_input_line_edit_with_kwargs.config.gui_id == "test_gui_id"
assert device_input_line_edit_with_kwargs.config.device_filter == "FakePositioner"
assert device_input_line_edit_with_kwargs.config.default_device == "samx"
assert device_input_line_edit_with_kwargs.config.arg_name == "test_arg_name"
def test_get_device_from_input_line_edit_init(device_input_line_edit):
device_input_line_edit.setText("samx")
device_text = device_input_line_edit.text()
current_device = device_input_line_edit.get_device()
assert current_device.name == device_text

View File

@@ -97,3 +97,17 @@ def test_client_generator_with_black_formatting():
generated_output_formatted = isort.code(generated_output_formatted)
assert expected_output_formatted == generated_output_formatted
def test_client_generator_classes():
generator = ClientGenerator()
out = generator.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"]]
assert "BECFigure" in connector_cls_names
assert "BECWaveform" in connector_cls_names
assert "BECDockArea" in top_level_cls_names
assert "BECFigure" in top_level_cls_names
assert "BECWaveform" not in top_level_cls_names

View File

@@ -1,8 +1,5 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
from unittest import mock
import pytest
from qtpy.QtGui import QFontInfo
from .client_mocks import mocked_client
from .test_bec_figure import bec_figure
@@ -40,30 +37,6 @@ def test_plot_base_axes_by_separate_methods(bec_figure):
assert plot_base.plot_item.ctrl.logXCheck.isChecked() == True
assert plot_base.plot_item.ctrl.logYCheck.isChecked() == True
# Check the font size by mocking the set functions
# I struggled retrieving it from the QFont object directly
# thus I mocked the set functions to check internally the functionality
with (
mock.patch.object(plot_base.plot_item, "setLabel") as mock_set_label,
mock.patch.object(plot_base.plot_item, "setTitle") as mock_set_title,
):
plot_base.set_x_label("Test x Label", 20)
plot_base.set_y_label("Test y Label", 16)
assert mock_set_label.call_count == 2
assert plot_base.config.axis.x_label_size == 20
assert plot_base.config.axis.y_label_size == 16
col = plot_base.get_text_color()
calls = []
style = {"color": col, "font-size": "20pt"}
calls.append(mock.call("bottom", "Test x Label", **style))
style = {"color": col, "font-size": "16pt"}
calls.append(mock.call("left", "Test y Label", **style))
assert mock_set_label.call_args_list == calls
plot_base.set_title("Test Title", 16)
style = {"color": col, "size": "16pt"}
call = mock.call("Test Title", **style)
assert mock_set_title.call_args == call
def test_plot_base_axes_added_by_kwargs(bec_figure):
plot_base = bec_figure.add_widget(widget_type="PlotBase", widget_id="test_plot")

View File

@@ -1,14 +0,0 @@
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"]]
assert "BECFigure" in connector_cls_names
assert "BECWaveform" in connector_cls_names
assert "BECDockArea" in top_level_cls_names
assert "BECFigure" in top_level_cls_names
assert "BECWaveform" not in top_level_cls_names

View File

@@ -1,7 +0,0 @@
from bec_widgets.cli.rpc_wigdet_handler import RPCWidgetHandler
def test_rpc_widget_handler():
handler = RPCWidgetHandler()
assert "BECFigure" in handler.widget_classes
assert "SpiralProgressBar" in handler.widget_classes

View File

@@ -5,7 +5,7 @@ import pytest
from qtpy.QtWidgets import QLineEdit
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.scan_control import ScanControl
from bec_widgets.widgets import ScanControl
from tests.unit_tests.test_msgs.available_scans_message import available_scans_message

View File

@@ -5,7 +5,7 @@ from bec_lib.endpoints import MessageEndpoints
from pydantic import ValidationError
from bec_widgets.utils import Colors
from bec_widgets.widgets.spiral_progress_bar import SpiralProgressBar
from bec_widgets.widgets import SpiralProgressBar
from bec_widgets.widgets.spiral_progress_bar.ring import RingConfig, RingConnections
from bec_widgets.widgets.spiral_progress_bar.spiral_progress_bar import SpiralProgressBarConfig

View File

@@ -2,7 +2,7 @@
import pytest
from bec_widgets.widgets.buttons import StopButton
from bec_widgets.widgets import StopButton
from .client_mocks import mocked_client

View File

@@ -1,5 +1,5 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
from unittest import mock
from unittest.mock import MagicMock
import numpy as np
import pytest
@@ -56,12 +56,8 @@ def test_create_waveform1D_by_config(bec_figure):
"col": 0,
"axis": {
"title": "Widget 1",
"title_size": None,
"x_label": None,
"x_label_size": None,
"y_label": None,
"y_label_size": None,
"legend_label_size": None,
"x_scale": "linear",
"y_scale": "linear",
"x_lim": (1, 10),
@@ -197,18 +193,6 @@ def test_add_curve(bec_figure):
assert c1.config.source == "scan_segment"
def test_change_legend_font_size(bec_figure):
plot = bec_figure.add_plot()
w1 = plot.add_curve_scan(x_name="samx", y_name="bpm4i")
my_func = plot.plot_item.legend
with mock.patch.object(my_func, "setScale") as mock_set_scale:
plot.set_legend_label_size(18)
assert plot.config.axis.legend_label_size == 18
assert mock_set_scale.call_count == 1
assert mock_set_scale.call_args == mock.call(2)
def test_remove_curve(bec_figure):
w1 = bec_figure.add_plot()
@@ -422,10 +406,10 @@ def test_scan_update(bec_figure, qtbot):
"scan_id": 1,
}
# Mock scan_storage.find_scan_by_ID
mock_scan_data_waveform = mock.MagicMock()
mock_scan_data_waveform = MagicMock()
mock_scan_data_waveform.data = {
device_name: {
entry: mock.MagicMock(val=[msg_waveform["data"][device_name][entry]["value"]])
entry: MagicMock(val=[msg_waveform["data"][device_name][entry]["value"]])
for entry in msg_waveform["data"][device_name]
}
for device_name in msg_waveform["data"]
@@ -446,12 +430,12 @@ def test_scan_history_with_val_access(bec_figure, qtbot):
c1 = w1.add_curve_scan(x_name="samx", y_name="bpm4i")
mock_scan_data = {
"samx": {"samx": mock.MagicMock(val=np.array([1, 2, 3]))}, # Use mock.MagicMock for .val
"bpm4i": {"bpm4i": mock.MagicMock(val=np.array([4, 5, 6]))}, # Use mock.MagicMock for .val
"samx": {"samx": MagicMock(val=np.array([1, 2, 3]))}, # Use MagicMock for .val
"bpm4i": {"bpm4i": MagicMock(val=np.array([4, 5, 6]))}, # Use MagicMock for .val
}
mock_scan_storage = mock.MagicMock()
mock_scan_storage.find_scan_by_ID.return_value = mock.MagicMock(data=mock_scan_data)
mock_scan_storage = MagicMock()
mock_scan_storage.find_scan_by_ID.return_value = MagicMock(data=mock_scan_data)
w1.queue.scan_storage = mock_scan_storage
fake_scan_id = "fake_scan_id"
@@ -480,10 +464,10 @@ def test_scatter_2d_update(bec_figure, qtbot):
}
msg_metadata = {"scan_name": "line_scan"}
mock_scan_data = mock.MagicMock()
mock_scan_data = MagicMock()
mock_scan_data.data = {
device_name: {
entry: mock.MagicMock(val=msg["data"][device_name][entry]["value"])
entry: MagicMock(val=msg["data"][device_name][entry]["value"])
for entry in msg["data"][device_name]
}
for device_name in msg["data"]