1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-05-11 01:02:17 +02:00

Compare commits

..

16 Commits

Author SHA1 Message Date
semantic-release 5dc373bd8e 3.7.1
Automatically generated by python-semantic-release
2026-04-21 11:56:57 +00:00
appel_c 91afc775d5 test: fix exit status and status access in tests 2026-04-21 13:56:12 +02:00
appel_c 55694ff2b9 fix(heatmap): fix access to status from metadata 2026-04-21 13:56:12 +02:00
perl_d 5b68a51aaa tests: skip broken and mark with issue 2026-04-21 12:43:31 +02:00
semantic-release f13fa75e25 3.7.0
Automatically generated by python-semantic-release
2026-04-21 10:42:54 +00:00
wakonig_k 0cf84cd1d8 feat: move companion app to applications 2026-04-21 12:42:08 +02:00
wakonig_k 3e77f54034 refactor: cleanup of imports 2026-04-21 12:42:08 +02:00
semantic-release f7616102d8 3.6.0
Automatically generated by python-semantic-release
2026-04-21 06:39:15 +00:00
perl_d 5a497c3598 fix: small usability changes 2026-04-21 08:38:24 +02:00
perl_d 23e3644619 feat: add button/slot to pause/unpause logs 2026-04-21 08:38:24 +02:00
perl_d a5db2dc340 fix: change resize mode to interactive 2026-04-21 08:38:24 +02:00
perl_d 2e8f43fcac feat: add logpanel to menu 2026-04-21 08:38:24 +02:00
perl_d 09bb1121d8 feat: migrate logpanel to table model/view 2026-04-21 08:38:24 +02:00
semantic-release c9aaa77b3c 3.5.1
Automatically generated by python-semantic-release
2026-04-20 13:06:31 +00:00
perl_d f7a1ee49a4 fix: don't assume attr exists if we timed out waiting for it 2026-04-20 15:05:47 +02:00
perl_d 8e51c1adb6 refactor: don't import real widgets in client 2026-04-19 16:05:56 +02:00
81 changed files with 1242 additions and 1295 deletions
+61
View File
@@ -1,6 +1,67 @@
# CHANGELOG
## v3.7.1 (2026-04-21)
### Bug Fixes
- **heatmap**: Fix access to status from metadata
([`55694ff`](https://github.com/bec-project/bec_widgets/commit/55694ff2b96581e03c63c8b8e068e2db79bcf780))
### Testing
- Fix exit status and status access in tests
([`91afc77`](https://github.com/bec-project/bec_widgets/commit/91afc775d59b4ba31bed3585847a67c301acf9b0))
## v3.7.0 (2026-04-21)
### Features
- Move companion app to applications
([`0cf84cd`](https://github.com/bec-project/bec_widgets/commit/0cf84cd1d839ac4a39ffb5fb9ba57d432e04348a))
### Refactoring
- Cleanup of imports
([`3e77f54`](https://github.com/bec-project/bec_widgets/commit/3e77f540345f56b9f184a332fcdd50d4d4c8c621))
## v3.6.0 (2026-04-21)
### Bug Fixes
- Change resize mode to interactive
([`a5db2dc`](https://github.com/bec-project/bec_widgets/commit/a5db2dc340f3386e68b300fd4528a44f87cbbf97))
- Small usability changes
([`5a497c3`](https://github.com/bec-project/bec_widgets/commit/5a497c3598c2d8f27916d91d53c646d5d6d3a4a7))
### Features
- Add button/slot to pause/unpause logs
([`23e3644`](https://github.com/bec-project/bec_widgets/commit/23e3644619de958bcfdb8a0b2ee1f7c2ce05b235))
- Add logpanel to menu
([`2e8f43f`](https://github.com/bec-project/bec_widgets/commit/2e8f43fcac581cd1c227308198565d142a1bf276))
- Migrate logpanel to table model/view
([`09bb112`](https://github.com/bec-project/bec_widgets/commit/09bb1121d83bac1f6e4827daa476fbe7cd5b3a80))
## v3.5.1 (2026-04-20)
### Bug Fixes
- Don't assume attr exists if we timed out waiting for it
([`f7a1ee4`](https://github.com/bec-project/bec_widgets/commit/f7a1ee49a42c58ba315c8957b45a80d862ffe745))
### Refactoring
- Don't import real widgets in client
([`8e51c1a`](https://github.com/bec-project/bec_widgets/commit/8e51c1adb6a7658c54846794cf97b774cbac2193))
## v3.5.0 (2026-04-14)
### Bug Fixes
+3 -11
View File
@@ -1,21 +1,13 @@
import os
import sys
if sys.platform.startswith("linux"):
qt_platform = os.environ.get("QT_QPA_PLATFORM", "")
if qt_platform != "offscreen":
os.environ["QT_QPA_PLATFORM"] = "xcb"
__all__ = ["BECWidget", "SafeSlot", "SafeProperty"]
def __getattr__(name: str):
def __getattr__(name):
if name == "BECWidget":
from bec_widgets.utils.bec_widget import BECWidget
return BECWidget
if name in {"SafeProperty", "SafeSlot"}:
if name in {"SafeSlot", "SafeProperty"}:
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
return {"SafeProperty": SafeProperty, "SafeSlot": SafeSlot}[name]
return {"SafeSlot": SafeSlot, "SafeProperty": SafeProperty}[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+15
View File
@@ -0,0 +1,15 @@
import os
import sys
import bec_widgets.widgets.containers.qt_ads as QtAds
if sys.platform.startswith("linux"):
qt_platform = os.environ.get("QT_QPA_PLATFORM", "")
if qt_platform != "offscreen":
os.environ["QT_QPA_PLATFORM"] = "xcb"
# Default QtAds configuration
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
QtAds.CDockManager.setConfigFlag(
QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True
)
@@ -19,8 +19,8 @@ from qtpy.QtWidgets import QApplication
import bec_widgets
from bec_widgets.applications.launch_window import LaunchWindow
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.rpc_register import RPCRegister
logger = bec_logger.logger
+1 -1
View File
@@ -20,13 +20,13 @@ from qtpy.QtWidgets import (
)
import bec_widgets
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.utils.rpc_register import RPCRegister
from bec_widgets.utils.screen_utils import apply_window_geometry, centered_geometry_for_app
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.ui_loader import UILoader
@@ -0,0 +1,2 @@
from .config_choice_dialog import ConfigChoiceDialog
from .device_form_dialog import DeviceFormDialog
@@ -8,16 +8,14 @@ from ophyd_devices.interfaces.device_config_templates.ophyd_templates import OPH
from qtpy import QtCore, QtWidgets
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.control.device_manager.components import OphydValidation
from bec_widgets.widgets.control.device_manager.components.device_config_template.device_config_template import (
DeviceConfigTemplate,
)
from bec_widgets.widgets.control.device_manager.components.device_config_template.template_items import (
validate_name,
)
from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation import (
OphydValidation,
)
from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation_utils import (
from bec_widgets.widgets.control.device_manager.components.ophyd_validation import (
ConfigStatus,
ConnectionStatus,
format_error_to_md,
@@ -12,7 +12,7 @@ from qtpy import QtCore, QtGui, QtWidgets
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation_utils import (
from bec_widgets.widgets.control.device_manager.components.ophyd_validation import (
ConfigStatus,
ConnectionStatus,
get_validation_icons,
@@ -13,7 +13,6 @@ from bec_lib.file_utils import DeviceConfigWriter
from bec_lib.logger import bec_logger
from bec_lib.messages import ConfigAction, ScanStatusMessage
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
from bec_lib.utils.import_utils import lazy_import_from
from bec_qthemes import apply_theme, material_icon
from qtpy.QtCore import QMetaObject, Qt, QThreadPool, Signal
from qtpy.QtGui import QColor
@@ -27,18 +26,26 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs import (
ConfigChoiceDialog,
DeviceFormDialog,
)
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.upload_redis_dialog import (
UploadRedisDialog,
)
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal
from bec_widgets.widgets.control.device_manager.components.device_table.device_table import (
from bec_widgets.widgets.control.device_manager.components import (
DeviceTable,
DMConfigView,
DocstringView,
OphydValidation,
)
from bec_widgets.widgets.control.device_manager.components.dm_config_view import DMConfigView
from bec_widgets.widgets.control.device_manager.components.dm_docstring_view import DocstringView
from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal
from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation_utils import (
ConfigStatus,
ConnectionStatus,
@@ -54,29 +61,8 @@ from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
if TYPE_CHECKING: # pragma: no cover
from bec_lib.client import BECClient
from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.upload_redis_dialog import (
UploadRedisDialog,
)
logger = bec_logger.logger
ConfigChoiceDialog = lazy_import_from(
"bec_widgets.applications.views.device_manager_view.device_manager_dialogs.config_choice_dialog",
("ConfigChoiceDialog",),
)
DeviceFormDialog = lazy_import_from(
"bec_widgets.applications.views.device_manager_view.device_manager_dialogs.device_form_dialog",
("DeviceFormDialog",),
)
UploadRedisDialog = lazy_import_from(
"bec_widgets.applications.views.device_manager_view.device_manager_dialogs.upload_redis_dialog",
("UploadRedisDialog",),
)
OphydValidation = lazy_import_from(
"bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation",
("OphydValidation",),
)
_yes_no_question = partial(
QMessageBox.question,
buttons=QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
@@ -1,17 +1,14 @@
"""Module for Device Manager View."""
from bec_lib.utils.import_utils import lazy_import_from
from qtpy.QtCore import QRect
from qtpy.QtWidgets import QWidget
from bec_widgets.applications.views.device_manager_view.device_manager_widget import (
DeviceManagerWidget,
)
from bec_widgets.applications.views.view import ViewBase, ViewTourSteps
from bec_widgets.utils.error_popups import SafeSlot
DeviceManagerWidget = lazy_import_from(
"bec_widgets.applications.views.device_manager_view.device_manager_widget",
("DeviceManagerWidget",),
)
class DeviceManagerView(ViewBase):
"""
@@ -6,18 +6,15 @@ import os
from bec_lib.bec_yaml_loader import yaml_load
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import_from
from bec_qthemes import material_icon
from qtpy import QtCore, QtWidgets
from bec_widgets.applications.views.device_manager_view.device_manager_display_widget import (
DeviceManagerDisplayWidget,
)
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
DeviceManagerDisplayWidget = lazy_import_from(
"bec_widgets.applications.views.device_manager_view.device_manager_display_widget",
("DeviceManagerDisplayWidget",),
)
logger = bec_logger.logger
+1
View File
@@ -0,0 +1 @@
from bec_widgets.cli.rpc import rpc_base
+310 -27
View File
@@ -13,7 +13,7 @@ from typing import Literal, Optional
from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module
from bec_widgets.utils.bec_plugin_helper import get_plugin_client_module
logger = bec_logger.logger
@@ -62,29 +62,19 @@ _Widgets = {
try:
_plugin_widgets = get_all_plugin_widgets().as_dict()
plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
for _widget in _overlap:
logger.warning(
f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !"
)
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
if plugin_name not in _Widgets:
_Widgets[plugin_name] = plugin_name
if plugin_name in globals():
conflicting_file = (
inspect.getfile(_plugin_widgets[plugin_name])
if plugin_name in _plugin_widgets
else f"{plugin_client}"
)
logger.warning(
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
f"Plugin widget {plugin_name} in {plugin_class._IMPORT_MODULE} conflicts with a built-in class!"
)
continue
if plugin_name not in _overlap:
else:
globals()[plugin_name] = plugin_class
Widgets = _WidgetsEnumType("Widgets", _Widgets)
except ImportError as e:
logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(e))}")
@@ -92,6 +82,8 @@ except ImportError as e:
class AdminView(RPCBase):
"""A view for administrators to change the current active experiment, manage messaging"""
_IMPORT_MODULE = "bec_widgets.applications.views.admin_view.admin_view"
@rpc_call
def activate(self) -> "None":
"""
@@ -100,6 +92,8 @@ class AdminView(RPCBase):
class AutoUpdates(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.containers.auto_update.auto_updates"
@property
@rpc_call
def enabled(self) -> "bool":
@@ -136,6 +130,8 @@ class AutoUpdates(RPCBase):
class AvailableDeviceResources(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources"
@rpc_call
def remove(self):
"""
@@ -156,6 +152,8 @@ class AvailableDeviceResources(RPCBase):
class BECDockArea(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.containers.dock_area.dock_area"
@rpc_call
def new(
self,
@@ -391,6 +389,8 @@ class BECDockArea(RPCBase):
class BECMainWindow(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.containers.main_window.main_window"
@rpc_call
def remove(self):
"""
@@ -413,6 +413,8 @@ class BECMainWindow(RPCBase):
class BECProgressBar(RPCBase):
"""A custom progress bar with smooth transitions. The displayed text can be customized using a template."""
_IMPORT_MODULE = "bec_widgets.widgets.progress.bec_progressbar.bec_progressbar"
@rpc_call
def set_value(self, value):
"""
@@ -486,6 +488,8 @@ class BECProgressBar(RPCBase):
class BECQueue(RPCBase):
"""Widget to display the BEC queue."""
_IMPORT_MODULE = "bec_widgets.widgets.services.bec_queue.bec_queue"
@rpc_call
def remove(self):
"""
@@ -508,6 +512,8 @@ class BECQueue(RPCBase):
class BECShell(RPCBase):
"""A BecConsole pre-configured to run the BEC shell."""
_IMPORT_MODULE = "bec_widgets.widgets.editors.bec_console.bec_console"
@rpc_call
def remove(self):
"""
@@ -530,6 +536,8 @@ class BECShell(RPCBase):
class BECStatusBox(RPCBase):
"""An autonomous widget to display the status of BEC services."""
_IMPORT_MODULE = "bec_widgets.widgets.services.bec_status_box.bec_status_box"
@rpc_call
def get_server_state(self) -> "str":
"""
@@ -565,6 +573,8 @@ class BECStatusBox(RPCBase):
class BaseROI(RPCBase):
"""Base class for all Region of Interest (ROI) implementations."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
@property
@rpc_call
def label(self) -> "str":
@@ -694,6 +704,8 @@ class BaseROI(RPCBase):
class BecConsole(RPCBase):
"""A console widget with access to a shared registry of terminals, such that instances can be moved around."""
_IMPORT_MODULE = "bec_widgets.widgets.editors.bec_console.bec_console"
@rpc_call
def remove(self):
"""
@@ -716,6 +728,8 @@ class BecConsole(RPCBase):
class CircularROI(RPCBase):
"""Circular Region of Interest with center/diameter tracking and auto-labeling."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
@property
@rpc_call
def label(self) -> "str":
@@ -843,6 +857,8 @@ class CircularROI(RPCBase):
class Curve(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.plots.waveform.curve"
@rpc_call
def remove(self):
"""
@@ -1009,6 +1025,8 @@ class Curve(RPCBase):
class DapComboBox(RPCBase):
"""Editable combobox listing the available DAP models."""
_IMPORT_MODULE = "bec_widgets.widgets.dap.dap_combo_box.dap_combo_box"
@rpc_call
def select_y_axis(self, y_axis: str):
"""
@@ -1040,6 +1058,8 @@ class DapComboBox(RPCBase):
class DeveloperView(RPCBase):
"""A view for users to write scripts and macros and execute them within the application."""
_IMPORT_MODULE = "bec_widgets.applications.views.developer_view.developer_view"
@rpc_call
def activate(self) -> "None":
"""
@@ -1050,6 +1070,8 @@ class DeveloperView(RPCBase):
class DeviceBrowser(RPCBase):
"""DeviceBrowser is a widget that displays all available devices in the current BEC session."""
_IMPORT_MODULE = "bec_widgets.widgets.services.device_browser.device_browser"
@rpc_call
def remove(self):
"""
@@ -1072,6 +1094,8 @@ class DeviceBrowser(RPCBase):
class DeviceInitializationProgressBar(RPCBase):
"""A progress bar that displays the progress of device initialization."""
_IMPORT_MODULE = "bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar"
@rpc_call
def remove(self):
"""
@@ -1094,6 +1118,8 @@ class DeviceInitializationProgressBar(RPCBase):
class DeviceInputBase(RPCBase):
"""Mixin base class for device input widgets."""
_IMPORT_MODULE = "bec_widgets.widgets.control.device_input.base_classes.device_input_base"
@rpc_call
def remove(self):
"""
@@ -1116,6 +1142,8 @@ class DeviceInputBase(RPCBase):
class DeviceManagerView(RPCBase):
"""A view for users to manage devices within the application."""
_IMPORT_MODULE = "bec_widgets.applications.views.device_manager_view.device_manager_view"
@rpc_call
def activate(self) -> "None":
"""
@@ -1126,6 +1154,8 @@ class DeviceManagerView(RPCBase):
class DockAreaView(RPCBase):
"""Modular dock area view for arranging and managing multiple dockable widgets."""
_IMPORT_MODULE = "bec_widgets.applications.views.dock_area_view.dock_area_view"
@rpc_call
def activate(self) -> "None":
"""
@@ -1369,6 +1399,8 @@ class DockAreaView(RPCBase):
class DockAreaWidget(RPCBase):
"""Lightweight dock area that exposes the core Qt ADS docking helpers without any"""
_IMPORT_MODULE = "bec_widgets.widgets.containers.dock_area.basic_dock_area"
@rpc_call
def new(
self,
@@ -1553,6 +1585,8 @@ class DockAreaWidget(RPCBase):
class EllipticalROI(RPCBase):
"""Elliptical Region of Interest with centre/width/height tracking and auto-labelling."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
@property
@rpc_call
def label(self) -> "str":
@@ -1675,6 +1709,8 @@ class EllipticalROI(RPCBase):
class Heatmap(RPCBase):
"""Heatmap widget for visualizing 2d grid data with color mapping for the z-axis."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.heatmap.heatmap"
@rpc_call
def remove(self):
"""
@@ -2373,6 +2409,8 @@ class Heatmap(RPCBase):
class Image(RPCBase):
"""Image widget for displaying 2D data."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.image.image"
@rpc_call
def remove(self):
"""
@@ -2984,6 +3022,8 @@ class Image(RPCBase):
class ImageItem(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.plots.image.image_item"
@property
@rpc_call
def color_map(self) -> "str":
@@ -3134,6 +3174,8 @@ class ImageItem(RPCBase):
class LaunchWindow(RPCBase):
_IMPORT_MODULE = "bec_widgets.applications.launch_window"
@rpc_call
def show_launcher(self):
"""
@@ -3148,33 +3190,224 @@ class LaunchWindow(RPCBase):
class LogPanel(RPCBase):
"""Displays a log panel"""
"""Live display of the BEC logs in a table view."""
_IMPORT_MODULE = "bec_widgets.widgets.utility.logpanel.logpanel"
@rpc_call
def set_plain_text(self, text: str) -> None:
def remove(self):
"""
Set the plain text of the widget.
Args:
text (str): The text to set.
Cleanup the BECConnector
"""
@rpc_call
def set_html_text(self, text: str) -> None:
def attach(self):
"""
Set the HTML text of the widget.
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
class Minesweeper(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.games.minesweeper"
class MonacoDock(RPCBase):
"""MonacoDock is a dock widget that contains Monaco editor instances."""
_IMPORT_MODULE = "bec_widgets.widgets.editors.monaco.monaco_dock"
@rpc_call
def new(
self,
widget: "QWidget | str",
*,
closable: "bool" = True,
floatable: "bool" = True,
movable: "bool" = True,
start_floating: "bool" = False,
floating_state: "Mapping[str, object] | None" = None,
where: "Literal['left', 'right', 'top', 'bottom'] | None" = None,
on_close: "Callable[[CDockWidget, QWidget], None] | None" = None,
tab_with: "CDockWidget | QWidget | str | None" = None,
relative_to: "CDockWidget | QWidget | str | None" = None,
return_dock: "bool" = False,
show_title_bar: "bool | None" = None,
title_buttons: "Mapping[str, bool] | Sequence[str] | str | None" = None,
show_settings_action: "bool | None" = False,
promote_central: "bool" = False,
dock_icon: "QIcon | None" = None,
apply_widget_icon: "bool" = True,
object_name: "str | None" = None,
**widget_kwargs,
) -> "QWidget | CDockWidget | BECWidget":
"""
Create a new widget (or reuse an instance) and add it as a dock.
Args:
text (str): The text to set.
widget(QWidget | str): Instance or registered widget type string.
closable(bool): Whether the dock is closable.
floatable(bool): Whether the dock is floatable.
movable(bool): Whether the dock is movable.
start_floating(bool): Whether to start the dock floating.
floating_state(Mapping | None): Optional floating geometry metadata to apply when floating.
where(Literal["left", "right", "top", "bottom"] | None): Dock placement hint relative to the dock area (ignored when
``relative_to`` is provided without an explicit value).
on_close(Callable[[CDockWidget, QWidget], None] | None): Optional custom close handler accepting (dock, widget).
tab_with(CDockWidget | QWidget | str | None): Existing dock (or widget/name) to tab the new dock alongside.
relative_to(CDockWidget | QWidget | str | None): Existing dock (or widget/name) used as the positional anchor.
When supplied and ``where`` is ``None``, the new dock inherits the
anchor's current dock area.
return_dock(bool): When True, return the created dock instead of the widget.
show_title_bar(bool | None): Explicitly show or hide the dock area's title bar.
title_buttons(Mapping[str, bool] | Sequence[str] | str | None): Mapping or iterable describing which title bar buttons should
remain visible. Provide a mapping of button names (``"float"``,
``"close"``, ``"menu"``, ``"auto_hide"``, ``"minimize"``) to booleans,
or a sequence of button names to hide.
show_settings_action(bool | None): Control whether a dock settings/property action should
be installed. Defaults to ``False`` for the basic dock area; subclasses
such as `BECDockArea` override the default to ``True``.
promote_central(bool): When True, promote the created dock to be the dock manager's
central widget (useful for editor stacks or other root content).
dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``.
Provide a `QIcon` (e.g. from ``material_icon``). When ``None`` (default),
the widget's ``ICON_NAME`` attribute is used when available.
apply_widget_icon(bool): When False, skip automatically resolving the icon from
the widget's ``ICON_NAME`` (useful for callers who want no icon and do not pass one explicitly).
object_name(str | None): Optional object name to assign to the created widget.
**widget_kwargs: Additional keyword arguments passed to the widget constructor
when creating by type name.
Returns:
The widget instance by default, or the created `CDockWidget` when `return_dock` is True.
"""
@rpc_call
def dock_map(self) -> "dict[str, CDockWidget]":
"""
Return the dock widgets map as dictionary with names as keys.
"""
class Minesweeper(RPCBase): ...
@rpc_call
def dock_list(self) -> "list[CDockWidget]":
"""
Return the list of dock widgets.
"""
@rpc_call
def widget_map(self, bec_widgets_only: "bool" = True) -> "dict[str, QWidget]":
"""
Return a dictionary mapping widget names to their corresponding widgets.
Args:
bec_widgets_only(bool): If True, only include widgets that are BECConnector instances.
"""
@rpc_call
def widget_list(self, bec_widgets_only: "bool" = True) -> "list[QWidget]":
"""
Return a list of widgets contained in the dock area.
Args:
bec_widgets_only(bool): If True, only include widgets that are BECConnector instances.
"""
@rpc_call
def attach_all(self):
"""
Re-attach floating docks back into the dock manager.
"""
@rpc_call
def delete_all(self):
"""
Delete all docks and their associated widgets.
"""
@rpc_call
def delete(self, object_name: "str") -> "bool":
"""
Remove a widget from the dock area by its object name.
Args:
object_name: The object name of the widget to remove.
Returns:
bool: True if the widget was found and removed, False otherwise.
Raises:
ValueError: If no widget with the given object name is found.
Example:
>>> dock_area.delete("my_widget")
True
"""
@rpc_call
def set_layout_ratios(
self,
*,
horizontal: "Sequence[float] | Mapping[int | str, float] | None" = None,
vertical: "Sequence[float] | Mapping[int | str, float] | None" = None,
splitter_overrides: "Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]] | None" = None,
) -> "None":
"""
Adjust splitter ratios in the dock layout.
Args:
horizontal: Weights applied to every horizontal splitter encountered.
vertical: Weights applied to every vertical splitter encountered.
splitter_overrides: Optional overrides targeting specific splitters identified
by their index path (e.g. ``{0: [1, 2], (1, 0): [3, 5]}``). Paths are zero-based
indices following the splitter hierarchy, starting from the root splitter.
Example:
To build three columns with custom per-column ratios::
area.set_layout_ratios(
horizontal=[1, 2, 1], # column widths
splitter_overrides={
0: [1, 2], # column 0 (two rows)
1: [3, 2, 1], # column 1 (three rows)
2: [1], # column 2 (single row)
},
)
"""
@rpc_call
def describe_layout(self) -> "list[dict[str, Any]]":
"""
Return metadata describing splitter paths, orientations, and contained docks.
Useful for determining the keys to use in `set_layout_ratios(splitter_overrides=...)`.
"""
@rpc_call
def print_layout_structure(self) -> "None":
"""
Pretty-print the current splitter paths to stdout.
"""
@rpc_call
def set_central_dock(self, dock: "CDockWidget | QWidget | str") -> "None":
"""
Promote an existing dock to be the dock manager's central widget.
Args:
dock(CDockWidget | QWidget | str): Dock reference to promote.
"""
class MonacoWidget(RPCBase):
"""A simple Monaco editor widget"""
_IMPORT_MODULE = "bec_widgets.widgets.editors.monaco.monaco_widget"
@rpc_call
def set_text(
self, text: "str", file_name: "str | None" = None, reset: "bool" = False
@@ -3349,6 +3582,8 @@ class MonacoWidget(RPCBase):
class MotorMap(RPCBase):
"""Motor map widget for plotting motor positions in 2D including a trace of the last points."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.motor_map.motor_map"
@rpc_call
def remove(self):
"""
@@ -3819,6 +4054,8 @@ class MotorMap(RPCBase):
class MultiWaveform(RPCBase):
"""MultiWaveform widget for displaying multiple waveforms emitted by a single signal."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.multi_waveform.multi_waveform"
@rpc_call
def remove(self):
"""
@@ -4278,6 +4515,8 @@ class MultiWaveform(RPCBase):
class PdfViewerWidget(RPCBase):
"""A widget to display PDF documents with toolbar controls."""
_IMPORT_MODULE = "bec_widgets.widgets.utility.pdf_viewer.pdf_viewer"
@rpc_call
def load_pdf(self, file_path: str):
"""
@@ -4409,6 +4648,10 @@ class PdfViewerWidget(RPCBase):
class PositionIndicator(RPCBase):
"""Display a position within a defined range, e.g. motor limits."""
_IMPORT_MODULE = (
"bec_widgets.widgets.control.device_control.position_indicator.position_indicator"
)
@rpc_call
def set_value(self, position: float):
"""
@@ -4474,6 +4717,10 @@ class PositionIndicator(RPCBase):
class PositionerBox(RPCBase):
"""Simple Widget to control a positioner in box form"""
_IMPORT_MODULE = (
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box"
)
@rpc_call
def set_positioner(self, positioner: "str | Positioner"):
"""
@@ -4506,6 +4753,8 @@ class PositionerBox(RPCBase):
class PositionerBox2D(RPCBase):
"""Simple Widget to control two positioners in box form"""
_IMPORT_MODULE = "bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d"
@rpc_call
def set_positioner_hor(self, positioner: "str | Positioner"):
"""
@@ -4575,6 +4824,8 @@ class PositionerBox2D(RPCBase):
class PositionerControlLine(RPCBase):
"""A widget that controls a single device."""
_IMPORT_MODULE = "bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line"
@rpc_call
def set_positioner(self, positioner: "str | Positioner"):
"""
@@ -4607,6 +4858,8 @@ class PositionerControlLine(RPCBase):
class PositionerGroup(RPCBase):
"""Simple Widget to control a positioner in box form"""
_IMPORT_MODULE = "bec_widgets.widgets.control.device_control.positioner_group.positioner_group"
@rpc_call
def set_positioners(self, device_names: "str"):
"""
@@ -4638,6 +4891,8 @@ class PositionerGroup(RPCBase):
class RectangularROI(RPCBase):
"""Defines a rectangular Region of Interest (ROI) with additional functionality."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
@property
@rpc_call
def label(self) -> "str":
@@ -4767,6 +5022,8 @@ class RectangularROI(RPCBase):
class ResumeButton(RPCBase):
"""A button that continue scan queue."""
_IMPORT_MODULE = "bec_widgets.widgets.control.buttons.button_resume.button_resume"
@rpc_call
def remove(self):
"""
@@ -4787,6 +5044,8 @@ class ResumeButton(RPCBase):
class Ring(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.progress.ring_progress_bar.ring"
@rpc_call
def set_value(self, value: "int | float"):
"""
@@ -4880,6 +5139,8 @@ class Ring(RPCBase):
class RingProgressBar(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar"
@rpc_call
def remove(self):
"""
@@ -4959,12 +5220,14 @@ class RingProgressBar(RPCBase):
class SBBMonitor(RPCBase):
"""A widget to display the SBB monitor website."""
...
_IMPORT_MODULE = "bec_widgets.widgets.editors.sbb_monitor.sbb_monitor"
class ScanControl(RPCBase):
"""Widget to submit new scans to the queue."""
_IMPORT_MODULE = "bec_widgets.widgets.control.scan_control.scan_control"
@rpc_call
def attach(self):
"""
@@ -4988,6 +5251,8 @@ class ScanControl(RPCBase):
class ScanProgressBar(RPCBase):
"""Widget to display a progress bar that is hooked up to the scan progress of a scan."""
_IMPORT_MODULE = "bec_widgets.widgets.progress.scan_progressbar.scan_progressbar"
@rpc_call
def remove(self):
"""
@@ -5010,6 +5275,8 @@ class ScanProgressBar(RPCBase):
class ScatterCurve(RPCBase):
"""Scatter curve item for the scatter waveform widget."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.scatter_waveform.scatter_curve"
@property
@rpc_call
def color_map(self) -> "str":
@@ -5019,6 +5286,8 @@ class ScatterCurve(RPCBase):
class ScatterWaveform(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.plots.scatter_waveform.scatter_waveform"
@rpc_call
def remove(self):
"""
@@ -5486,6 +5755,8 @@ class ScatterWaveform(RPCBase):
class SignalLabel(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.utility.signal_label.signal_label"
@property
@rpc_call
def custom_label(self) -> "str":
@@ -5630,6 +5901,8 @@ class SignalLabel(RPCBase):
class TextBox(RPCBase):
"""A widget that displays text in plain and HTML format"""
_IMPORT_MODULE = "bec_widgets.widgets.editors.text_box.text_box"
@rpc_call
def set_plain_text(self, text: str) -> None:
"""
@@ -5652,6 +5925,8 @@ class TextBox(RPCBase):
class ViewBase(RPCBase):
"""Wrapper for a content widget used inside the main app's stacked view."""
_IMPORT_MODULE = "bec_widgets.applications.views.view"
@rpc_call
def activate(self) -> "None":
"""
@@ -5662,6 +5937,8 @@ class ViewBase(RPCBase):
class Waveform(RPCBase):
"""Widget for plotting waveforms."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.waveform.waveform"
@rpc_call
def remove(self):
"""
@@ -6240,6 +6517,8 @@ class Waveform(RPCBase):
class WaveformViewInline(RPCBase):
_IMPORT_MODULE = "bec_widgets.applications.views.view"
@rpc_call
def activate(self) -> "None":
"""
@@ -6248,6 +6527,8 @@ class WaveformViewInline(RPCBase):
class WaveformViewPopup(RPCBase):
_IMPORT_MODULE = "bec_widgets.applications.views.view"
@rpc_call
def activate(self) -> "None":
"""
@@ -6258,6 +6539,8 @@ class WaveformViewPopup(RPCBase):
class WebsiteWidget(RPCBase):
"""A simple widget to display a website"""
_IMPORT_MODULE = "bec_widgets.widgets.editors.website.website"
@rpc_call
def set_url(self, url: str) -> None:
"""
-79
View File
@@ -1,79 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from bec_lib.utils.import_utils import lazy_import_from
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widget_references
from bec_widgets.utils.plugin_utils import get_custom_class_references
try:
from bec_widgets.cli.constants import IGNORE_WIDGETS
except ModuleNotFoundError: # pragma: no cover
IGNORE_WIDGETS = ["LaunchWindow"]
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.bec_widget import BECWidget
class RPCWidgetHandler:
"""Handler class for creating widgets from RPC messages."""
def __init__(self):
self._widget_classes = None
@property
def widget_classes(self) -> dict[str, type["BECWidget"]]:
"""
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 # type: ignore
def update_available_widgets(self):
"""
Update the available widgets.
Returns:
None
"""
ignored = set(IGNORE_WIDGETS)
widget_classes = {
reference.name: lazy_import_from(reference.module, (reference.name,))
for reference in get_all_plugin_widget_references(use_cache=False)
if reference.name not in ignored
}
widget_classes.update(
{
reference.name: lazy_import_from(reference.module, (reference.name,))
for reference in get_custom_class_references(
"bec_widgets", packages=("widgets", "applications"), use_cache=False
)
if reference.name not in ignored
}
)
self._widget_classes = widget_classes
def create_widget(self, widget_type, **kwargs) -> "BECWidget":
"""
Create a widget from an RPC message.
Args:
widget_type(str): The type of the widget.
name (str): The name of the widget.
**kwargs: The keyword arguments for the widget.
Returns:
widget(BECWidget): The created widget.
"""
widget_class = self.widget_classes.get(widget_type) # type: ignore
if widget_class:
return widget_class(**kwargs)
raise ValueError(f"Unknown widget type: {widget_type}")
widget_handler = RPCWidgetHandler()
@@ -25,8 +25,8 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.rpc_widget_handler import widget_handler
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
+1
View File
@@ -0,0 +1 @@
+1 -1
View File
@@ -15,9 +15,9 @@ from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, Signal
from qtpy.QtWidgets import QApplication
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot
from bec_widgets.utils.name_utils import sanitize_namespace
from bec_widgets.utils.rpc_register import RPCRegister
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
+3 -59
View File
@@ -1,9 +1,7 @@
from __future__ import annotations
import ast
import importlib.metadata
import inspect
import logging
import pkgutil
import traceback
from importlib import util as importlib_util
@@ -11,65 +9,11 @@ from importlib.machinery import FileFinder, ModuleSpec, SourceFileLoader
from types import ModuleType
from typing import Generator
from bec_widgets.utils.plugin_utils import (
BECClassContainer,
BECClassInfo,
BECClassReference,
_ast_node_name,
_class_has_rpc_markers,
_discover_class_references_from_roots,
_find_package_roots,
)
from bec_lib.logger import bec_logger
logger = logging.getLogger(__name__)
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
def _plugin_class_is_candidate(node: ast.ClassDef) -> bool:
base_names = {_ast_node_name(base) for base in node.bases}
return bool({"BECWidget", "BECConnector"} & base_names) or _class_has_rpc_markers(node)
_PLUGIN_WIDGET_REFERENCE_CACHE: dict[tuple[tuple[str, str], ...], tuple[BECClassReference, ...]] = (
{}
)
def _plugin_entry_point_snapshot() -> tuple[tuple[str, str], ...]:
return tuple(
sorted(
(entry_point.name, entry_point.module)
for entry_point in importlib.metadata.entry_points(group="bec.widgets.user_widgets") # type: ignore
)
)
def _build_plugin_widget_references() -> tuple[BECClassReference, ...]:
references: list[BECClassReference] = []
seen_names: set[str] = set()
for entry_point in importlib.metadata.entry_points(group="bec.widgets.user_widgets"): # type: ignore
try:
package_roots = _find_package_roots(entry_point.module)
except ModuleNotFoundError:
continue
for reference in _discover_class_references_from_roots(
entry_point.module,
package_roots,
file_name_filter=lambda file_name: file_name.endswith(".py")
and not file_name.startswith("__"),
candidate_filter=_plugin_class_is_candidate,
):
if reference.name in seen_names:
continue
references.append(reference)
seen_names.add(reference.name)
return tuple(references)
def get_all_plugin_widget_references(*, use_cache: bool = True) -> list[BECClassReference]:
snapshot = _plugin_entry_point_snapshot()
if not use_cache or snapshot not in _PLUGIN_WIDGET_REFERENCE_CACHE:
_PLUGIN_WIDGET_REFERENCE_CACHE[snapshot] = _build_plugin_widget_references()
return list(_PLUGIN_WIDGET_REFERENCE_CACHE[snapshot])
logger = bec_logger.logger
def _submodule_specs(module: ModuleType) -> tuple[ModuleSpec | None, ...]:
+2 -1
View File
@@ -10,16 +10,17 @@ from qtpy.QtGui import QFont, QPixmap
from qtpy.QtWidgets import QApplication, QFileDialog, QLabel, QVBoxLayout, QWidget
import bec_widgets.widgets.containers.qt_ads as QtAds
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.busy_loader import install_busy_loader
from bec_widgets.utils.error_popups import SafeConnect, SafeSlot
from bec_widgets.utils.rpc_decorator import rpc_timeout
from bec_widgets.utils.rpc_register import RPCRegister
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.busy_loader import BusyLoaderOverlay
from bec_widgets.widgets.containers.dock import BECDock
logger = bec_logger.logger
@@ -7,6 +7,7 @@ import inspect
import os
import sys
from pathlib import Path
from typing import get_overloads
import black
import isort
@@ -18,20 +19,6 @@ from bec_widgets.utils.plugin_utils import BECClassContainer, get_custom_classes
logger = bec_logger.logger
if sys.version_info >= (3, 11):
from typing import get_overloads
else:
print(
"Python version is less than 3.11, using dummy function for get_overloads. "
"If you want to use the real function 'typing.get_overloads()', please use Python 3.11 or later."
)
def get_overloads(_obj):
"""
Dummy function for Python versions before 3.11.
"""
return []
class ClientGenerator:
def __init__(self, base=False):
@@ -54,7 +41,7 @@ from __future__ import annotations
from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
{"from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module" if self._base else ""}
{"from bec_widgets.utils.bec_plugin_helper import get_plugin_client_module" if self._base else ""}
logger = bec_logger.logger
@@ -111,27 +98,19 @@ _Widgets = {
self.content += """
try:
_plugin_widgets = get_all_plugin_widgets().as_dict()
plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
for _widget in _overlap:
logger.warning(f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !")
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
if plugin_name not in _Widgets:
_Widgets[plugin_name] = plugin_name
if plugin_name in globals():
conflicting_file = (
inspect.getfile(_plugin_widgets[plugin_name])
if plugin_name in _plugin_widgets
else f"{plugin_client}"
)
logger.warning(
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
f"Plugin widget {plugin_name} in {plugin_class._IMPORT_MODULE} conflicts with a built-in class!"
)
continue
if plugin_name not in _overlap:
else:
globals()[plugin_name] = plugin_class
Widgets = _WidgetsEnumType("Widgets", _Widgets)
except ImportError as e:
logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}")
"""
@@ -146,12 +125,8 @@ except ImportError as e:
class_name = cls.__name__
if class_name == "BECDockArea":
self.content += f"""
class {class_name}(RPCBase):"""
else:
self.content += f"""
class {class_name}(RPCBase):"""
self.content += f"""
class {class_name}(RPCBase):\n"""
if cls.__doc__:
# We only want the first line of the docstring
@@ -162,13 +137,9 @@ class {class_name}(RPCBase):"""
else:
class_docs = cls.__doc__.split("\n")[1]
self.content += f"""
\"\"\"{class_docs}\"\"\"
"""
\"\"\"{class_docs}\"\"\"\n"""
user_access_entries = self._get_user_access_entries(cls)
if not user_access_entries:
self.content += """...
"""
self.content += f' _IMPORT_MODULE="{cls.__module__}"\n'
for method_entry in user_access_entries:
method, obj, is_property_setter = self._resolve_method_object(cls, method_entry)
if obj is None:
+47 -288
View File
@@ -1,23 +1,22 @@
from __future__ import annotations
import ast
import importlib
import inspect
import os
from dataclasses import dataclass
from functools import lru_cache
from importlib import util as importlib_util
from typing import TYPE_CHECKING, Callable, Iterable
from typing import TYPE_CHECKING, Iterable
from bec_lib.plugin_helper import _get_available_plugins
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
_DISCOVERY_BASE_NAMES = frozenset({"BECConnector", "BECWidget", "ViewBase"})
def get_plugin_widgets() -> dict[str, "BECConnector"]:
def get_plugin_widgets() -> dict[str, BECConnector]:
"""
Get all available widgets from the plugin directory. Widgets are classes that inherit from BECConnector.
The plugins are provided through python plugins and specified in the respective pyproject.toml file using
@@ -36,10 +35,9 @@ def get_plugin_widgets() -> dict[str, "BECConnector"]:
Returns:
dict[str, BECConnector]: A dictionary of widget names and their respective classes.
"""
from bec_lib.plugin_helper import _get_available_plugins
modules = _get_available_plugins("bec.widgets.user_widgets")
loaded_plugins = {}
print(modules)
for module in modules:
mods = inspect.getmembers(module, predicate=_filter_plugins)
for name, mod_cls in mods:
@@ -50,8 +48,6 @@ def get_plugin_widgets() -> dict[str, "BECConnector"]:
def _filter_plugins(obj):
from bec_widgets.utils.bec_connector import BECConnector
return inspect.isclass(obj) and issubclass(obj, BECConnector)
@@ -70,8 +66,6 @@ def get_plugin_auto_updates() -> dict[str, type[AutoUpdates]]:
Returns:
dict[str, AutoUpdates]: A dictionary of widget names and their respective classes.
"""
from bec_lib.plugin_helper import _get_available_plugins
modules = _get_available_plugins("bec.widgets.auto_updates")
loaded_plugins = {}
for module in modules:
@@ -96,20 +90,14 @@ class BECClassInfo:
name: str
module: str
file: str
obj: type["BECWidget"]
obj: type[BECWidget]
is_connector: bool = False
is_widget: bool = False
is_plugin: bool = False
@dataclass(frozen=True)
class BECClassReference:
name: str
module: str
class BECClassContainer:
def __init__(self, initial: Iterable[BECClassInfo] = ()):
def __init__(self, initial: Iterable[BECClassInfo] = []):
self._collection: list[BECClassInfo] = list(initial)
def __repr__(self):
@@ -121,13 +109,12 @@ class BECClassContainer:
def __add__(self, other: BECClassContainer):
return BECClassContainer((*self, *(c for c in other if c.name not in self.names)))
def as_dict(self, ignores: list[str] | None = None) -> dict[str, type["BECWidget"]]:
def as_dict(self, ignores: list[str] = []) -> dict[str, type[BECWidget]]:
"""get a dict of {name: Type} for all the entries in the collection.
Args:
ignores(list[str]): a list of class names to exclude from the dictionary."""
ignore_set = set(ignores or ())
return {c.name: c.obj for c in self if c.name not in ignore_set}
return {c.name: c.obj for c in self if c.name not in ignores}
def add_class(self, class_info: BECClassInfo):
"""
@@ -179,276 +166,48 @@ class BECClassContainer:
return [info.obj for info in self.collection]
def _ast_node_name(node: ast.expr) -> str | None:
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Attribute):
return node.attr
return None
def _class_has_rpc_markers(node: ast.ClassDef) -> bool:
for stmt in node.body:
if isinstance(stmt, ast.Assign):
target_names = {target.id for target in stmt.targets if isinstance(target, ast.Name)}
if (
"PLUGIN" in target_names
and isinstance(stmt.value, ast.Constant)
and stmt.value.value
):
return True
if {"RPC_CONTENT_CLASS", "USER_ACCESS"} & target_names:
return True
if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
if (
stmt.target.id == "PLUGIN"
and isinstance(stmt.value, ast.Constant)
and stmt.value.value
):
return True
if stmt.target.id in {"RPC_CONTENT_CLASS", "USER_ACCESS"}:
return True
return False
def _class_is_candidate(node: ast.ClassDef) -> bool:
base_names = {_ast_node_name(base) for base in node.bases}
return bool(_DISCOVERY_BASE_NAMES & base_names) or _class_has_rpc_markers(node)
def _candidate_top_level_class_names(path: str) -> list[str]:
with open(path, encoding="utf-8") as file_handle:
module = ast.parse(file_handle.read(), filename=path)
return [
node.name
for node in module.body
if isinstance(node, ast.ClassDef) and _class_is_candidate(node)
]
@lru_cache(maxsize=64)
def _find_package_roots(module_name: str) -> tuple[str, ...]:
spec = importlib_util.find_spec(module_name)
if spec is None:
raise ModuleNotFoundError(module_name)
package_roots = tuple(spec.submodule_search_locations or ())
if package_roots:
return package_roots
if spec.origin:
return (os.path.dirname(spec.origin),)
raise ModuleNotFoundError(module_name)
def _discover_class_references_from_roots(
module_prefix: str,
package_roots: Iterable[str],
*,
file_name_filter: Callable[[str], bool],
candidate_filter: Callable[[ast.ClassDef], bool],
) -> tuple[BECClassReference, ...]:
references: list[BECClassReference] = []
seen_names: set[str] = set()
for package_root in package_roots:
for root, _, files in sorted(os.walk(package_root)):
for file_name in sorted(files):
if not file_name_filter(file_name):
continue
path = os.path.join(root, file_name)
with open(path, encoding="utf-8") as file_handle:
module = ast.parse(file_handle.read(), filename=path)
rel_path = os.path.relpath(path, package_root).removesuffix(".py")
module_name = ".".join([module_prefix, *rel_path.split(os.sep)])
for node in module.body:
if not isinstance(node, ast.ClassDef) or not candidate_filter(node):
continue
if node.name in seen_names:
continue
references.append(BECClassReference(name=node.name, module=module_name))
seen_names.add(node.name)
return tuple(references)
def _iter_candidate_modules(repo_name: str, package: str) -> Iterable[tuple[str, str, list[str]]]:
try:
package_roots = _find_package_roots(f"{repo_name}.{package}")
except ModuleNotFoundError:
return ()
modules: list[tuple[str, str, list[str]]] = []
for directory in package_roots:
for root, _, files in sorted(os.walk(directory)):
for file_name in sorted(files):
if (
not file_name.endswith(".py")
or file_name.startswith("__")
or file_name.startswith("register_")
or file_name.endswith("_plugin.py")
):
continue
path = os.path.join(root, file_name)
rel_dir = os.path.dirname(os.path.relpath(path, directory))
module_name = (
file_name.removesuffix(".py")
if rel_dir in ("", ".")
else ".".join(rel_dir.split(os.sep) + [file_name.removesuffix(".py")])
)
class_names = _candidate_top_level_class_names(path)
if class_names:
modules.append((f"{repo_name}.{package}.{module_name}", path, class_names))
return tuple(modules)
def _collect_classes_from_package(repo_name: str, package: str) -> BECClassContainer:
"""Collect classes from a package subtree (for example ``widgets`` or ``applications``)."""
collection = BECClassContainer()
for module_name, path, _ in _iter_candidate_modules(repo_name, package):
from qtpy.QtWidgets import QWidget
try:
anchor_module = importlib.import_module(f"{repo_name}.{package}")
except ModuleNotFoundError as exc:
# Some plugin repositories expose only one subtree. Skip gracefully if it does not exist.
if exc.name == f"{repo_name}.{package}":
return collection
raise
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
module = importlib.import_module(module_name)
for name, obj in inspect.getmembers(module, inspect.isclass):
if obj.__module__ != module.__name__:
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
class_info = BECClassInfo(name=name, module=module.__name__, file=path, obj=obj)
if issubclass(obj, BECConnector):
class_info.is_connector = True
if issubclass(obj, QWidget) or issubclass(obj, BECWidget):
class_info.is_widget = True
if hasattr(obj, "PLUGIN") and obj.PLUGIN:
class_info.is_plugin = True
collection.add_class(class_info)
path = os.path.join(root, file)
rel_dir = os.path.dirname(os.path.relpath(path, directory))
if rel_dir in ("", "."):
module_name = file.split(".")[0]
else:
module_name = ".".join(rel_dir.split(os.sep) + [file.split(".")[0]])
module = importlib.import_module(f"{repo_name}.{package}.{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):
class_info = BECClassInfo(name=name, module=module.__name__, file=path, obj=obj)
if issubclass(obj, BECConnector):
class_info.is_connector = True
if issubclass(obj, QWidget) or issubclass(obj, BECWidget):
class_info.is_widget = True
if hasattr(obj, "PLUGIN") and obj.PLUGIN:
class_info.is_plugin = True
collection.add_class(class_info)
return collection
def _build_ast_inheritance_map(
repo_name: str, packages: tuple[str, ...]
) -> dict[str, tuple[str, set[str]]]:
"""
Walk all candidate modules in the given packages and return a map of:
class_name -> (module_name, {direct_base_names})
This is used for the transitive-closure widget discovery so that subclasses
of discovered widget bases are themselves discoverable without needing to
repeat ``PLUGIN = True`` on every intermediate or leaf class.
"""
mapping: dict[str, tuple[str, set[str]]] = {}
for package in packages:
try:
package_roots = _find_package_roots(f"{repo_name}.{package}")
except ModuleNotFoundError:
continue
for directory in package_roots:
for root, _, files in sorted(os.walk(directory)):
for file_name in sorted(files):
if (
not file_name.endswith(".py")
or file_name.startswith("__")
or file_name.startswith("register_")
or file_name.endswith("_plugin.py")
):
continue
path = os.path.join(root, file_name)
rel_dir = os.path.dirname(os.path.relpath(path, directory))
module_name = (
file_name.removesuffix(".py")
if rel_dir in ("", ".")
else ".".join(rel_dir.split(os.sep) + [file_name.removesuffix(".py")])
)
full_module = f"{repo_name}.{package}.{module_name}"
with open(path, encoding="utf-8") as fh:
tree = ast.parse(fh.read(), filename=path)
for node in tree.body:
if not isinstance(node, ast.ClassDef):
continue
base_names = {_ast_node_name(b) for b in node.bases} - {None}
mapping[node.name] = (full_module, base_names) # type: ignore[arg-type]
return mapping
@lru_cache(maxsize=32)
def _cached_custom_class_references(
repo_name: str, packages: tuple[str, ...]
) -> tuple[BECClassReference, ...]:
"""Discover widget/connector class references using a transitive-closure AST scan.
The first pass identifies classes that directly inherit from
``_DISCOVERY_BASE_NAMES`` or carry explicit RPC markers (``PLUGIN``,
``USER_ACCESS``, ``RPC_CONTENT_CLASS``). Subsequent passes treat every
newly found class name as an additional base name, so subclasses of
subclasses are discovered automatically — without requiring each
intermediate class to repeat ``PLUGIN = True``.
"""
inheritance_map = _build_ast_inheritance_map(repo_name, packages)
# Seed with _class_has_rpc_markers — we need the AST nodes for that check.
# Re-parse only to identify initial RPC-marker classes; inheritance_map
# already has everything else we need.
rpc_marker_names: set[str] = set()
for package in packages:
try:
package_roots = _find_package_roots(f"{repo_name}.{package}")
except ModuleNotFoundError:
continue
for directory in package_roots:
for root, _, files in sorted(os.walk(directory)):
for file_name in sorted(files):
if (
not file_name.endswith(".py")
or file_name.startswith("__")
or file_name.startswith("register_")
or file_name.endswith("_plugin.py")
):
continue
path = os.path.join(root, file_name)
with open(path, encoding="utf-8") as fh:
tree = ast.parse(fh.read(), filename=path)
for node in tree.body:
if isinstance(node, ast.ClassDef) and _class_has_rpc_markers(node):
rpc_marker_names.add(node.name)
# Transitive closure: start with known base names + RPC-marker classes,
# then repeatedly add classes whose direct bases are already known.
known: set[str] = set(_DISCOVERY_BASE_NAMES) | rpc_marker_names
changed = True
while changed:
changed = False
for class_name, (_, bases) in inheritance_map.items():
if class_name not in known and bases & known:
known.add(class_name)
changed = True
# Build the final list of references, preserving first-seen order and
# keeping only classes that are in the inheritance map (i.e. have a module).
references: list[BECClassReference] = []
seen_names: set[str] = set()
for class_name, (module_name, bases) in inheritance_map.items():
if class_name not in known:
continue
if class_name in seen_names:
continue
# Only emit if the class is actually a candidate (not just a raw base)
if class_name in _DISCOVERY_BASE_NAMES:
continue
references.append(BECClassReference(name=class_name, module=module_name))
seen_names.add(class_name)
return tuple(references)
def get_custom_class_references(
repo_name: str, packages: tuple[str, ...] | None = None, *, use_cache: bool = True
) -> list[BECClassReference]:
selected_packages = packages or ("widgets",)
if use_cache:
return list(_cached_custom_class_references(repo_name, tuple(selected_packages)))
_cached_custom_class_references.cache_clear()
return list(_cached_custom_class_references(repo_name, tuple(selected_packages)))
def get_custom_classes(
repo_name: str, packages: tuple[str, ...] | None = None
) -> BECClassContainer:
+2 -1
View File
@@ -14,17 +14,18 @@ from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import QWidget
from redis.exceptions import RedisError
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import ErrorPopupUtility
from bec_widgets.utils.rpc_register import RPCRegister
from bec_widgets.utils.screen_utils import apply_window_geometry
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC
if TYPE_CHECKING: # pragma: no cover
from bec_lib import messages
from qtpy.QtCore import QObject
else:
messages = lazy_import("bec_lib.messages")
logger = bec_logger.logger
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.plugin_utils import get_custom_classes
class RPCWidgetHandler:
"""Handler class for creating widgets from RPC messages."""
def __init__(self):
self._widget_classes = None
@property
def widget_classes(self) -> dict[str, type[BECWidget]]:
"""
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 # type: ignore
def update_available_widgets(self):
"""
Update the available widgets.
Returns:
None
"""
self._widget_classes = (
get_custom_classes("bec_widgets", packages=("widgets", "applications"))
+ get_all_plugin_widgets()
).as_dict(IGNORE_WIDGETS)
def create_widget(self, widget_type, **kwargs) -> BECWidget:
"""
Create a widget from an RPC message.
Args:
widget_type(str): The type of the widget.
name (str): The name of the widget.
**kwargs: The keyword arguments for the widget.
Returns:
widget(BECWidget): The created widget.
"""
widget_class = self.widget_classes.get(widget_type) # type: ignore
if widget_class:
return widget_class(**kwargs)
raise ValueError(f"Unknown widget type: {widget_type}")
widget_handler = RPCWidgetHandler()
@@ -13,9 +13,9 @@ from shiboken6 import isValid
import bec_widgets.widgets.containers.qt_ads as QtAds
from bec_widgets import BECWidget, SafeSlot
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.property_editor import PropertyEditor
from bec_widgets.utils.rpc_widget_handler import widget_handler
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.widgets.containers.qt_ads import (
CDockAreaWidget,
@@ -20,10 +20,10 @@ from qtpy.QtWidgets import (
import bec_widgets.widgets.containers.qt_ads as QtAds
from bec_widgets import BECWidget, SafeProperty, SafeSlot
from bec_widgets.applications.views.view import ViewTourSteps
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.rpc_decorator import rpc_timeout
from bec_widgets.utils.rpc_widget_handler import widget_handler
from bec_widgets.utils.toolbars.actions import (
ExpandableMenuAction,
MaterialIconAction,
@@ -68,7 +68,7 @@ from bec_widgets.widgets.containers.dock_area.toolbar_components.workspace_actio
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindowNoRPC
from bec_widgets.widgets.containers.qt_ads import CDockWidget
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox, PositionerBox2D
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
from bec_widgets.widgets.control.scan_control import ScanControl
from bec_widgets.widgets.editors.bec_console.bec_console import BecConsole, BECShell
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
from bec_widgets.widgets.plots.image.image import Image
@@ -79,7 +79,7 @@ from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.utility.logpanel import LogPanel
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
logger = bec_logger.logger
@@ -376,6 +376,7 @@ class BECDockArea(DockAreaWidget):
"bec_shell": (BECShell.ICON_NAME, "Add BEC Shell", "BECShell"),
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel", "LogPanel"),
}
# Create expandable menu actions (original behavior)
@@ -487,9 +488,7 @@ class BECDockArea(DockAreaWidget):
# first two items not needed for this part
for key, (_, _, widget_type) in mapping.items():
act = menu.actions[key].action
if widget_type == "LogPanel":
act.setEnabled(False) # keep disabled per issue #644
elif key == "terminal":
if key == "terminal":
act.triggered.connect(
lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None)
)
@@ -510,10 +509,7 @@ class BECDockArea(DockAreaWidget):
for action_id, (_, _, widget_type) in mapping.items():
flat_action_id = f"flat_{action_id}"
flat_action = self.toolbar.components.get_action(flat_action_id).action
if widget_type == "LogPanel":
flat_action.setEnabled(False) # keep disabled per issue #644
else:
flat_action.triggered.connect(lambda _, t=widget_type: self.new(t))
flat_action.triggered.connect(lambda _, t=widget_type: self.new(t))
_connect_flat_actions(self._ACTION_MAPPINGS["menu_plots"])
_connect_flat_actions(self._ACTION_MAPPINGS["menu_devices"])
@@ -22,7 +22,7 @@ from qtpy.QtWidgets import (
)
from typeguard import typechecked
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils.rpc_widget_handler import widget_handler
class LayoutManagerWidget(QWidget):
@@ -1,4 +1 @@
from PySide6QtAds import *
CDockManager.setConfigFlag(CDockManager.eConfigFlag.FocusHighlighting, True)
CDockManager.setConfigFlag(CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True)
@@ -0,0 +1 @@
from .components import DeviceTable, DMConfigView, DocstringView, OphydValidation
@@ -0,0 +1,5 @@
# from .device_table_view import DeviceTableView
from .device_table.device_table import DeviceTable
from .dm_config_view import DMConfigView
from .dm_docstring_view import DocstringView, docstring_to_markdown
from .ophyd_validation.ophyd_validation import OphydValidation
@@ -0,0 +1,3 @@
from .available_device_resources import AvailableDeviceResources
__all__ = ["AvailableDeviceResources"]
@@ -22,7 +22,7 @@ from bec_widgets.utils.fuzzy_search import is_match
from bec_widgets.widgets.control.device_manager.components.device_table.device_table_row import (
DeviceTableRow,
)
from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation_utils import (
from bec_widgets.widgets.control.device_manager.components.ophyd_validation import (
ConfigStatus,
ConnectionStatus,
get_validation_icons,
@@ -2,7 +2,7 @@
from bec_lib.atlas_models import Device as DeviceModel
from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation_utils import (
from bec_widgets.widgets.control.device_manager.components.ophyd_validation import (
ConfigStatus,
ConnectionStatus,
)
@@ -0,0 +1,8 @@
from .ophyd_validation_utils import (
ConfigStatus,
ConnectionStatus,
DeviceTestModel,
format_error_to_md,
get_validation_icons,
)
from .validation_list_item import ValidationButton, ValidationListItem
@@ -22,16 +22,14 @@ from bec_widgets.utils.bec_list import BECList
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation_utils import (
from bec_widgets.widgets.control.device_manager.components.ophyd_validation import (
ConfigStatus,
ConnectionStatus,
DeviceTestModel,
format_error_to_md,
get_validation_icons,
)
from bec_widgets.widgets.control.device_manager.components.ophyd_validation.validation_list_item import (
ValidationButton,
ValidationListItem,
format_error_to_md,
get_validation_icons,
)
READY_TO_TEST = False
@@ -7,7 +7,7 @@ from qtpy import QtCore, QtGui, QtWidgets
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation_utils import (
from bec_widgets.widgets.control.device_manager.components.ophyd_validation import (
ConfigStatus,
ConnectionStatus,
DeviceTestModel,
@@ -0,0 +1 @@
from .scan_control import ScanControl
@@ -1,12 +1,10 @@
from bec_lib.utils.import_utils import lazy_import_from
from bec_ipython_client.main import BECIPythonClient
from qtconsole.inprocess import QtInProcessKernelManager
from qtconsole.manager import QtKernelManager
from qtconsole.rich_jupyter_widget import RichJupyterWidget
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication, QMainWindow
BECIPythonClient = lazy_import_from("bec_ipython_client.main", ("BECIPythonClient",))
class BECJupyterConsole(RichJupyterWidget): # pragma: no cover:
def __init__(self, inprocess: bool = False):
@@ -11,7 +11,7 @@ from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize, Qt
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QPushButton, QVBoxLayout
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
from bec_widgets.widgets.control.scan_control import ScanControl
logger = bec_logger.logger
+1 -1
View File
@@ -611,7 +611,7 @@ class Heatmap(ImageBase):
scan_msg = self.scan_item.status_message
elif hasattr(self.scan_item, "metadata"):
metadata = self.scan_item.metadata["bec"]
status = metadata["exit_status"]
status = metadata["status"]
scan_id = metadata["scan_id"]
scan_name = metadata["scan_name"]
scan_type = metadata["scan_type"]
@@ -12,15 +12,15 @@ from pyqtgraph import SignalProxy
from qtpy.QtCore import QThreadPool, Signal
from qtpy.QtWidgets import QFileDialog, QListWidget, QToolButton, QVBoxLayout, QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames
from bec_widgets.utils.rpc_register import RPCRegister
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.services.device_browser.device_item import DeviceItem
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
DirectUpdateDeviceConfigDialog,
)
from bec_widgets.widgets.services.device_browser.device_item.device_item import DeviceItem
from bec_widgets.widgets.services.device_browser.util import map_device_type_to_icon
logger = bec_logger.logger
@@ -0,0 +1 @@
from .device_item import DeviceItem
@@ -0,0 +1,5 @@
from .scan_history_device_viewer import ScanHistoryDeviceViewer
from .scan_history_metadata_viewer import ScanHistoryMetadataViewer
from .scan_history_view import ScanHistoryView
__all__ = ["ScanHistoryDeviceViewer", "ScanHistoryMetadataViewer", "ScanHistoryView"]
@@ -330,10 +330,8 @@ class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
from bec_widgets.widgets.services.scan_history_browser.components.scan_history_device_viewer import (
from bec_widgets.widgets.services.scan_history_browser.components import (
ScanHistoryDeviceViewer,
)
from bec_widgets.widgets.services.scan_history_browser.components.scan_history_metadata_viewer import (
ScanHistoryMetadataViewer,
)
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
@@ -1,13 +1,9 @@
from qtpy import QtCore, QtWidgets
from bec_widgets.utils.bec_widget import BECWidget, ConnectionConfig
from bec_widgets.widgets.services.scan_history_browser.components.scan_history_device_viewer import (
from bec_widgets.widgets.services.scan_history_browser.components import (
ScanHistoryDeviceViewer,
)
from bec_widgets.widgets.services.scan_history_browser.components.scan_history_metadata_viewer import (
ScanHistoryMetadataViewer,
)
from bec_widgets.widgets.services.scan_history_browser.components.scan_history_view import (
ScanHistoryView,
)
@@ -1,58 +0,0 @@
"""Utilities for filtering and formatting in the LogPanel"""
from __future__ import annotations
import re
from collections import deque
from typing import Callable, Iterator
from bec_lib.logger import LogLevel
from bec_lib.messages import LogMessage
from qtpy.QtCore import QDateTime
LinesHtmlFormatter = Callable[[deque[LogMessage]], Iterator[str]]
LineFormatter = Callable[[LogMessage], str]
LineFilter = Callable[[LogMessage], bool] | None
ANSI_ESCAPE_REGEX = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
def replace_escapes(s: str):
s = ANSI_ESCAPE_REGEX.sub("", s)
return s.replace(" ", "&nbsp;").replace("\n", "<br />").replace("\t", " ")
def level_filter(msg: LogMessage, thresh: int):
return LogLevel[msg.content["log_type"].upper()].value >= thresh
def noop_format(line: LogMessage):
_textline = line.log_msg if isinstance(line.log_msg, str) else line.log_msg["text"]
return replace_escapes(_textline.strip()) + "<br />"
def simple_color_format(line: LogMessage, colors: dict[LogLevel, str]):
color = colors.get(LogLevel[line.content["log_type"].upper()]) or colors[LogLevel.INFO]
return f'<font color="{color}">{noop_format(line)}</font>'
def create_formatter(line_format: LineFormatter, line_filter: LineFilter) -> LinesHtmlFormatter:
def _formatter(data: deque[LogMessage]):
if line_filter is not None:
return (line_format(line) for line in data if line_filter(line))
else:
return (line_format(line) for line in data)
return _formatter
def log_txt(line):
return line.log_msg if isinstance(line.log_msg, str) else line.log_msg["text"]
def log_time(line):
return QDateTime.fromMSecsSinceEpoch(int(line.log_msg["record"]["time"]["timestamp"] * 1000))
def log_svc(line):
return line.log_msg["service_name"]
@@ -30,7 +30,7 @@ class LogPanelPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return DOM_XML
def group(self):
return "BEC Services"
return ""
def icon(self):
return designer_material_icon(LogPanel.ICON_NAME)
@@ -51,7 +51,7 @@ class LogPanelPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "LogPanel"
def toolTip(self):
return "Displays a log panel"
return "LogPanel"
def whatsThis(self):
return self.toolTip()
+432 -329
View File
@@ -2,21 +2,31 @@
from __future__ import annotations
import operator
import os
import re
from collections import deque
from functools import partial, reduce
from re import Pattern
from typing import TYPE_CHECKING, Literal
from dataclasses import dataclass
from functools import partial
from typing import Iterable, Literal
from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import LogLevel, bec_logger
from bec_lib.messages import LogMessage, StatusMessage
from pyqtgraph import SignalProxy
from qtpy.QtCore import QDateTime, QObject, Qt, Signal
from qtpy.QtGui import QFont
from bec_qthemes import material_icon
from qtpy.QtCore import Signal # type: ignore
from qtpy.QtCore import (
QAbstractTableModel,
QCoreApplication,
QDateTime,
QModelIndex,
QObject,
QPersistentModelIndex,
QSize,
QSortFilterProxyModel,
Qt,
QTimer,
)
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
@@ -25,204 +35,414 @@ from qtpy.QtWidgets import (
QDialog,
QGridLayout,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QTextEdit,
QSizePolicy,
QTableView,
QToolButton,
QVBoxLayout,
QWidget,
)
from thefuzz import fuzz
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.colors import apply_theme, get_theme_palette
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.editors.text_box.text_box import TextBox
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECServiceStatusMixin
from bec_widgets.widgets.utility.logpanel._util import (
LineFilter,
LineFormatter,
LinesHtmlFormatter,
create_formatter,
level_filter,
log_svc,
log_time,
log_txt,
noop_format,
simple_color_format,
)
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtCore import SignalInstance
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
# TODO: improve log color handling
DEFAULT_LOG_COLORS = {
LogLevel.INFO: "#FFFFFF",
LogLevel.SUCCESS: "#00FF00",
LogLevel.WARNING: "#FFCC00",
LogLevel.ERROR: "#FF0000",
LogLevel.DEBUG: "#0000CC",
_DEFAULT_LOG_COLORS = {
LogLevel.INFO.name: QColor("#FFFFFF"),
LogLevel.SUCCESS.name: QColor("#00FF00"),
LogLevel.WARNING.name: QColor("#FFCC00"),
LogLevel.ERROR.name: QColor("#FF0000"),
LogLevel.DEBUG.name: QColor("#0000CC"),
}
@dataclass(frozen=True)
class _Constants:
FUZZ_THRESHOLD = 80
UPDATE_INTERVAL_MS = 200
headers = ["level", "timestamp", "service_name", "message", "function"]
_CONST = _Constants()
class TimestampUpdate:
def __init__(self, value: QDateTime | None, update_type: Literal["start", "end"]) -> None:
self.value = value
self.update_type = update_type
class BecLogsQueue(BECConnector, QObject):
"""Manages getting logs from BEC Redis and formatting them for display"""
RPC = False
new_message = Signal()
new_messages = Signal()
paused = Signal(bool)
_instance: BecLogsQueue | None = None
def __init__(
self,
parent: QObject | None,
maxlen: int = 1000,
line_formatter: LineFormatter = noop_format,
**kwargs,
) -> None:
@classmethod
def instance(cls):
if cls._instance is None:
cls._instance = cls(QCoreApplication.instance())
return cls._instance
def __init__(self, parent: QObject | None, maxlen: int = 2500, **kwargs) -> None:
if BecLogsQueue._instance:
raise RuntimeError("Create no more than one BecLogsQueue - use BecLogsQueue.instance()")
super().__init__(parent=parent, **kwargs)
self._timestamp_start: QDateTime | None = None
self._timestamp_end: QDateTime | None = None
self._max_length = maxlen
self._data: deque[LogMessage] = deque([], self._max_length)
self._display_queue: deque[str] = deque([], self._max_length)
self._log_level: str | None = None
self._search_query: Pattern | str | None = None
self._selected_services: set[str] | None = None
self._set_formatter_and_update_filter(line_formatter)
# instance attribute still accessible after c++ object is deleted, so the callback can be unregistered
self._paused = False
self._data = deque(
(
item["data"]
for item in self.bec_dispatcher.client.connector.xread(
MessageEndpoints.log(), count=self._max_length, id="0"
)
),
maxlen=self._max_length,
)
self._incoming: deque[LogMessage] = deque([], maxlen=self._max_length)
self.bec_dispatcher.connect_slot(self._process_incoming_log_msg, MessageEndpoints.log())
self._update_timer = QTimer(self, interval=_CONST.UPDATE_INTERVAL_MS)
self._update_timer.timeout.connect(self._proc_update)
QCoreApplication.instance().aboutToQuit.connect(self.cleanup) # type: ignore
self._update_timer.start()
def __len__(self):
return len(self._data)
@SafeSlot()
def toggle_pause(self):
self._paused = not self._paused
self.paused.emit(self._paused)
def row_data(self, index: int) -> LogMessage | None:
if index < 0 or index > (len(self._data) - 1):
return None
return self._data[index]
def cell_data(self, row: int, key: str):
if key == "level":
return self._data[row].log_type.upper()
msg_item = self._data[row].log_msg
if isinstance(msg_item, str):
return msg_item
if key == "service_name":
return msg_item.get(key)
elif key in ["service_name", "function", "message"]:
return msg_item.get("record", {}).get(key)
elif key == "timestamp":
return msg_item.get("record", {}).get("time", {}).get("repr")
def log_timestamp(self, row: int) -> float:
msg_item = self._data[row].log_msg
if isinstance(msg_item, str):
return 0
return msg_item.get("record", {}).get("time", {}).get("timestamp")
def cleanup(self, *_):
"""Stop listening to the Redis log stream"""
self.bec_dispatcher.disconnect_slot(
self._process_incoming_log_msg, [MessageEndpoints.log()]
)
self._update_timer.stop()
BecLogsQueue._instance = None
@SafeSlot(verify_sender=True)
def _process_incoming_log_msg(self, msg: dict, _metadata: dict):
try:
_msg = LogMessage(**msg)
self._data.append(_msg)
if self.filter is None or self.filter(_msg):
self._display_queue.append(self._line_formatter(_msg))
self.new_message.emit()
self._incoming.append(_msg)
except Exception as e:
if "Internal C++ object (BecLogsQueue) already deleted." in e.args:
return
logger.warning(f"Error in LogPanel incoming message callback: {e}")
def _set_formatter_and_update_filter(self, line_formatter: LineFormatter = noop_format):
self._line_formatter: LineFormatter = line_formatter
self._queue_formatter: LinesHtmlFormatter = create_formatter(
self._line_formatter, self.filter
)
def _combine_filters(self, *args: LineFilter):
return lambda msg: reduce(operator.and_, [filt(msg) for filt in args if filt is not None])
def _create_re_filter(self) -> LineFilter:
if self._search_query is None:
return None
elif isinstance(self._search_query, str):
return lambda line: self._search_query in log_txt(line)
return lambda line: self._search_query.match(log_txt(line)) is not None
def _create_service_filter(self):
return (
lambda line: self._selected_services is None or log_svc(line) in self._selected_services
)
def _create_timestamp_filter(self) -> LineFilter:
s, e = self._timestamp_start, self._timestamp_end
if s is e is None:
return lambda msg: True
def _time_filter(msg):
msg_time = log_time(msg)
if s is None:
return msg_time <= e
if e is None:
return s <= msg_time
return s <= msg_time <= e
return _time_filter
@property
def filter(self) -> LineFilter:
"""A function which filters a log message based on all applied criteria"""
thresh = LogLevel[self._log_level].value if self._log_level is not None else 0
return self._combine_filters(
partial(level_filter, thresh=thresh),
self._create_re_filter(),
self._create_timestamp_filter(),
self._create_service_filter(),
)
def update_level_filter(self, level: str):
"""Change the log-level of the level filter"""
if level not in [l.name for l in LogLevel]:
logger.error(f"Logging level {level} unrecognized for filter!")
@SafeSlot(verify_sender=True)
def _proc_update(self):
if self._paused or len(self._incoming) == 0:
return
self._log_level = level
self._set_formatter_and_update_filter(self._line_formatter)
self._data.extend(self._incoming)
self._incoming.clear()
self.new_messages.emit()
def update_search_filter(self, search_query: Pattern | str | None = None):
"""Change the string or regex to filter against"""
self._search_query = search_query
self._set_formatter_and_update_filter(self._line_formatter)
def update_time_filter(self, start: QDateTime | None, end: QDateTime | None):
"""Change the start and/or end times to filter against"""
self._timestamp_start = start
self._timestamp_end = end
self._set_formatter_and_update_filter(self._line_formatter)
class BecLogsTableModel(QAbstractTableModel):
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self.log_queue = BecLogsQueue.instance()
self.log_queue.new_messages.connect(self.handle_new_messages)
self._headers = _CONST.headers
def update_service_filter(self, services: set[str]):
"""Change the selected services to display"""
self._selected_services = services
self._set_formatter_and_update_filter(self._line_formatter)
def rowCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int:
return len(self.log_queue)
def update_line_formatter(self, line_formatter: LineFormatter):
"""Update the formatter"""
self._set_formatter_and_update_filter(line_formatter)
def columnCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int:
return len(self._headers)
def display_all(self) -> str:
"""Return formatted output for all log messages"""
return "\n".join(self._queue_formatter(self._data.copy()))
def headerData(self, section, orientation, role=int(Qt.ItemDataRole.DisplayRole)):
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
return self._headers[section]
return None
def format_new(self):
"""Return formatted output for the display queue"""
res = "\n".join(self._display_queue)
self._display_queue = deque([], self._max_length)
return res
def get_row_data(self, index: QModelIndex) -> LogMessage | None:
"""Return the row data for the given index."""
if not index.isValid():
return None
return self.log_queue.row_data(index.row())
def clear_logs(self):
"""Clear the cache and display queue"""
self._data = deque([])
self._display_queue = deque([])
def timestamp(self, row: int):
return QDateTime.fromMSecsSinceEpoch(int(self.log_queue.log_timestamp(row) * 1000))
def fetch_history(self):
"""Fetch all available messages from Redis"""
self._data = deque(
item["data"]
for item in self.bec_dispatcher.client.connector.xread(
MessageEndpoints.log().endpoint, from_start=True, count=self._max_length
)
def data(self, index, role=int(Qt.ItemDataRole.DisplayRole)):
"""Return data for the given index and role."""
if not index.isValid():
return
if role in [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ToolTipRole]:
return self.log_queue.cell_data(index.row(), self._headers[index.column()])
if role in [Qt.ItemDataRole.ForegroundRole]:
return self._map_log_level_color(self.log_queue.cell_data(index.row(), "level"))
def _map_log_level_color(self, data):
return _DEFAULT_LOG_COLORS.get(data)
def handle_new_messages(self):
self.dataChanged.emit(
self.index(0, 0), self.index(self.rowCount() - 1, self.columnCount() - 1)
)
def unique_service_names_from_history(self) -> set[str]:
"""Go through the log history to determine active service names"""
return set(msg.log_msg["service_name"] for msg in self._data)
class LogMsgProxyModel(QSortFilterProxyModel):
show_service_column = Signal(bool)
def __init__(
self,
parent=None,
service_filter: set[str] | None = None,
level_filter: LogLevel | None = None,
):
super().__init__(parent)
self._service_filter = service_filter or set()
self._level_filter: LogLevel | None = level_filter
self._filter_text: str = ""
self._fuzzy_search: bool = False
self._time_filter_start: QDateTime | None = None
self._time_filter_end: QDateTime | None = None
def get_row_data(self, rows: Iterable[QModelIndex]) -> Iterable[LogMessage | None]:
return (self.sourceModel().get_row_data(self.mapToSource(idx)) for idx in rows)
def sourceModel(self) -> BecLogsTableModel:
return super().sourceModel() # type: ignore
@SafeSlot(int, int)
def refresh(self, *_):
self.invalidateRowsFilter()
@SafeSlot(None)
@SafeSlot(set)
def update_service_filter(self, filter: set[str]):
"""Filter to the selected services (show any service in the provided set)
Args:
filter (set[str] | None): set of services for which to show logs"""
self._service_filter = filter
self.show_service_column.emit(len(filter) != 1)
self.invalidateRowsFilter()
@SafeSlot(None)
@SafeSlot(LogLevel)
def update_level_filter(self, filter: LogLevel | None):
"""Filter to the selected log level
Args:
filter (str | None): lowest log level to show"""
self._level_filter = filter
self.invalidateRowsFilter()
@SafeSlot(str)
def update_filter_text(self, filter: str):
"""Filter messages based on text
Args:
filter (str | None): set of services for which to show logs"""
self._filter_text = filter
self.invalidateRowsFilter()
@SafeSlot(bool)
def update_fuzzy(self, state: bool):
"""Set text filter to fuzzy search or not
Args:
state (bool): fuzzy search on"""
self._fuzzy_search = state
self.invalidateRowsFilter()
@SafeSlot(TimestampUpdate)
def update_timestamp(self, update: TimestampUpdate):
if update.update_type == "start":
self._time_filter_start = update.value
else:
self._time_filter_end = update.value
self.invalidateRowsFilter()
def filterAcceptsRow(self, source_row: int, source_parent) -> bool:
# No service filter, and no filter text, display everything
possible_filters = [
self._service_filter,
self._level_filter,
self._filter_text,
self._time_filter_start,
self._time_filter_end,
]
if not any(map(bool, possible_filters)):
return True
model = self.sourceModel()
# Filter out services
if self._service_filter:
col = _CONST.headers.index("service_name")
if model.data(model.index(source_row, col, source_parent)) not in self._service_filter:
return False
# Filter out levels
if self._level_filter:
col = _CONST.headers.index("level")
level: str = model.data(model.index(source_row, col, source_parent)) # type: ignore
if LogLevel[level] < self._level_filter:
return False
# Filter time
if self._time_filter_start:
if model.timestamp(source_row) < self._time_filter_start:
return False
if self._time_filter_end:
if model.timestamp(source_row) > self._time_filter_end:
return False
# Filter message text - must go last because this can return True
if self._filter_text:
col = _CONST.headers.index("message")
msg: str = model.data(model.index(source_row, col, source_parent)).lower() # type: ignore
if self._fuzzy_search:
return fuzz.partial_ratio(self._filter_text.lower(), msg) >= _CONST.FUZZ_THRESHOLD
else:
return self._filter_text.lower() in msg.lower()
return True
class BecLogTableView(QTableView):
def __init__(self, *args, max_message_width: int = 1000, **kwargs) -> None:
super().__init__(*args, **kwargs)
header = QHeaderView(Qt.Orientation.Horizontal, parent=self)
header.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
header.setStretchLastSection(True)
header.setMaximumSectionSize(max_message_width)
self.setHorizontalHeader(header)
def model(self) -> LogMsgProxyModel:
return super().model() # type: ignore
class LogPanel(BECWidget, QWidget):
"""Live display of the BEC logs in a table view."""
PLUGIN = True
ICON_NAME = "browse_activity"
def __init__(
self,
parent: QWidget | None = None,
max_message_width: int = 1000,
show_toolbar: bool = True,
service_filter: set[str] | None = None,
level_filter: LogLevel | None = None,
**kwargs,
) -> None:
super().__init__(parent=parent, **kwargs)
self._setup_models(service_filter=service_filter, level_filter=level_filter)
self._layout = QVBoxLayout()
self.setLayout(self._layout)
if show_toolbar:
self._setup_toolbar(client=self.client)
self._setup_table_view(max_message_width=max_message_width)
self._update_service_filter(service_filter or set())
if show_toolbar:
self._connect_toolbar()
self._proxy.show_service_column.connect(self._show_service_column)
colors = QApplication.instance().theme.accent_colors # type: ignore
dict_colors = QApplication.instance().theme.colors # type: ignore
_DEFAULT_LOG_COLORS.update(
{
LogLevel.INFO.name: dict_colors["FG"],
LogLevel.SUCCESS.name: colors.success,
LogLevel.WARNING.name: colors.warning,
LogLevel.ERROR.name: colors.emergency,
LogLevel.DEBUG.name: dict_colors["BORDER"],
}
)
self._table.scrollToBottom()
def _setup_models(self, service_filter: set[str] | None, level_filter: LogLevel | None):
self._model = BecLogsTableModel(parent=self)
self._proxy = LogMsgProxyModel(
parent=self, service_filter=service_filter, level_filter=level_filter
)
self._proxy.setSourceModel(self._model)
self._model.log_queue.new_messages.connect(self._proxy.refresh)
def _setup_table_view(self, max_message_width: int) -> None:
"""Setup the table view."""
self._table = BecLogTableView(self, max_message_width=max_message_width)
self._table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._layout.addWidget(self._table)
self._table.setModel(self._proxy)
self._table.setHorizontalScrollMode(QTableView.ScrollMode.ScrollPerPixel)
self._table.setTextElideMode(Qt.TextElideMode.ElideRight)
self._table.resizeColumnsToContents()
def _setup_toolbar(self, client: BECClient):
self._toolbar = LogPanelToolbar(self, client)
self._layout.addWidget(self._toolbar)
def _connect_toolbar(self):
self._toolbar.services_selected.connect(self._proxy.update_service_filter)
self._toolbar.search_textbox.textChanged.connect(self._proxy.update_filter_text)
self._toolbar.level_changed.connect(self._proxy.update_level_filter)
self._toolbar.fuzzy_changed.connect(self._proxy.update_fuzzy)
self._toolbar.timestamp_update.connect(self._proxy.update_timestamp)
self._toolbar.pause_button.clicked.connect(self._model.log_queue.toggle_pause)
self._model.log_queue.paused.connect(self._toolbar._update_pause_button_icon)
def _update_service_filter(self, filter: set[str]):
self._service_filter = filter
self._proxy.update_service_filter(filter)
self._table.setColumnHidden(
_CONST.headers.index("service_name"), len(self._service_filter) == 1
)
@SafeSlot(bool)
def _show_service_column(self, show: bool):
self._table.setColumnHidden(_CONST.headers.index("service_name"), not show)
def sizeHint(self) -> QSize:
return QSize(600, 300)
class LogPanelToolbar(QWidget):
services_selected = Signal(set)
level_changed = Signal(LogLevel)
fuzzy_changed = Signal(bool)
timestamp_update = Signal(TimestampUpdate)
services_selected: SignalInstance = Signal(set)
def __init__(self, parent: QWidget | None = None) -> None:
def __init__(self, parent: QWidget | None = None, client: BECClient | None = None) -> None:
"""A toolbar for the logpanel, mainly used for managing the states of filters"""
super().__init__(parent)
@@ -231,51 +451,69 @@ class LogPanelToolbar(QWidget):
self._timestamp_end: QDateTime | None = None
self._unique_service_names: set[str] = set()
self._services_selected: set[str] | None = None
self._services_selected: set[str] = set()
self.layout = QHBoxLayout(self) # type: ignore
self._layout = QHBoxLayout(self)
self.service_choice_button = QPushButton("Select services", self)
self.layout.addWidget(self.service_choice_button)
self.service_choice_button.clicked.connect(self._open_service_filter_dialog)
if client is not None:
self.client = client
self.service_choice_button = QPushButton("Select services", self)
self._layout.addWidget(self.service_choice_button)
self.service_choice_button.clicked.connect(self._open_service_filter_dialog)
self.service_list_update(self.client.service_status)
self._services_selected = self._unique_service_names
self.filter_level_dropdown = self._log_level_box()
self.layout.addWidget(self.filter_level_dropdown)
self.clear_button = QPushButton("Clear all", self)
self.layout.addWidget(self.clear_button)
self.fetch_button = QPushButton("Fetch history", self)
self.layout.addWidget(self.fetch_button)
self._layout.addWidget(self.filter_level_dropdown)
self.filter_level_dropdown.currentTextChanged.connect(self._emit_level)
self._string_search_box()
self.timerange_button = QPushButton("Set time range", self)
self.layout.addWidget(self.timerange_button)
self._layout.addWidget(self.timerange_button)
self.timerange_button.clicked.connect(self._open_datetime_dialog)
@property
def time_start(self):
return self._timestamp_start
self.pause_button = QToolButton()
self.pause_button.setIcon(material_icon("pause", size=(20, 20), convert_to_pixmap=False))
self._PLAYING_TOOLTIP = "Pause live log updates."
self._PAUSED_TOOLTIP = "Continue live log updates."
self.pause_button.setToolTip(self._PLAYING_TOOLTIP)
self._layout.addWidget(self.pause_button)
@property
def time_end(self):
return self._timestamp_end
@SafeSlot(bool)
def _update_pause_button_icon(self, paused):
if paused:
icon = "play_arrow"
tooltip = self._PAUSED_TOOLTIP
else:
icon = "pause"
tooltip = self._PLAYING_TOOLTIP
self.pause_button.setIcon(material_icon(icon, size=(20, 20), convert_to_pixmap=False))
self.pause_button.setToolTip(tooltip)
def _string_search_box(self):
self.layout.addWidget(QLabel("Search: "))
self._layout.addWidget(QLabel("Search: "))
self.search_textbox = QLineEdit()
self.layout.addWidget(self.search_textbox)
self.layout.addWidget(QLabel("Use regex: "))
self.regex_enabled = QCheckBox()
self.layout.addWidget(self.regex_enabled)
self.update_re_button = QPushButton("Update search", self)
self.layout.addWidget(self.update_re_button)
self._layout.addWidget(self.search_textbox)
self._layout.addWidget(QLabel("Fuzzy: "))
self.fuzzy = QCheckBox()
self._layout.addWidget(self.fuzzy)
self.fuzzy.checkStateChanged.connect(self._emit_fuzzy)
def _log_level_box(self):
box = QComboBox()
box.setToolTip("Display logs with equal or greater significance to the selected level.")
[box.addItem(l.name) for l in LogLevel]
[box.addItem(level.name) for level in LogLevel]
return box
@SafeSlot(str)
def _emit_level(self, level: str):
self.level_changed.emit(LogLevel[level])
@SafeSlot(Qt.CheckState)
def _emit_fuzzy(self, state: Qt.CheckState):
self.fuzzy_changed.emit(state == Qt.CheckState.Checked)
def _current_ts(self, selection_type: Literal["start", "end"]):
if selection_type == "start":
return self._timestamp_start
@@ -284,6 +522,7 @@ class LogPanelToolbar(QWidget):
else:
raise ValueError(f"timestamps can only be for the start or end, not {selection_type}")
@SafeSlot()
def _open_datetime_dialog(self):
"""Open dialog window for timestamp filter selection"""
self._dt_dialog = QDialog(self)
@@ -312,8 +551,8 @@ class LogPanelToolbar(QWidget):
)
_layout.addWidget(date_clear_button)
for v in [("start", label_start), ("end", label_end)]:
date_button_set(*v)
date_button_set("start", label_start)
date_button_set("end", label_end)
close_button = QPushButton("Close", parent=self._dt_dialog)
close_button.clicked.connect(self._dt_dialog.accept)
@@ -352,27 +591,23 @@ class LogPanelToolbar(QWidget):
self._timestamp_start = dt
else:
self._timestamp_end = dt
self.timestamp_update.emit(TimestampUpdate(value=dt, update_type=selection_type))
@SafeSlot(dict, set)
def service_list_update(
self, services_info: dict[str, StatusMessage], services_from_history: set[str], *_, **__
):
def service_list_update(self, services_info: dict[str, StatusMessage]):
"""Change the list of services which can be selected"""
self._unique_service_names = set([s.split("/")[0] for s in services_info.keys()])
self._unique_service_names |= services_from_history
if self._services_selected is None:
self._services_selected = self._unique_service_names
@SafeSlot()
def _open_service_filter_dialog(self):
self.service_list_update(self.client.service_status)
if len(self._unique_service_names) == 0 or self._services_selected is None:
return
self._svc_dialog = QDialog(self)
self._svc_dialog.setWindowTitle(f"Select services to show logs from")
self._svc_dialog.setWindowTitle("Select services to show logs from")
layout = QVBoxLayout()
self._svc_dialog.setLayout(layout)
service_cb_grid = QGridLayout(parent=self._svc_dialog)
service_cb_grid = QGridLayout()
layout.addLayout(service_cb_grid)
def check_box(name: str, checked: Qt.CheckState):
@@ -398,146 +633,6 @@ class LogPanelToolbar(QWidget):
self._svc_dialog.deleteLater()
class LogPanel(TextBox):
"""Displays a log panel"""
ICON_NAME = "terminal"
service_list_update = Signal(dict, set)
def __init__(
self,
parent=None,
client: BECClient | None = None,
service_status: BECServiceStatusMixin | None = None,
**kwargs,
):
"""Initialize the LogPanel widget."""
super().__init__(parent=parent, client=client, config={"text": ""}, **kwargs)
self._update_colors()
self._service_status = service_status or BECServiceStatusMixin(self, client=self.client) # type: ignore
self._log_manager = BecLogsQueue(
parent=self, line_formatter=partial(simple_color_format, colors=self._colors)
)
self._proxy_update = SignalProxy(
self._log_manager.new_message, rateLimit=1, slot=self._on_append
)
self.toolbar = LogPanelToolbar(parent=self)
self.toolbar_area = QScrollArea()
self.toolbar_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.toolbar_area.setSizeAdjustPolicy(QScrollArea.SizeAdjustPolicy.AdjustToContents)
self.toolbar_area.setFixedHeight(int(self.toolbar.clear_button.height() * 2))
self.toolbar_area.setWidget(self.toolbar)
self.layout.addWidget(self.toolbar_area)
self.toolbar.clear_button.clicked.connect(self._on_clear)
self.toolbar.fetch_button.clicked.connect(self._on_fetch)
self.toolbar.update_re_button.clicked.connect(self._on_re_update)
self.toolbar.search_textbox.returnPressed.connect(self._on_re_update)
self.toolbar.regex_enabled.checkStateChanged.connect(self._on_re_update)
self.toolbar.filter_level_dropdown.currentTextChanged.connect(self._set_level_filter)
self.toolbar.timerange_button.clicked.connect(self._choose_datetime)
self._service_status.services_update.connect(self._update_service_list)
self.service_list_update.connect(self.toolbar.service_list_update)
self.toolbar.services_selected.connect(self._update_service_filter)
self.text_box_text_edit.setFont(QFont("monospace", 12))
self.text_box_text_edit.setHtml("")
self.text_box_text_edit.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
self._connect_to_theme_change()
@SafeSlot(set)
def _update_service_filter(self, services: set[str]):
self._log_manager.update_service_filter(services)
self._on_redraw()
@SafeSlot(dict, dict)
def _update_service_list(self, services_info: dict[str, StatusMessage], *_, **__):
self.service_list_update.emit(
services_info, self._log_manager.unique_service_names_from_history()
)
@SafeSlot()
def _choose_datetime(self):
self.toolbar._open_datetime_dialog()
self._set_time_filter()
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self._on_redraw) # type: ignore
def _update_colors(self):
self._colors = DEFAULT_LOG_COLORS.copy()
self._colors.update({LogLevel.INFO: get_theme_palette().text().color().name()})
def _cursor_to_end(self):
c = self.text_box_text_edit.textCursor()
c.movePosition(c.MoveOperation.End)
self.text_box_text_edit.setTextCursor(c)
@SafeSlot()
@SafeSlot(str)
def _on_redraw(self, *_):
self._update_colors()
self._log_manager.update_line_formatter(partial(simple_color_format, colors=self._colors))
self.set_html_text(self._log_manager.display_all())
self._cursor_to_end()
@SafeSlot(verify_sender=True)
def _on_append(self, *_):
self.text_box_text_edit.insertHtml(self._log_manager.format_new())
self._cursor_to_end()
@SafeSlot()
def _on_clear(self):
self._log_manager.clear_logs()
self.set_html_text(self._log_manager.display_all())
self._cursor_to_end()
@SafeSlot()
@SafeSlot(Qt.CheckState)
def _on_re_update(self, *_):
if self.toolbar.regex_enabled.isChecked():
try:
search_query = re.compile(self.toolbar.search_textbox.text())
except Exception as e:
logger.warning(f"Failed to compile search regex with error {e}")
search_query = None
logger.info(f"Setting LogPanel search regex to {search_query}")
else:
search_query = self.toolbar.search_textbox.text()
logger.info(f'Setting LogPanel search string to "{search_query}"')
self._log_manager.update_search_filter(search_query)
self.set_html_text(self._log_manager.display_all())
self._cursor_to_end()
@SafeSlot()
def _on_fetch(self):
self._log_manager.fetch_history()
self.set_html_text(self._log_manager.display_all())
self._cursor_to_end()
@SafeSlot(str)
def _set_level_filter(self, level: str):
self._log_manager.update_level_filter(level)
self._on_redraw()
@SafeSlot()
def _set_time_filter(self):
self._log_manager.update_time_filter(self.toolbar.time_start, self.toolbar.time_end)
self._on_redraw()
def cleanup(self):
self._service_status.cleanup()
self._log_manager.cleanup()
self._log_manager.deleteLater()
super().cleanup()
if __name__ == "__main__": # pragma: no cover
import sys
@@ -545,7 +640,15 @@ if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
apply_theme("dark")
widget = LogPanel()
panel = QWidget()
queue = BecLogsQueue(panel)
layout = QVBoxLayout(panel)
layout.addWidget(QLabel("All logs, no filters:"))
layout.addWidget(LogPanel())
layout.addWidget(QLabel("All services, level filter WARNING preapplied:"))
layout.addWidget(LogPanel(level_filter=LogLevel.WARNING))
layout.addWidget(QLabel('All services, service filter {"DeviceServer"} preapplied:'))
layout.addWidget(LogPanel(service_filter={"DeviceServer"}))
widget.show()
panel.show()
sys.exit(app.exec())
@@ -3,6 +3,7 @@ from qtpy import QtCore, QtGui
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtWidgets import QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import Colors
+9 -11
View File
@@ -1,6 +1,6 @@
[project]
name = "bec_widgets"
version = "3.5.0"
version = "3.7.1"
description = "BEC Widgets"
requires-python = ">=3.11"
classifiers = [
@@ -12,19 +12,19 @@ dependencies = [
"PyJWT~=2.9",
"PySide6==6.9.0",
"PySide6-QtAds==4.4.0",
"bec_ipython_client~=3.107,>=3.107.2", # needed for jupyter console
"bec_ipython_client~=3.107,>=3.107.2", # needed for jupyter console
"bec_lib~=3.107,>=3.107.2",
"bec_qthemes~=1.0, >=1.3.4",
"black>=26,<27", # needed for bw-generate-cli
"black>=26,<27", # needed for bw-generate-cli
"copier~=9.7",
"darkdetect~=0.8",
"isort>=5.13, <9.0", # needed for bw-generate-cli
"isort>=5.13, <9.0", # needed for bw-generate-cli
"markdown~=3.9",
"ophyd_devices~=1.29, >=1.29.1",
"pydantic~=2.0",
"pylsp-bec~=1.2",
"pyqtgraph==0.13.7",
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtmonaco~=0.8, >=0.8.1",
"qtpy~=2.4",
"thefuzz~=0.22",
@@ -38,8 +38,8 @@ Homepage = "https://gitlab.psi.ch/bec/bec_widgets"
[project.scripts]
bec-app = "bec_widgets.applications.main_app:main"
bec-designer = "bec_widgets.utils.bec_designer:main"
bec-gui-server = "bec_widgets.cli.server:main"
bw-generate-cli = "bec_widgets.cli.generate_cli:main"
bec-gui-server = "bec_widgets.applications.companion_app:main"
bw-generate-cli = "bec_widgets.utils.generate_cli:main"
[project.optional-dependencies]
dev = [
@@ -55,9 +55,7 @@ dev = [
"watchdog~=6.0",
"pre_commit~=4.2",
]
qtermwidget = [
"pyside6_qtermwidget",
]
qtermwidget = ["pyside6_qtermwidget"]
[build-system]
requires = ["hatchling"]
@@ -68,7 +66,7 @@ line-length = 100
skip-magic-trailing-comma = true
[tool.coverage.report]
skip_empty = true # exclude empty *files*, e.g. __init__.py, from the report
skip_empty = true # exclude empty *files*, e.g. __init__.py, from the report
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
+18 -5
View File
@@ -1,3 +1,5 @@
import traceback
import pytest
import qtpy.QtCore
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
@@ -5,12 +7,14 @@ from qtpy.QtCore import QTimer
class TestableQTimer(QTimer):
_instances: list[tuple[QTimer, str]] = []
_instances: list[tuple[QTimer, str, str]] = []
_current_test_name: str = ""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
TestableQTimer._instances.append((self, TestableQTimer._current_test_name))
tb = traceback.format_stack()
init_line = list(filter(lambda msg: "QTimer(" in msg, tb))[-1]
TestableQTimer._instances.append((self, TestableQTimer._current_test_name, init_line))
@classmethod
def check_all_stopped(cls, qtbot):
@@ -20,12 +24,21 @@ class TestableQTimer(QTimer):
except RuntimeError as e:
return "already deleted" in e.args[0]
def _format_timers(timers: list[tuple[QTimer, str, str]]):
return "\n".join(
f"Timer: {t[0]}\n in test: {t[1]}\n created at:{t[2]}" for t in timers
)
try:
qtbot.waitUntil(lambda: all(_is_done_or_deleted(timer) for timer, _ in cls._instances))
qtbot.waitUntil(
lambda: all(_is_done_or_deleted(timer) for timer, _, _ in cls._instances)
)
except QtBotTimeoutError as exc:
active_timers = list(filter(lambda t: t[0].isActive(), cls._instances))
(t.stop() for t, _ in cls._instances)
raise TimeoutError(f"Failed to stop all timers: {active_timers}") from exc
(t.stop() for t, _, _ in cls._instances)
raise TimeoutError(
f"Failed to stop all timers:\n{_format_timers(active_timers)}"
) from exc
cls._instances = []
+5 -4
View File
@@ -5,7 +5,7 @@ import random
import pytest
from bec_widgets.cli.client_utils import BECGuiClient
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
from bec_widgets.widgets.control.scan_control import ScanControl
# pylint: disable=unused-argument
# pylint: disable=redefined-outer-name
@@ -33,7 +33,7 @@ def threads_check_fixture(threads_check):
@pytest.fixture
def gui_id():
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturb"""
return f"figure_{random.randint(0,100)}" # make a new gui id each time, to ensure no 'gui is alive' zombie key can perturb
return f"figure_{random.randint(0, 100)}" # make a new gui id each time, to ensure no 'gui is alive' zombie key can perturb
@pytest.fixture(scope="function")
@@ -51,6 +51,7 @@ def connected_client_gui_obj(qtbot, gui_id, bec_client_lib):
qtbot.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000)
yield gui
finally:
gui.bec.delete_all() # ensure clean state
qtbot.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000)
if (bec := getattr(gui, "bec", None)) is not None:
bec.delete_all() # ensure clean state
qtbot.waitUntil(lambda: len(bec.widget_list()) == 0, timeout=10000)
gui.kill_server()
+7
View File
@@ -89,6 +89,13 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
# Skip private attributes
if object_name.startswith("_"):
continue
# Skip BECShell as ttyd is not installed
if object_name == "BECShell":
continue
# Skip BecConsole as ttyd is not installed
if object_name == "BecConsole":
continue
#############################
######### Add widget ########
+1 -1
View File
@@ -10,7 +10,7 @@ except ImportError:
from qtpy.QtWidgets import QGridLayout
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
from bec_widgets.widgets.control.scan_control import ScanControl
@pytest.fixture(scope="function")
@@ -260,22 +260,6 @@ def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_fro
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# TODO re-enable when issue is resolved #560
# @pytest.mark.timeout(PYTEST_TIMEOUT)
# def test_widgets_e2e_log_panel(qtbot, connected_client_gui_obj, random_generator_from_seed):
# """Test the LogPanel widget."""
# gui = connected_client_gui_obj
# bec = gui._client
# # Create dock_area and widget
# widget = create_widget(qtbot, gui, gui.available_widgets.LogPanel)
# widget: client.LogPanel
# # No rpc calls to check so far
# # Test removing the widget, or leaving it open for the next test
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_minesweeper(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the MineSweeper widget."""
+4 -4
View File
@@ -23,11 +23,11 @@ from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
from qtpy.QtCore import QEvent, QEventLoop
from qtpy.QtWidgets import QApplication, QMessageBox
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.tests.utils import DEVICES, DMMock
from bec_widgets.utils import bec_dispatcher as bec_dispatcher_module
from bec_widgets.utils import error_popups
from bec_widgets.utils.bec_dispatcher import QtRedisConnector
from bec_widgets.utils.rpc_register import RPCRegister
# Patch to set default RAISE_ERROR_DEFAULT to True for tests
# This means that by default, error popups will raise exceptions during tests
@@ -227,7 +227,7 @@ def create_history_file(file_path, data: dict, metadata: dict) -> messages.ScanH
msg = messages.ScanHistoryMessage(
scan_id=metadata["scan_id"],
scan_name=metadata["scan_name"],
exit_status=metadata["exit_status"],
exit_status=metadata["status"],
file_path=file_path,
scan_number=metadata["scan_number"],
dataset_number=metadata["dataset_number"],
@@ -274,7 +274,7 @@ def grid_scan_history_msg(tmpdir):
"scan_id": "test_scan",
"scan_name": "grid_scan",
"scan_type": "step",
"exit_status": "closed",
"status": "closed",
"scan_number": 1,
"dataset_number": 1,
"request_inputs": {
@@ -354,7 +354,7 @@ def scan_history_factory(tmpdir):
"scan_id": scan_id,
"scan_name": scan_name,
"scan_type": scan_type,
"exit_status": "closed",
"status": "closed",
"scan_number": scan_number,
"dataset_number": dataset_number,
"request_inputs": {
@@ -9,7 +9,8 @@ from bec_widgets.cli.rpc.rpc_base import RPCBase
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
class _TestGlobalPlugin(RPCBase): ...
class _TestGlobalPlugin(RPCBase):
_IMPORT_MODULE = "test.global.plugin.widgets"
mock_client_module_globals = SimpleNamespace()
@@ -25,12 +26,13 @@ mock_client_module_globals.Widgets = _TestGlobalPlugin
def test_plugins_dont_clobber_client_globals(bec_logger: MagicMock):
reload(client)
bec_logger.logger.warning.assert_called_with(
"Plugin widget Widgets from namespace(Widgets=<class 'tests.unit_tests.test_client_plugin_widgets._TestGlobalPlugin'>) conflicts with a built-in class!"
"Plugin widget Widgets in test.global.plugin.widgets conflicts with a built-in class!"
)
assert isinstance(client.Widgets, enum.EnumType)
class _TestDuplicatePlugin(RPCBase): ...
class _TestDuplicatePlugin(RPCBase):
_IMPORT_MODULE = "test.duplicate.plugin.module"
mock_client_module_duplicate = SimpleNamespace()
@@ -54,7 +56,7 @@ def test_duplicate_plugins_not_allowed(_, bec_logger: MagicMock):
reload(client)
assert (
call(
f"Detected duplicate widget Waveform in plugin repo file: {inspect.getfile(_TestDuplicatePlugin)} !"
"Plugin widget Waveform in test.duplicate.plugin.module conflicts with a built-in class!"
)
in bec_logger.logger.warning.mock_calls
)
+2 -3
View File
@@ -8,9 +8,8 @@ from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import Colors, apply_theme
from bec_widgets.widgets.plots.waveform.curve import CurveConfig
from .client_mocks import mocked_client
from .conftest import create_widget
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget
def test_color_validation_CSS():
+2 -2
View File
@@ -7,8 +7,8 @@ from qtpy.QtGui import QTransform
from bec_widgets.utils.crosshair import Crosshair
from bec_widgets.widgets.plots.image.image_item import ImageItem
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from tests.unit_tests.client_mocks import mocked_client
from .client_mocks import mocked_client
from .conftest import create_widget
# pylint: disable = redefined-outer-name
@@ -214,7 +214,7 @@ def test_crosshair_clicked_signal(qtbot, plot_widget_with_crosshair):
pos_in_widget = graphics_view.mapFromScene(pos_in_scene)
# Simulate mouse click
qtbot.mouseClick(graphics_view.viewport(), Qt.MouseButton.LeftButton, pos=pos_in_widget)
qtbot.mouseClick(graphics_view.viewport(), Qt.LeftButton, pos=pos_in_widget)
x, y = emitted_positions[0]
+2 -3
View File
@@ -12,9 +12,8 @@ from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_tree impor
ScanIndexValidator,
)
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from .client_mocks import dap_plugin_message, mocked_client, mocked_client_with_dap
from .conftest import create_widget
from tests.unit_tests.client_mocks import dap_plugin_message, mocked_client, mocked_client_with_dap
from tests.unit_tests.conftest import create_widget
##################################################
# CurveSetting
+1 -1
View File
@@ -19,7 +19,7 @@ from .client_mocks import mocked_client
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtWidgets import QListWidgetItem
from bec_widgets.widgets.services.device_browser.device_item.device_item import DeviceItem
from bec_widgets.widgets.services.device_browser.device_item import DeviceItem
# pylint: disable=no-member
@@ -16,6 +16,8 @@ from qtpy import QtCore, QtGui, QtWidgets
from bec_widgets.utils.bec_list import BECList
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.widgets.control.device_manager import DeviceTable, DMConfigView, DocstringView
from bec_widgets.widgets.control.device_manager.components import docstring_to_markdown
from bec_widgets.widgets.control.device_manager.components.constants import HEADERS_HELP_MD
from bec_widgets.widgets.control.device_manager.components.device_config_template.device_config_template import (
DeviceConfigTemplate,
@@ -32,17 +34,9 @@ from bec_widgets.widgets.control.device_manager.components.device_config_templat
ReadoutPriorityComboBox,
_try_literal_eval,
)
from bec_widgets.widgets.control.device_manager.components.device_table.device_table import (
DeviceTable,
)
from bec_widgets.widgets.control.device_manager.components.device_table.device_table_row import (
DeviceTableRow,
)
from bec_widgets.widgets.control.device_manager.components.dm_config_view import DMConfigView
from bec_widgets.widgets.control.device_manager.components.dm_docstring_view import (
DocstringView,
docstring_to_markdown,
)
from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation import (
DeviceTest,
LegendLabel,
+4 -3
View File
@@ -31,11 +31,12 @@ from bec_widgets.applications.views.device_manager_view.device_manager_view impo
DeviceManagerWidget,
)
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.widgets.control.device_manager.components.device_table.device_table import (
from bec_widgets.widgets.control.device_manager.components import (
DeviceTable,
DMConfigView,
DocstringView,
OphydValidation,
)
from bec_widgets.widgets.control.device_manager.components.dm_config_view import DMConfigView
from bec_widgets.widgets.control.device_manager.components.dm_docstring_view import DocstringView
from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophyd_validation import (
ConfigStatus,
ConnectionStatus,
-6
View File
@@ -2229,7 +2229,6 @@ class TestFlatToolbarActions:
"flat_progress_bar",
"flat_terminal",
"flat_bec_shell",
"flat_log_panel",
"flat_sbb_monitor",
]
@@ -2289,11 +2288,6 @@ class TestFlatToolbarActions:
action.trigger()
mock_new.assert_called_once_with(widget_type)
def test_flat_log_panel_action_disabled(self, advanced_dock_area):
"""Test that flat log panel action is disabled."""
action = advanced_dock_area.toolbar.components.get_action("flat_log_panel").action
assert not action.isEnabled()
class TestModeTransitions:
"""Test mode transitions and state consistency."""
+11 -16
View File
@@ -5,7 +5,7 @@ import black
import isort
import pytest
from bec_widgets.cli.generate_cli import ClientGenerator
from bec_widgets.utils.generate_cli import ClientGenerator
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
# pylint: disable=missing-function-docstring
@@ -104,8 +104,7 @@ def test_client_generator_with_black_formatting():
from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
from bec_widgets.utils.bec_plugin_helper import (get_all_plugin_widgets,
get_plugin_client_module)
from bec_widgets.utils.bec_plugin_helper import get_plugin_client_module
logger = bec_logger.logger
@@ -123,31 +122,25 @@ def test_client_generator_with_black_formatting():
try:
_plugin_widgets = get_all_plugin_widgets().as_dict()
plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
for _widget in _overlap:
logger.warning(f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !")
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
if plugin_name not in _Widgets:
_Widgets[plugin_name] = plugin_name
if plugin_name in globals():
conflicting_file = (
inspect.getfile(_plugin_widgets[plugin_name])
if plugin_name in _plugin_widgets
else f"{plugin_client}"
)
logger.warning(
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
f"Plugin widget {plugin_name} in {plugin_class._IMPORT_MODULE} conflicts with a built-in class!"
)
continue
if plugin_name not in _overlap:
else:
globals()[plugin_name] = plugin_class
Widgets = _WidgetsEnumType("Widgets", _Widgets)
except ImportError as e:
logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}")
class MockBECFigure(RPCBase):
_IMPORT_MODULE = "tests.unit_tests.test_generate_cli_client"
@rpc_call
def add_plot(self, plot_id: str):
"""
@@ -162,6 +155,8 @@ def test_client_generator_with_black_formatting():
class MockBECWaveform1D(RPCBase):
_IMPORT_MODULE = "tests.unit_tests.test_generate_cli_client"
@rpc_call
def set_frequency(self, frequency: float) -> list:
"""
+3 -1
View File
@@ -16,7 +16,9 @@ from bec_widgets.widgets.plots.heatmap.heatmap import (
)
# pytest: disable=unused-import
from .client_mocks import create_dummy_scan_item, mocked_client
from tests.unit_tests.client_mocks import mocked_client
from .client_mocks import create_dummy_scan_item
@pytest.fixture
+5 -10
View File
@@ -7,9 +7,8 @@ from qtpy.QtCore import QPointF, Qt
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree
from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI
from .client_mocks import mocked_client
from .conftest import create_widget
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget
@pytest.fixture
@@ -45,7 +44,7 @@ def test_initialization(roi_tree, image_widget):
assert roi_tree.plot == image_widget.plot_item
assert roi_tree.controller == image_widget.roi_controller
assert isinstance(roi_tree.roi_items, dict)
assert len(roi_tree.tree.findItems("", Qt.MatchFlag.MatchContains)) == 0 # Empty tree initially
assert len(roi_tree.tree.findItems("", Qt.MatchContains)) == 0 # Empty tree initially
# Check toolbar actions
assert roi_tree.toolbar.components.get_action("roi_rectangle")
@@ -67,16 +66,12 @@ def test_controller_connection(roi_tree, image_widget):
# Verify that ROI was added to the tree
assert roi in roi_tree.roi_items
assert (
len(roi_tree.tree.findItems("test_roi", Qt.MatchFlag.MatchExactly, roi_tree.COL_ROI)) == 1
)
assert len(roi_tree.tree.findItems("test_roi", Qt.MatchExactly, roi_tree.COL_ROI)) == 1
# Remove ROI via controller and check that it's removed from the tree
image_widget.remove_roi(0)
assert roi not in roi_tree.roi_items
assert (
len(roi_tree.tree.findItems("test_roi", Qt.MatchFlag.MatchExactly, roi_tree.COL_ROI)) == 0
)
assert len(roi_tree.tree.findItems("test_roi", Qt.MatchExactly, roi_tree.COL_ROI)) == 0
def test_expand_collapse_tree(roi_tree, image_widget):
+2 -3
View File
@@ -12,9 +12,8 @@ from bec_widgets.widgets.plots.roi.image_roi import (
RectangularROI,
ROIController,
)
from .client_mocks import mocked_client
from .conftest import create_widget
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget
@pytest.fixture(params=["rect", "circle", "ellipse"])
+2 -3
View File
@@ -5,9 +5,8 @@ from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QPointF
from bec_widgets.widgets.plots.image.image import Image
from .client_mocks import mocked_client
from .conftest import create_widget
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget
##################################################
# Image widget base functionality tests
+106 -146
View File
@@ -7,163 +7,123 @@ from collections import deque
from unittest.mock import MagicMock, patch
import pytest
from bec_lib.logger import LogLevel
from bec_lib.messages import LogMessage
from bec_lib.redis_connector import StreamMessage
from qtpy.QtCore import QDateTime
from bec_widgets.widgets.utility.logpanel._util import (
log_time,
replace_escapes,
simple_color_format,
)
from bec_widgets.widgets.utility.logpanel.logpanel import DEFAULT_LOG_COLORS, LogPanel
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel, TimestampUpdate
from .client_mocks import mocked_client
TEST_TABLE_STRING = "2025-01-15 15:57:18 | bec_server.scan_server.scan_queue | [INFO] | \n \x1b[3m primary queue / ScanQueueStatus.RUNNING \x1b[0m\n┏━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┓\n\x1b[1m \x1b[0m\x1b[1m queue_id \x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mscan_id\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mis_scan\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mtype\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mscan_numb…\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mIQ status\x1b[0m\x1b[1m \x1b[0m┃\n┡━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━┩\n│ bbe50c82-6… │ None │ False │ mv │ None │ PENDING │\n└─────────────┴─────────┴─────────┴──────┴────────────┴───────────┘\n\n"
TEST_LOG_MESSAGES = [
LogMessage(
metadata={},
log_type="debug",
log_msg={
"text": "datetime | debug | test log message",
"record": {"time": {"timestamp": 123456789.000}},
"service_name": "ScanServer",
},
),
LogMessage(
metadata={},
log_type="info",
log_msg={
"text": "datetime | info | test log message",
"record": {"time": {"timestamp": 123456789.007}},
"service_name": "ScanServer",
},
),
LogMessage(
metadata={},
log_type="success",
log_msg={
"text": "datetime | success | test log message",
"record": {"time": {"timestamp": 123456789.012}},
"service_name": "ScanServer",
},
),
]
TEST_COMBINED_PLAINTEXT = "datetime | debug | test log message\ndatetime | info | test log message\ndatetime | success | test log message\n"
@pytest.fixture
def raw_queue():
yield deque(TEST_LOG_MESSAGES, maxlen=100)
@pytest.fixture
def log_panel(qtbot, mocked_client: MagicMock):
widget = LogPanel(client=mocked_client, service_status=MagicMock())
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_log_panel_init(log_panel: LogPanel):
assert log_panel.plain_text == ""
def test_table_string_processing():
assert "\x1b" in TEST_TABLE_STRING
sanitized = replace_escapes(TEST_TABLE_STRING)
assert "\x1b" not in sanitized
assert " " not in sanitized
assert "\n" not in sanitized
@pytest.mark.parametrize(
["msg", "color"], zip(TEST_LOG_MESSAGES, ["#0000CC", "#FFFFFF", "#00FF00"])
)
def test_color_format(msg: LogMessage, color: str):
assert color in simple_color_format(msg, DEFAULT_LOG_COLORS)
def test_logpanel_output(qtbot, log_panel: LogPanel):
log_panel._log_manager._data = deque(TEST_LOG_MESSAGES)
log_panel._on_redraw()
assert log_panel.plain_text == TEST_COMBINED_PLAINTEXT
def display_queue_empty():
print(log_panel._log_manager._display_queue)
return len(log_panel._log_manager._display_queue) == 0
next_text = "datetime | error | test log message"
msg = LogMessage(
metadata={},
log_type="error",
log_msg={"text": next_text, "record": {}, "service_name": "ScanServer"},
)
log_panel._log_manager._process_incoming_log_msg(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
qtbot.waitUntil(display_queue_empty, timeout=5000)
assert log_panel.plain_text == TEST_COMBINED_PLAINTEXT + next_text + "\n"
def test_level_filter(log_panel: LogPanel):
log_panel._log_manager._data = deque(TEST_LOG_MESSAGES)
log_panel._log_manager.update_level_filter("INFO")
log_panel._on_redraw()
assert (
log_panel.plain_text
== "datetime | info | test log message\ndatetime | success | test log message\n"
)
def test_clear_button(log_panel: LogPanel):
log_panel._log_manager._data = deque(TEST_LOG_MESSAGES)
log_panel.toolbar.clear_button.click()
assert log_panel._log_manager._data == deque([])
def test_timestamp_filter(log_panel: LogPanel):
log_panel._log_manager._timestamp_start = QDateTime(1973, 11, 29, 21, 33, 9, 5, 1)
pytest.approx(log_panel._log_manager._timestamp_start.toMSecsSinceEpoch() / 1000, 123456789.005)
log_panel._log_manager._timestamp_end = QDateTime(1973, 11, 29, 21, 33, 9, 10, 1)
pytest.approx(log_panel._log_manager._timestamp_end.toMSecsSinceEpoch() / 1000, 123456789.010)
filter_ = log_panel._log_manager._create_timestamp_filter()
assert not filter_(TEST_LOG_MESSAGES[0])
assert filter_(TEST_LOG_MESSAGES[1])
assert not filter_(TEST_LOG_MESSAGES[2])
def test_error_handling_in_callback(log_panel: LogPanel):
log_panel._log_manager.new_message = MagicMock()
with patch("bec_widgets.widgets.utility.logpanel.logpanel.logger") as logger:
# generally errors should be logged
log_panel._log_manager.new_message.emit = MagicMock(
side_effect=ValueError("Something went wrong")
)
msg = LogMessage(
{"data": msg}
for msg in [
LogMessage(
metadata={},
log_type="debug",
log_msg={
"text": "datetime | debug | test log message",
"record": {"time": {"timestamp": 123456789.000}},
"record": {
"time": {"timestamp": 123456789.000, "repr": "2025-01-01 00:00:01"},
"message": "test debug message abcd",
"function": "_debug",
},
"service_name": "ScanServer",
},
),
LogMessage(
metadata={},
log_type="info",
log_msg={
"text": "datetime | info | test info log message",
"record": {
"time": {"timestamp": 123456789.007, "repr": "2025-01-01 00:00:02"},
"message": "test info message efgh",
"function": "_info",
},
"service_name": "DeviceServer",
},
),
LogMessage(
metadata={},
log_type="success",
log_msg={
"text": "datetime | success | test log message",
"record": {
"time": {"timestamp": 123456789.012, "repr": "2025-01-01 00:00:03"},
"message": "test success message ijkl",
"function": "_success",
},
"service_name": "ScanServer",
},
),
]
]
@pytest.fixture
def log_panel(qtbot, mocked_client):
mocked_client.connector.xread = lambda *_, **__: TEST_LOG_MESSAGES
widget = LogPanel()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget._model.log_queue.cleanup()
widget.close()
widget.deleteLater()
qtbot.wait(100)
def test_log_panel_init(qtbot, log_panel: LogPanel):
assert log_panel
def test_log_panel_filters(qtbot, log_panel: LogPanel):
assert log_panel._proxy.rowCount() == 3
# Service filter
log_panel._update_service_filter({"DeviceServer"})
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 1, timeout=200)
log_panel._update_service_filter(set())
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 3, timeout=200)
# Text filter
log_panel._proxy.update_filter_text("efgh")
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 1, timeout=200)
log_panel._proxy.update_filter_text("")
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 3, timeout=200)
# Time filter
log_panel._proxy.update_timestamp(
TimestampUpdate(value=QDateTime.fromMSecsSinceEpoch(123456789004), update_type="start")
)
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 2, timeout=200)
log_panel._proxy.update_timestamp(
TimestampUpdate(value=QDateTime.fromMSecsSinceEpoch(123456789009), update_type="end")
)
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 1, timeout=200)
log_panel._proxy.update_timestamp(TimestampUpdate(value=None, update_type="start"))
log_panel._proxy.update_timestamp(TimestampUpdate(value=None, update_type="end"))
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 3, timeout=200)
# Level filter
log_panel._proxy.update_level_filter(LogLevel.SUCCESS)
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 1, timeout=200)
log_panel._proxy.update_level_filter(None)
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 3, timeout=200)
def test_log_panel_update(qtbot, log_panel: LogPanel):
log_panel._model.log_queue._incoming.append(
LogMessage(
metadata={},
log_type="error",
log_msg={
"text": "datetime | error | test log message",
"record": {
"time": {"timestamp": 123456789.015, "repr": "2025-01-01 00:00:03"},
"message": "test error message xyz",
"function": "_error",
},
"service_name": "ScanServer",
},
)
log_panel._log_manager._process_incoming_log_msg(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
logger.warning.assert_called_once()
# this specific error should be ignored and not relogged
log_panel._log_manager.new_message.emit = MagicMock(
side_effect=RuntimeError("Internal C++ object (BecLogsQueue) already deleted.")
)
log_panel._log_manager._process_incoming_log_msg(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
logger.warning.assert_called_once()
)
log_panel._model.log_queue._proc_update()
qtbot.waitUntil(lambda: log_panel._model.rowCount() == 4, timeout=500)
+1 -1
View File
@@ -1,8 +1,8 @@
from qtpy.QtTest import QSignalSpy
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from tests.unit_tests.client_mocks import mocked_client
from .client_mocks import mocked_client
from .conftest import create_widget
@@ -1,8 +1,8 @@
import numpy as np
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
from tests.unit_tests.client_mocks import mocked_client
from .client_mocks import mocked_client
from .conftest import create_widget
##################################################
+1 -13
View File
@@ -1,6 +1,4 @@
import sys
from bec_widgets.utils.plugin_utils import get_custom_class_references, get_custom_classes
from bec_widgets.utils.plugin_utils import get_custom_classes
def test_client_generator_classes():
@@ -12,13 +10,3 @@ def test_client_generator_classes():
assert "Waveform" in connector_cls_names
assert "MotorMap" in plugins
assert "NonExisting" not in plugins
def test_get_custom_class_references_avoids_importing_widget_modules():
target_module = "bec_widgets.widgets.plots.image.image"
sys.modules.pop(target_module, None)
references = get_custom_class_references("bec_widgets", use_cache=False)
assert "Image" in [reference.name for reference in references]
assert target_module not in sys.modules
@@ -3,8 +3,7 @@ import pytest
from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_settings_cards import RingSettings
from .client_mocks import mocked_client
from tests.unit_tests.client_mocks import mocked_client
@pytest.fixture
+1 -1
View File
@@ -1,4 +1,4 @@
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.rpc_register import RPCRegister
class FakeObject:
+1 -1
View File
@@ -5,7 +5,7 @@ import pytest
from bec_lib.service_config import ServiceConfig
from qtpy.QtWidgets import QWidget
from bec_widgets.cli.server import GUIServer
from bec_widgets.applications.companion_app import GUIServer
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.rpc_server import RegistryNotReadyError, RPCServer, SingleshotRPCRepeat
+17 -37
View File
@@ -1,9 +1,8 @@
import sys
from types import ModuleType
from unittest.mock import patch
from bec_widgets.cli.rpc.rpc_widget_handler import RPCWidgetHandler
from bec_widgets.utils.plugin_utils import BECClassReference
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
from bec_widgets.utils.rpc_widget_handler import RPCWidgetHandler
def test_rpc_widget_handler():
@@ -13,38 +12,19 @@ def test_rpc_widget_handler():
assert "BECDockArea" in handler.widget_classes
def test_duplicate_plugins_not_allowed(monkeypatch):
builtin_module = ModuleType("builtin_test_widgets")
plugin_module = ModuleType("plugin_test_widgets")
class _TestPluginWidget(BECWidget): ...
SharedWidgetBuiltin = type("SharedWidget", (), {})
SharedWidgetBuiltin.__module__ = builtin_module.__name__
setattr(builtin_module, "SharedWidget", SharedWidgetBuiltin)
SharedWidgetPlugin = type("SharedWidget", (), {})
SharedWidgetPlugin.__module__ = plugin_module.__name__
setattr(plugin_module, "SharedWidget", SharedWidgetPlugin)
NewPluginWidget = type("NewPluginWidget", (), {})
NewPluginWidget.__module__ = plugin_module.__name__
setattr(plugin_module, "NewPluginWidget", NewPluginWidget)
monkeypatch.setitem(sys.modules, builtin_module.__name__, builtin_module)
monkeypatch.setitem(sys.modules, plugin_module.__name__, plugin_module)
with (
patch(
"bec_widgets.cli.rpc.rpc_widget_handler.get_all_plugin_widget_references",
return_value=[
BECClassReference(name="SharedWidget", module=plugin_module.__name__),
BECClassReference(name="NewPluginWidget", module=plugin_module.__name__),
],
),
patch(
"bec_widgets.cli.rpc.rpc_widget_handler.get_custom_class_references",
return_value=[BECClassReference(name="SharedWidget", module=builtin_module.__name__)],
),
):
handler = RPCWidgetHandler()
assert handler.widget_classes["SharedWidget"].__module__ == builtin_module.__name__
assert handler.widget_classes["NewPluginWidget"].__module__ == plugin_module.__name__
@patch(
"bec_widgets.utils.rpc_widget_handler.get_all_plugin_widgets",
return_value=BECClassContainer(
[
BECClassInfo(name="DeviceComboBox", obj=_TestPluginWidget, module="", file=""),
BECClassInfo(name="NewPluginWidget", obj=_TestPluginWidget, module="", file=""),
]
),
)
def test_duplicate_plugins_not_allowed(_):
handler = RPCWidgetHandler()
assert handler.widget_classes["DeviceComboBox"] is not _TestPluginWidget
assert handler.widget_classes["NewPluginWidget"] is _TestPluginWidget
+3 -1
View File
@@ -9,7 +9,7 @@ from qtpy.QtCore import QModelIndex, Qt
from bec_widgets.utils.forms_from_types.items import StrFormItem
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
from bec_widgets.widgets.control.scan_control import ScanControl
from .client_mocks import mocked_client
@@ -501,6 +501,7 @@ def test_changing_scans_remember_parameters(scan_control, mocked_client):
assert grid_kwargs["burst_at_each_point"] == kwargs["burst_at_each_point"]
@pytest.mark.skip(reason="Unreliable - GH issue #1134")
def test_get_scan_parameters_from_redis(scan_control, mocked_client):
scan_name = "line_scan"
scan_control.comboBox_scan_selection.setCurrentText(scan_name)
@@ -585,6 +586,7 @@ def test_scan_metadata_is_passed_to_scan_function(scan_control: ScanControl):
scans.grid_scan.assert_called_once_with(metadata=TEST_MD)
@pytest.mark.skip(reason="Unreliable - GH issue #1134")
def test_restore_parameters_with_fewer_arg_bundles(scan_control, qtbot):
"""
Ensure that when more argument bundles are present than exist in the
@@ -6,13 +6,9 @@ from pytestqt import qtbot
from qtpy import QtCore
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.widgets.services.scan_history_browser.components.scan_history_device_viewer import (
from bec_widgets.widgets.services.scan_history_browser.components import (
ScanHistoryDeviceViewer,
)
from bec_widgets.widgets.services.scan_history_browser.components.scan_history_metadata_viewer import (
ScanHistoryMetadataViewer,
)
from bec_widgets.widgets.services.scan_history_browser.components.scan_history_view import (
ScanHistoryView,
)
from bec_widgets.widgets.services.scan_history_browser.scan_history_browser import (
+1 -1
View File
@@ -10,8 +10,8 @@ from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterW
from bec_widgets.widgets.plots.scatter_waveform.settings.scatter_curve_setting import (
ScatterCurveSettings,
)
from tests.unit_tests.client_mocks import create_dummy_scan_item, mocked_client
from .client_mocks import create_dummy_scan_item, mocked_client
from .conftest import create_widget
+2 -2
View File
@@ -18,8 +18,7 @@ from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.services.scan_history_browser.scan_history_browser import (
ScanHistoryBrowser,
)
from .client_mocks import (
from tests.unit_tests.client_mocks import (
DummyData,
create_dummy_scan_item,
dap_plugin_message,
@@ -27,6 +26,7 @@ from .client_mocks import (
mocked_client,
mocked_client_with_dap,
)
from .conftest import create_widget
# pylint: disable=unexpected-keyword-arg