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

Compare commits

..

17 Commits

Author SHA1 Message Date
perl_d 389519013d pin fakeredis ? 2026-04-21 12:11:22 +02:00
perl_d bf483dedf1 readd to stream just before reading 2026-04-21 11:55:56 +02:00
perl_d 1e8b135b6a check slot is called 2026-04-21 11:41:06 +02:00
perl_d 8ca0e297cf check line scan exists in scans 2026-04-21 11:25:49 +02:00
perl_d 494c3bafb3 make sure current index will change before setting scan 2026-04-21 11:22:15 +02:00
perl_d 5cdd8c56f2 waituntil signals resolved 2026-04-21 10:48:55 +02:00
perl_d 7715439d25 waituntil signals resolved 2026-04-21 10:21:45 +02:00
perl_d f95aee2091 wip: clear redis just in case 2026-04-21 10:16:51 +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
16 changed files with 804 additions and 675 deletions
+35
View File
@@ -1,6 +1,41 @@
# CHANGELOG # CHANGELOG
## 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) ## v3.5.0 (2026-04-14)
### Bug Fixes ### Bug Fixes
+127 -28
View File
@@ -13,7 +13,7 @@ from typing import Literal, Optional
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout 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 logger = bec_logger.logger
@@ -62,29 +62,19 @@ _Widgets = {
try: try:
_plugin_widgets = get_all_plugin_widgets().as_dict()
plugin_client = get_plugin_client_module() 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): for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase: 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(): 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( 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 continue
if plugin_name not in _overlap: else:
globals()[plugin_name] = plugin_class globals()[plugin_name] = plugin_class
Widgets = _WidgetsEnumType("Widgets", _Widgets)
except ImportError as e: except ImportError as e:
logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(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): class AdminView(RPCBase):
"""A view for administrators to change the current active experiment, manage messaging""" """A view for administrators to change the current active experiment, manage messaging"""
_IMPORT_MODULE = "bec_widgets.applications.views.admin_view.admin_view"
@rpc_call @rpc_call
def activate(self) -> "None": def activate(self) -> "None":
""" """
@@ -100,6 +92,8 @@ class AdminView(RPCBase):
class AutoUpdates(RPCBase): class AutoUpdates(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.containers.auto_update.auto_updates"
@property @property
@rpc_call @rpc_call
def enabled(self) -> "bool": def enabled(self) -> "bool":
@@ -136,6 +130,8 @@ class AutoUpdates(RPCBase):
class AvailableDeviceResources(RPCBase): class AvailableDeviceResources(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -156,6 +152,8 @@ class AvailableDeviceResources(RPCBase):
class BECDockArea(RPCBase): class BECDockArea(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.containers.dock_area.dock_area"
@rpc_call @rpc_call
def new( def new(
self, self,
@@ -391,6 +389,8 @@ class BECDockArea(RPCBase):
class BECMainWindow(RPCBase): class BECMainWindow(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.containers.main_window.main_window"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -413,6 +413,8 @@ class BECMainWindow(RPCBase):
class BECProgressBar(RPCBase): class BECProgressBar(RPCBase):
"""A custom progress bar with smooth transitions. The displayed text can be customized using a template.""" """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 @rpc_call
def set_value(self, value): def set_value(self, value):
""" """
@@ -486,6 +488,8 @@ class BECProgressBar(RPCBase):
class BECQueue(RPCBase): class BECQueue(RPCBase):
"""Widget to display the BEC queue.""" """Widget to display the BEC queue."""
_IMPORT_MODULE = "bec_widgets.widgets.services.bec_queue.bec_queue"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -508,6 +512,8 @@ class BECQueue(RPCBase):
class BECShell(RPCBase): class BECShell(RPCBase):
"""A BecConsole pre-configured to run the BEC shell.""" """A BecConsole pre-configured to run the BEC shell."""
_IMPORT_MODULE = "bec_widgets.widgets.editors.bec_console.bec_console"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -530,6 +536,8 @@ class BECShell(RPCBase):
class BECStatusBox(RPCBase): class BECStatusBox(RPCBase):
"""An autonomous widget to display the status of BEC services.""" """An autonomous widget to display the status of BEC services."""
_IMPORT_MODULE = "bec_widgets.widgets.services.bec_status_box.bec_status_box"
@rpc_call @rpc_call
def get_server_state(self) -> "str": def get_server_state(self) -> "str":
""" """
@@ -565,6 +573,8 @@ class BECStatusBox(RPCBase):
class BaseROI(RPCBase): class BaseROI(RPCBase):
"""Base class for all Region of Interest (ROI) implementations.""" """Base class for all Region of Interest (ROI) implementations."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
@property @property
@rpc_call @rpc_call
def label(self) -> "str": def label(self) -> "str":
@@ -694,6 +704,8 @@ class BaseROI(RPCBase):
class BecConsole(RPCBase): class BecConsole(RPCBase):
"""A console widget with access to a shared registry of terminals, such that instances can be moved around.""" """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 @rpc_call
def remove(self): def remove(self):
""" """
@@ -716,6 +728,8 @@ class BecConsole(RPCBase):
class CircularROI(RPCBase): class CircularROI(RPCBase):
"""Circular Region of Interest with center/diameter tracking and auto-labeling.""" """Circular Region of Interest with center/diameter tracking and auto-labeling."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
@property @property
@rpc_call @rpc_call
def label(self) -> "str": def label(self) -> "str":
@@ -843,6 +857,8 @@ class CircularROI(RPCBase):
class Curve(RPCBase): class Curve(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.plots.waveform.curve"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -1009,6 +1025,8 @@ class Curve(RPCBase):
class DapComboBox(RPCBase): class DapComboBox(RPCBase):
"""Editable combobox listing the available DAP models.""" """Editable combobox listing the available DAP models."""
_IMPORT_MODULE = "bec_widgets.widgets.dap.dap_combo_box.dap_combo_box"
@rpc_call @rpc_call
def select_y_axis(self, y_axis: str): def select_y_axis(self, y_axis: str):
""" """
@@ -1040,6 +1058,8 @@ class DapComboBox(RPCBase):
class DeveloperView(RPCBase): class DeveloperView(RPCBase):
"""A view for users to write scripts and macros and execute them within the application.""" """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 @rpc_call
def activate(self) -> "None": def activate(self) -> "None":
""" """
@@ -1050,6 +1070,8 @@ class DeveloperView(RPCBase):
class DeviceBrowser(RPCBase): class DeviceBrowser(RPCBase):
"""DeviceBrowser is a widget that displays all available devices in the current BEC session.""" """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 @rpc_call
def remove(self): def remove(self):
""" """
@@ -1072,6 +1094,8 @@ class DeviceBrowser(RPCBase):
class DeviceInitializationProgressBar(RPCBase): class DeviceInitializationProgressBar(RPCBase):
"""A progress bar that displays the progress of device initialization.""" """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 @rpc_call
def remove(self): def remove(self):
""" """
@@ -1094,6 +1118,8 @@ class DeviceInitializationProgressBar(RPCBase):
class DeviceInputBase(RPCBase): class DeviceInputBase(RPCBase):
"""Mixin base class for device input widgets.""" """Mixin base class for device input widgets."""
_IMPORT_MODULE = "bec_widgets.widgets.control.device_input.base_classes.device_input_base"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -1116,6 +1142,8 @@ class DeviceInputBase(RPCBase):
class DeviceManagerView(RPCBase): class DeviceManagerView(RPCBase):
"""A view for users to manage devices within the application.""" """A view for users to manage devices within the application."""
_IMPORT_MODULE = "bec_widgets.applications.views.device_manager_view.device_manager_view"
@rpc_call @rpc_call
def activate(self) -> "None": def activate(self) -> "None":
""" """
@@ -1126,6 +1154,8 @@ class DeviceManagerView(RPCBase):
class DockAreaView(RPCBase): class DockAreaView(RPCBase):
"""Modular dock area view for arranging and managing multiple dockable widgets.""" """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 @rpc_call
def activate(self) -> "None": def activate(self) -> "None":
""" """
@@ -1369,6 +1399,8 @@ class DockAreaView(RPCBase):
class DockAreaWidget(RPCBase): class DockAreaWidget(RPCBase):
"""Lightweight dock area that exposes the core Qt ADS docking helpers without any""" """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 @rpc_call
def new( def new(
self, self,
@@ -1553,6 +1585,8 @@ class DockAreaWidget(RPCBase):
class EllipticalROI(RPCBase): class EllipticalROI(RPCBase):
"""Elliptical Region of Interest with centre/width/height tracking and auto-labelling.""" """Elliptical Region of Interest with centre/width/height tracking and auto-labelling."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
@property @property
@rpc_call @rpc_call
def label(self) -> "str": def label(self) -> "str":
@@ -1675,6 +1709,8 @@ class EllipticalROI(RPCBase):
class Heatmap(RPCBase): class Heatmap(RPCBase):
"""Heatmap widget for visualizing 2d grid data with color mapping for the z-axis.""" """Heatmap widget for visualizing 2d grid data with color mapping for the z-axis."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.heatmap.heatmap"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -2373,6 +2409,8 @@ class Heatmap(RPCBase):
class Image(RPCBase): class Image(RPCBase):
"""Image widget for displaying 2D data.""" """Image widget for displaying 2D data."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.image.image"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -2984,6 +3022,8 @@ class Image(RPCBase):
class ImageItem(RPCBase): class ImageItem(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.plots.image.image_item"
@property @property
@rpc_call @rpc_call
def color_map(self) -> "str": def color_map(self) -> "str":
@@ -3134,6 +3174,8 @@ class ImageItem(RPCBase):
class LaunchWindow(RPCBase): class LaunchWindow(RPCBase):
_IMPORT_MODULE = "bec_widgets.applications.launch_window"
@rpc_call @rpc_call
def show_launcher(self): def show_launcher(self):
""" """
@@ -3148,33 +3190,38 @@ class LaunchWindow(RPCBase):
class LogPanel(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 @rpc_call
def set_plain_text(self, text: str) -> None: def remove(self):
""" """
Set the plain text of the widget. Cleanup the BECConnector
Args:
text (str): The text to set.
""" """
@rpc_call @rpc_call
def set_html_text(self, text: str) -> None: def attach(self):
"""
None
""" """
Set the HTML text of the widget.
Args: @rpc_call
text (str): The text to set. 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): ... class Minesweeper(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.games.minesweeper"
class MonacoDock(RPCBase): class MonacoDock(RPCBase):
"""MonacoDock is a dock widget that contains Monaco editor instances.""" """MonacoDock is a dock widget that contains Monaco editor instances."""
_IMPORT_MODULE = "bec_widgets.widgets.editors.monaco.monaco_dock"
@rpc_call @rpc_call
def new( def new(
self, self,
@@ -3359,6 +3406,8 @@ class MonacoDock(RPCBase):
class MonacoWidget(RPCBase): class MonacoWidget(RPCBase):
"""A simple Monaco editor widget""" """A simple Monaco editor widget"""
_IMPORT_MODULE = "bec_widgets.widgets.editors.monaco.monaco_widget"
@rpc_call @rpc_call
def set_text( def set_text(
self, text: "str", file_name: "str | None" = None, reset: "bool" = False self, text: "str", file_name: "str | None" = None, reset: "bool" = False
@@ -3533,6 +3582,8 @@ class MonacoWidget(RPCBase):
class MotorMap(RPCBase): class MotorMap(RPCBase):
"""Motor map widget for plotting motor positions in 2D including a trace of the last points.""" """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 @rpc_call
def remove(self): def remove(self):
""" """
@@ -4003,6 +4054,8 @@ class MotorMap(RPCBase):
class MultiWaveform(RPCBase): class MultiWaveform(RPCBase):
"""MultiWaveform widget for displaying multiple waveforms emitted by a single signal.""" """MultiWaveform widget for displaying multiple waveforms emitted by a single signal."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.multi_waveform.multi_waveform"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -4462,6 +4515,8 @@ class MultiWaveform(RPCBase):
class PdfViewerWidget(RPCBase): class PdfViewerWidget(RPCBase):
"""A widget to display PDF documents with toolbar controls.""" """A widget to display PDF documents with toolbar controls."""
_IMPORT_MODULE = "bec_widgets.widgets.utility.pdf_viewer.pdf_viewer"
@rpc_call @rpc_call
def load_pdf(self, file_path: str): def load_pdf(self, file_path: str):
""" """
@@ -4593,6 +4648,10 @@ class PdfViewerWidget(RPCBase):
class PositionIndicator(RPCBase): class PositionIndicator(RPCBase):
"""Display a position within a defined range, e.g. motor limits.""" """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 @rpc_call
def set_value(self, position: float): def set_value(self, position: float):
""" """
@@ -4658,6 +4717,10 @@ class PositionIndicator(RPCBase):
class PositionerBox(RPCBase): class PositionerBox(RPCBase):
"""Simple Widget to control a positioner in box form""" """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 @rpc_call
def set_positioner(self, positioner: "str | Positioner"): def set_positioner(self, positioner: "str | Positioner"):
""" """
@@ -4690,6 +4753,8 @@ class PositionerBox(RPCBase):
class PositionerBox2D(RPCBase): class PositionerBox2D(RPCBase):
"""Simple Widget to control two positioners in box form""" """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 @rpc_call
def set_positioner_hor(self, positioner: "str | Positioner"): def set_positioner_hor(self, positioner: "str | Positioner"):
""" """
@@ -4759,6 +4824,8 @@ class PositionerBox2D(RPCBase):
class PositionerControlLine(RPCBase): class PositionerControlLine(RPCBase):
"""A widget that controls a single device.""" """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 @rpc_call
def set_positioner(self, positioner: "str | Positioner"): def set_positioner(self, positioner: "str | Positioner"):
""" """
@@ -4791,6 +4858,8 @@ class PositionerControlLine(RPCBase):
class PositionerGroup(RPCBase): class PositionerGroup(RPCBase):
"""Simple Widget to control a positioner in box form""" """Simple Widget to control a positioner in box form"""
_IMPORT_MODULE = "bec_widgets.widgets.control.device_control.positioner_group.positioner_group"
@rpc_call @rpc_call
def set_positioners(self, device_names: "str"): def set_positioners(self, device_names: "str"):
""" """
@@ -4822,6 +4891,8 @@ class PositionerGroup(RPCBase):
class RectangularROI(RPCBase): class RectangularROI(RPCBase):
"""Defines a rectangular Region of Interest (ROI) with additional functionality.""" """Defines a rectangular Region of Interest (ROI) with additional functionality."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
@property @property
@rpc_call @rpc_call
def label(self) -> "str": def label(self) -> "str":
@@ -4951,6 +5022,8 @@ class RectangularROI(RPCBase):
class ResumeButton(RPCBase): class ResumeButton(RPCBase):
"""A button that continue scan queue.""" """A button that continue scan queue."""
_IMPORT_MODULE = "bec_widgets.widgets.control.buttons.button_resume.button_resume"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -4971,6 +5044,8 @@ class ResumeButton(RPCBase):
class Ring(RPCBase): class Ring(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.progress.ring_progress_bar.ring"
@rpc_call @rpc_call
def set_value(self, value: "int | float"): def set_value(self, value: "int | float"):
""" """
@@ -5064,6 +5139,8 @@ class Ring(RPCBase):
class RingProgressBar(RPCBase): class RingProgressBar(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -5143,12 +5220,14 @@ class RingProgressBar(RPCBase):
class SBBMonitor(RPCBase): class SBBMonitor(RPCBase):
"""A widget to display the SBB monitor website.""" """A widget to display the SBB monitor website."""
... _IMPORT_MODULE = "bec_widgets.widgets.editors.sbb_monitor.sbb_monitor"
class ScanControl(RPCBase): class ScanControl(RPCBase):
"""Widget to submit new scans to the queue.""" """Widget to submit new scans to the queue."""
_IMPORT_MODULE = "bec_widgets.widgets.control.scan_control.scan_control"
@rpc_call @rpc_call
def attach(self): def attach(self):
""" """
@@ -5172,6 +5251,8 @@ class ScanControl(RPCBase):
class ScanProgressBar(RPCBase): class ScanProgressBar(RPCBase):
"""Widget to display a progress bar that is hooked up to the scan progress of a scan.""" """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 @rpc_call
def remove(self): def remove(self):
""" """
@@ -5194,6 +5275,8 @@ class ScanProgressBar(RPCBase):
class ScatterCurve(RPCBase): class ScatterCurve(RPCBase):
"""Scatter curve item for the scatter waveform widget.""" """Scatter curve item for the scatter waveform widget."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.scatter_waveform.scatter_curve"
@property @property
@rpc_call @rpc_call
def color_map(self) -> "str": def color_map(self) -> "str":
@@ -5203,6 +5286,8 @@ class ScatterCurve(RPCBase):
class ScatterWaveform(RPCBase): class ScatterWaveform(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.plots.scatter_waveform.scatter_waveform"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -5670,6 +5755,8 @@ class ScatterWaveform(RPCBase):
class SignalLabel(RPCBase): class SignalLabel(RPCBase):
_IMPORT_MODULE = "bec_widgets.widgets.utility.signal_label.signal_label"
@property @property
@rpc_call @rpc_call
def custom_label(self) -> "str": def custom_label(self) -> "str":
@@ -5814,6 +5901,8 @@ class SignalLabel(RPCBase):
class TextBox(RPCBase): class TextBox(RPCBase):
"""A widget that displays text in plain and HTML format""" """A widget that displays text in plain and HTML format"""
_IMPORT_MODULE = "bec_widgets.widgets.editors.text_box.text_box"
@rpc_call @rpc_call
def set_plain_text(self, text: str) -> None: def set_plain_text(self, text: str) -> None:
""" """
@@ -5836,6 +5925,8 @@ class TextBox(RPCBase):
class ViewBase(RPCBase): class ViewBase(RPCBase):
"""Wrapper for a content widget used inside the main app's stacked view.""" """Wrapper for a content widget used inside the main app's stacked view."""
_IMPORT_MODULE = "bec_widgets.applications.views.view"
@rpc_call @rpc_call
def activate(self) -> "None": def activate(self) -> "None":
""" """
@@ -5846,6 +5937,8 @@ class ViewBase(RPCBase):
class Waveform(RPCBase): class Waveform(RPCBase):
"""Widget for plotting waveforms.""" """Widget for plotting waveforms."""
_IMPORT_MODULE = "bec_widgets.widgets.plots.waveform.waveform"
@rpc_call @rpc_call
def remove(self): def remove(self):
""" """
@@ -6424,6 +6517,8 @@ class Waveform(RPCBase):
class WaveformViewInline(RPCBase): class WaveformViewInline(RPCBase):
_IMPORT_MODULE = "bec_widgets.applications.views.view"
@rpc_call @rpc_call
def activate(self) -> "None": def activate(self) -> "None":
""" """
@@ -6432,6 +6527,8 @@ class WaveformViewInline(RPCBase):
class WaveformViewPopup(RPCBase): class WaveformViewPopup(RPCBase):
_IMPORT_MODULE = "bec_widgets.applications.views.view"
@rpc_call @rpc_call
def activate(self) -> "None": def activate(self) -> "None":
""" """
@@ -6442,6 +6539,8 @@ class WaveformViewPopup(RPCBase):
class WebsiteWidget(RPCBase): class WebsiteWidget(RPCBase):
"""A simple widget to display a website""" """A simple widget to display a website"""
_IMPORT_MODULE = "bec_widgets.widgets.editors.website.website"
@rpc_call @rpc_call
def set_url(self, url: str) -> None: def set_url(self, url: str) -> None:
""" """
+11 -40
View File
@@ -7,6 +7,7 @@ import inspect
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
from typing import get_overloads
import black import black
import isort import isort
@@ -18,20 +19,6 @@ from bec_widgets.utils.plugin_utils import BECClassContainer, get_custom_classes
logger = bec_logger.logger 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: class ClientGenerator:
def __init__(self, base=False): def __init__(self, base=False):
@@ -54,7 +41,7 @@ from __future__ import annotations
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout 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 logger = bec_logger.logger
@@ -111,27 +98,19 @@ _Widgets = {
self.content += """ self.content += """
try: try:
_plugin_widgets = get_all_plugin_widgets().as_dict()
plugin_client = get_plugin_client_module() 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): for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase: 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(): 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( 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 continue
if plugin_name not in _overlap: else:
globals()[plugin_name] = plugin_class globals()[plugin_name] = plugin_class
Widgets = _WidgetsEnumType("Widgets", _Widgets)
except ImportError as e: except ImportError as e:
logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(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__ class_name = cls.__name__
if class_name == "BECDockArea": self.content += f"""
self.content += f""" class {class_name}(RPCBase):\n"""
class {class_name}(RPCBase):"""
else:
self.content += f"""
class {class_name}(RPCBase):"""
if cls.__doc__: if cls.__doc__:
# We only want the first line of the docstring # We only want the first line of the docstring
@@ -162,13 +137,9 @@ class {class_name}(RPCBase):"""
else: else:
class_docs = cls.__doc__.split("\n")[1] class_docs = cls.__doc__.split("\n")[1]
self.content += f""" self.content += f"""
\"\"\"{class_docs}\"\"\" \"\"\"{class_docs}\"\"\"\n"""
"""
user_access_entries = self._get_user_access_entries(cls) user_access_entries = self._get_user_access_entries(cls)
if not user_access_entries: self.content += f' _IMPORT_MODULE="{cls.__module__}"\n'
self.content += """...
"""
for method_entry in user_access_entries: for method_entry in user_access_entries:
method, obj, is_property_setter = self._resolve_method_object(cls, method_entry) method, obj, is_property_setter = self._resolve_method_object(cls, method_entry)
if obj is None: if obj is None:
@@ -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.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_queue.bec_queue import BECQueue
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox 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 from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
logger = bec_logger.logger logger = bec_logger.logger
@@ -376,6 +376,7 @@ class BECDockArea(DockAreaWidget):
"bec_shell": (BECShell.ICON_NAME, "Add BEC Shell", "BECShell"), "bec_shell": (BECShell.ICON_NAME, "Add BEC Shell", "BECShell"),
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"), "log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"), "sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel", "LogPanel"),
} }
# Create expandable menu actions (original behavior) # Create expandable menu actions (original behavior)
@@ -487,9 +488,7 @@ class BECDockArea(DockAreaWidget):
# first two items not needed for this part # first two items not needed for this part
for key, (_, _, widget_type) in mapping.items(): for key, (_, _, widget_type) in mapping.items():
act = menu.actions[key].action act = menu.actions[key].action
if widget_type == "LogPanel": if key == "terminal":
act.setEnabled(False) # keep disabled per issue #644
elif key == "terminal":
act.triggered.connect( act.triggered.connect(
lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None) 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(): for action_id, (_, _, widget_type) in mapping.items():
flat_action_id = f"flat_{action_id}" flat_action_id = f"flat_{action_id}"
flat_action = self.toolbar.components.get_action(flat_action_id).action flat_action = self.toolbar.components.get_action(flat_action_id).action
if widget_type == "LogPanel": flat_action.triggered.connect(lambda _, t=widget_type: self.new(t))
flat_action.setEnabled(False) # keep disabled per issue #644
else:
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_plots"])
_connect_flat_actions(self._ACTION_MAPPINGS["menu_devices"]) _connect_flat_actions(self._ACTION_MAPPINGS["menu_devices"])
@@ -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 return DOM_XML
def group(self): def group(self):
return "BEC Services" return ""
def icon(self): def icon(self):
return designer_material_icon(LogPanel.ICON_NAME) return designer_material_icon(LogPanel.ICON_NAME)
@@ -51,7 +51,7 @@ class LogPanelPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return "LogPanel" return "LogPanel"
def toolTip(self): def toolTip(self):
return "Displays a log panel" return "LogPanel"
def whatsThis(self): def whatsThis(self):
return self.toolTip() return self.toolTip()
+432 -329
View File
@@ -2,21 +2,31 @@
from __future__ import annotations from __future__ import annotations
import operator
import os import os
import re
from collections import deque from collections import deque
from functools import partial, reduce from dataclasses import dataclass
from re import Pattern from functools import partial
from typing import TYPE_CHECKING, Literal from typing import Iterable, Literal
from bec_lib.client import BECClient from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import LogLevel, bec_logger from bec_lib.logger import LogLevel, bec_logger
from bec_lib.messages import LogMessage, StatusMessage from bec_lib.messages import LogMessage, StatusMessage
from pyqtgraph import SignalProxy from bec_qthemes import material_icon
from qtpy.QtCore import QDateTime, QObject, Qt, Signal from qtpy.QtCore import Signal # type: ignore
from qtpy.QtGui import QFont from qtpy.QtCore import (
QAbstractTableModel,
QCoreApplication,
QDateTime,
QModelIndex,
QObject,
QPersistentModelIndex,
QSize,
QSortFilterProxyModel,
Qt,
QTimer,
)
from qtpy.QtGui import QColor
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QApplication, QApplication,
QCheckBox, QCheckBox,
@@ -25,204 +35,414 @@ from qtpy.QtWidgets import (
QDialog, QDialog,
QGridLayout, QGridLayout,
QHBoxLayout, QHBoxLayout,
QHeaderView,
QLabel, QLabel,
QLineEdit, QLineEdit,
QPushButton, QPushButton,
QScrollArea, QSizePolicy,
QTextEdit, QTableView,
QToolButton,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from thefuzz import fuzz
from bec_widgets.utils.bec_connector import BECConnector 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.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 logger = bec_logger.logger
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
# TODO: improve log color handling _DEFAULT_LOG_COLORS = {
DEFAULT_LOG_COLORS = { LogLevel.INFO.name: QColor("#FFFFFF"),
LogLevel.INFO: "#FFFFFF", LogLevel.SUCCESS.name: QColor("#00FF00"),
LogLevel.SUCCESS: "#00FF00", LogLevel.WARNING.name: QColor("#FFCC00"),
LogLevel.WARNING: "#FFCC00", LogLevel.ERROR.name: QColor("#FF0000"),
LogLevel.ERROR: "#FF0000", LogLevel.DEBUG.name: QColor("#0000CC"),
LogLevel.DEBUG: "#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): class BecLogsQueue(BECConnector, QObject):
"""Manages getting logs from BEC Redis and formatting them for display""" """Manages getting logs from BEC Redis and formatting them for display"""
RPC = False RPC = False
new_message = Signal() new_messages = Signal()
paused = Signal(bool)
_instance: BecLogsQueue | None = None
def __init__( @classmethod
self, def instance(cls):
parent: QObject | None, if cls._instance is None:
maxlen: int = 1000, cls._instance = cls(QCoreApplication.instance())
line_formatter: LineFormatter = noop_format, return cls._instance
**kwargs,
) -> None: 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) super().__init__(parent=parent, **kwargs)
self._timestamp_start: QDateTime | None = None
self._timestamp_end: QDateTime | None = None
self._max_length = maxlen self._max_length = maxlen
self._data: deque[LogMessage] = deque([], self._max_length) self._paused = False
self._display_queue: deque[str] = deque([], self._max_length) self._data = deque(
self._log_level: str | None = None (
self._search_query: Pattern | str | None = None item["data"]
self._selected_services: set[str] | None = None for item in self.bec_dispatcher.client.connector.xread(
self._set_formatter_and_update_filter(line_formatter) MessageEndpoints.log(), count=self._max_length, id="0"
# instance attribute still accessible after c++ object is deleted, so the callback can be unregistered )
),
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.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, *_): def cleanup(self, *_):
"""Stop listening to the Redis log stream""" """Stop listening to the Redis log stream"""
self.bec_dispatcher.disconnect_slot( self.bec_dispatcher.disconnect_slot(
self._process_incoming_log_msg, [MessageEndpoints.log()] self._process_incoming_log_msg, [MessageEndpoints.log()]
) )
self._update_timer.stop()
BecLogsQueue._instance = None
@SafeSlot(verify_sender=True) @SafeSlot(verify_sender=True)
def _process_incoming_log_msg(self, msg: dict, _metadata: dict): def _process_incoming_log_msg(self, msg: dict, _metadata: dict):
try: try:
_msg = LogMessage(**msg) _msg = LogMessage(**msg)
self._data.append(_msg) self._incoming.append(_msg)
if self.filter is None or self.filter(_msg):
self._display_queue.append(self._line_formatter(_msg))
self.new_message.emit()
except Exception as e: except Exception as e:
if "Internal C++ object (BecLogsQueue) already deleted." in e.args: if "Internal C++ object (BecLogsQueue) already deleted." in e.args:
return return
logger.warning(f"Error in LogPanel incoming message callback: {e}") logger.warning(f"Error in LogPanel incoming message callback: {e}")
def _set_formatter_and_update_filter(self, line_formatter: LineFormatter = noop_format): @SafeSlot(verify_sender=True)
self._line_formatter: LineFormatter = line_formatter def _proc_update(self):
self._queue_formatter: LinesHtmlFormatter = create_formatter( if self._paused or len(self._incoming) == 0:
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!")
return return
self._log_level = level self._data.extend(self._incoming)
self._set_formatter_and_update_filter(self._line_formatter) 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): class BecLogsTableModel(QAbstractTableModel):
"""Change the start and/or end times to filter against""" def __init__(self, parent: QWidget | None = None):
self._timestamp_start = start super().__init__(parent)
self._timestamp_end = end self.log_queue = BecLogsQueue.instance()
self._set_formatter_and_update_filter(self._line_formatter) self.log_queue.new_messages.connect(self.handle_new_messages)
self._headers = _CONST.headers
def update_service_filter(self, services: set[str]): def rowCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int:
"""Change the selected services to display""" return len(self.log_queue)
self._selected_services = services
self._set_formatter_and_update_filter(self._line_formatter)
def update_line_formatter(self, line_formatter: LineFormatter): def columnCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int:
"""Update the formatter""" return len(self._headers)
self._set_formatter_and_update_filter(line_formatter)
def display_all(self) -> str: def headerData(self, section, orientation, role=int(Qt.ItemDataRole.DisplayRole)):
"""Return formatted output for all log messages""" if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
return "\n".join(self._queue_formatter(self._data.copy())) return self._headers[section]
return None
def format_new(self): def get_row_data(self, index: QModelIndex) -> LogMessage | None:
"""Return formatted output for the display queue""" """Return the row data for the given index."""
res = "\n".join(self._display_queue) if not index.isValid():
self._display_queue = deque([], self._max_length) return None
return res return self.log_queue.row_data(index.row())
def clear_logs(self): def timestamp(self, row: int):
"""Clear the cache and display queue""" return QDateTime.fromMSecsSinceEpoch(int(self.log_queue.log_timestamp(row) * 1000))
self._data = deque([])
self._display_queue = deque([])
def fetch_history(self): def data(self, index, role=int(Qt.ItemDataRole.DisplayRole)):
"""Fetch all available messages from Redis""" """Return data for the given index and role."""
self._data = deque( if not index.isValid():
item["data"] return
for item in self.bec_dispatcher.client.connector.xread( if role in [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ToolTipRole]:
MessageEndpoints.log().endpoint, from_start=True, count=self._max_length 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""" class LogMsgProxyModel(QSortFilterProxyModel):
return set(msg.log_msg["service_name"] for msg in self._data) 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): 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, client: BECClient | None = None) -> None:
def __init__(self, parent: QWidget | None = None) -> None:
"""A toolbar for the logpanel, mainly used for managing the states of filters""" """A toolbar for the logpanel, mainly used for managing the states of filters"""
super().__init__(parent) super().__init__(parent)
@@ -231,51 +451,69 @@ class LogPanelToolbar(QWidget):
self._timestamp_end: QDateTime | None = None self._timestamp_end: QDateTime | None = None
self._unique_service_names: set[str] = set() 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) if client is not None:
self.layout.addWidget(self.service_choice_button) self.client = client
self.service_choice_button.clicked.connect(self._open_service_filter_dialog) 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.filter_level_dropdown = self._log_level_box()
self.layout.addWidget(self.filter_level_dropdown) self._layout.addWidget(self.filter_level_dropdown)
self.filter_level_dropdown.currentTextChanged.connect(self._emit_level)
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._string_search_box() self._string_search_box()
self.timerange_button = QPushButton("Set time range", self) 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 self.pause_button = QToolButton()
def time_start(self): self.pause_button.setIcon(material_icon("pause", size=(20, 20), convert_to_pixmap=False))
return self._timestamp_start 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 @SafeSlot(bool)
def time_end(self): def _update_pause_button_icon(self, paused):
return self._timestamp_end 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): def _string_search_box(self):
self.layout.addWidget(QLabel("Search: ")) self._layout.addWidget(QLabel("Search: "))
self.search_textbox = QLineEdit() self.search_textbox = QLineEdit()
self.layout.addWidget(self.search_textbox) self._layout.addWidget(self.search_textbox)
self.layout.addWidget(QLabel("Use regex: ")) self._layout.addWidget(QLabel("Fuzzy: "))
self.regex_enabled = QCheckBox() self.fuzzy = QCheckBox()
self.layout.addWidget(self.regex_enabled) self._layout.addWidget(self.fuzzy)
self.update_re_button = QPushButton("Update search", self) self.fuzzy.checkStateChanged.connect(self._emit_fuzzy)
self.layout.addWidget(self.update_re_button)
def _log_level_box(self): def _log_level_box(self):
box = QComboBox() box = QComboBox()
box.setToolTip("Display logs with equal or greater significance to the selected level.") 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 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"]): def _current_ts(self, selection_type: Literal["start", "end"]):
if selection_type == "start": if selection_type == "start":
return self._timestamp_start return self._timestamp_start
@@ -284,6 +522,7 @@ class LogPanelToolbar(QWidget):
else: else:
raise ValueError(f"timestamps can only be for the start or end, not {selection_type}") raise ValueError(f"timestamps can only be for the start or end, not {selection_type}")
@SafeSlot()
def _open_datetime_dialog(self): def _open_datetime_dialog(self):
"""Open dialog window for timestamp filter selection""" """Open dialog window for timestamp filter selection"""
self._dt_dialog = QDialog(self) self._dt_dialog = QDialog(self)
@@ -312,8 +551,8 @@ class LogPanelToolbar(QWidget):
) )
_layout.addWidget(date_clear_button) _layout.addWidget(date_clear_button)
for v in [("start", label_start), ("end", label_end)]: date_button_set("start", label_start)
date_button_set(*v) date_button_set("end", label_end)
close_button = QPushButton("Close", parent=self._dt_dialog) close_button = QPushButton("Close", parent=self._dt_dialog)
close_button.clicked.connect(self._dt_dialog.accept) close_button.clicked.connect(self._dt_dialog.accept)
@@ -352,27 +591,23 @@ class LogPanelToolbar(QWidget):
self._timestamp_start = dt self._timestamp_start = dt
else: else:
self._timestamp_end = dt 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]):
def service_list_update(
self, services_info: dict[str, StatusMessage], services_from_history: set[str], *_, **__
):
"""Change the list of services which can be selected""" """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 = 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() @SafeSlot()
def _open_service_filter_dialog(self): 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: if len(self._unique_service_names) == 0 or self._services_selected is None:
return return
self._svc_dialog = QDialog(self) 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() layout = QVBoxLayout()
self._svc_dialog.setLayout(layout) self._svc_dialog.setLayout(layout)
service_cb_grid = QGridLayout(parent=self._svc_dialog) service_cb_grid = QGridLayout()
layout.addLayout(service_cb_grid) layout.addLayout(service_cb_grid)
def check_box(name: str, checked: Qt.CheckState): def check_box(name: str, checked: Qt.CheckState):
@@ -398,146 +633,6 @@ class LogPanelToolbar(QWidget):
self._svc_dialog.deleteLater() 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 if __name__ == "__main__": # pragma: no cover
import sys import sys
@@ -545,7 +640,15 @@ if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv) app = QApplication(sys.argv)
apply_theme("dark") 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()) sys.exit(app.exec())
+2 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "bec_widgets" name = "bec_widgets"
version = "3.5.0" version = "3.6.0"
description = "BEC Widgets" description = "BEC Widgets"
requires-python = ">=3.11" requires-python = ">=3.11"
classifiers = [ classifiers = [
@@ -44,7 +44,7 @@ bw-generate-cli = "bec_widgets.cli.generate_cli:main"
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"coverage~=7.0", "coverage~=7.0",
"fakeredis~=2.23, >=2.23.2", "fakeredis==2.34.1",
"pytest-bec-e2e>=2.21.4, <=4.0", "pytest-bec-e2e>=2.21.4, <=4.0",
"pytest-qt~=4.4", "pytest-qt~=4.4",
"pytest-random-order~=1.1", "pytest-random-order~=1.1",
+18 -5
View File
@@ -1,3 +1,5 @@
import traceback
import pytest import pytest
import qtpy.QtCore import qtpy.QtCore
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
@@ -5,12 +7,14 @@ from qtpy.QtCore import QTimer
class TestableQTimer(QTimer): class TestableQTimer(QTimer):
_instances: list[tuple[QTimer, str]] = [] _instances: list[tuple[QTimer, str, str]] = []
_current_test_name: str = "" _current_test_name: str = ""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*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 @classmethod
def check_all_stopped(cls, qtbot): def check_all_stopped(cls, qtbot):
@@ -20,12 +24,21 @@ class TestableQTimer(QTimer):
except RuntimeError as e: except RuntimeError as e:
return "already deleted" in e.args[0] 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: 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: except QtBotTimeoutError as exc:
active_timers = list(filter(lambda t: t[0].isActive(), cls._instances)) active_timers = list(filter(lambda t: t[0].isActive(), cls._instances))
(t.stop() for t, _ in cls._instances) (t.stop() for t, _, _ in cls._instances)
raise TimeoutError(f"Failed to stop all timers: {active_timers}") from exc raise TimeoutError(
f"Failed to stop all timers:\n{_format_timers(active_timers)}"
) from exc
cls._instances = [] cls._instances = []
+4 -3
View File
@@ -33,7 +33,7 @@ def threads_check_fixture(threads_check):
@pytest.fixture @pytest.fixture
def gui_id(): def gui_id():
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturb""" """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") @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) qtbot.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000)
yield gui yield gui
finally: finally:
gui.bec.delete_all() # ensure clean state if (bec := getattr(gui, "bec", None)) is not None:
qtbot.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000) bec.delete_all() # ensure clean state
qtbot.waitUntil(lambda: len(bec.widget_list()) == 0, timeout=10000)
gui.kill_server() gui.kill_server()
@@ -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) 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) @pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_minesweeper(qtbot, connected_client_gui_obj, random_generator_from_seed): def test_widgets_e2e_minesweeper(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the MineSweeper widget.""" """Test the MineSweeper widget."""
@@ -9,7 +9,8 @@ from bec_widgets.cli.rpc.rpc_base import RPCBase
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo 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() 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): def test_plugins_dont_clobber_client_globals(bec_logger: MagicMock):
reload(client) reload(client)
bec_logger.logger.warning.assert_called_with( 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) assert isinstance(client.Widgets, enum.EnumType)
class _TestDuplicatePlugin(RPCBase): ... class _TestDuplicatePlugin(RPCBase):
_IMPORT_MODULE = "test.duplicate.plugin.module"
mock_client_module_duplicate = SimpleNamespace() mock_client_module_duplicate = SimpleNamespace()
@@ -54,7 +56,7 @@ def test_duplicate_plugins_not_allowed(_, bec_logger: MagicMock):
reload(client) reload(client)
assert ( assert (
call( 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 in bec_logger.logger.warning.mock_calls
) )
-6
View File
@@ -2229,7 +2229,6 @@ class TestFlatToolbarActions:
"flat_progress_bar", "flat_progress_bar",
"flat_terminal", "flat_terminal",
"flat_bec_shell", "flat_bec_shell",
"flat_log_panel",
"flat_sbb_monitor", "flat_sbb_monitor",
] ]
@@ -2289,11 +2288,6 @@ class TestFlatToolbarActions:
action.trigger() action.trigger()
mock_new.assert_called_once_with(widget_type) 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: class TestModeTransitions:
"""Test mode transitions and state consistency.""" """Test mode transitions and state consistency."""
+10 -15
View File
@@ -104,8 +104,7 @@ def test_client_generator_with_black_formatting():
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout 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, from bec_widgets.utils.bec_plugin_helper import get_plugin_client_module
get_plugin_client_module)
logger = bec_logger.logger logger = bec_logger.logger
@@ -123,31 +122,25 @@ def test_client_generator_with_black_formatting():
try: try:
_plugin_widgets = get_all_plugin_widgets().as_dict()
plugin_client = get_plugin_client_module() 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): for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase: 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(): 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( 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 continue
if plugin_name not in _overlap: else:
globals()[plugin_name] = plugin_class globals()[plugin_name] = plugin_class
Widgets = _WidgetsEnumType("Widgets", _Widgets)
except ImportError as e: except ImportError as e:
logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}") logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}")
class MockBECFigure(RPCBase): class MockBECFigure(RPCBase):
_IMPORT_MODULE = "tests.unit_tests.test_generate_cli_client"
@rpc_call @rpc_call
def add_plot(self, plot_id: str): def add_plot(self, plot_id: str):
""" """
@@ -162,6 +155,8 @@ def test_client_generator_with_black_formatting():
class MockBECWaveform1D(RPCBase): class MockBECWaveform1D(RPCBase):
_IMPORT_MODULE = "tests.unit_tests.test_generate_cli_client"
@rpc_call @rpc_call
def set_frequency(self, frequency: float) -> list: def set_frequency(self, frequency: float) -> list:
""" """
+106 -146
View File
@@ -7,163 +7,123 @@ from collections import deque
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
from bec_lib.logger import LogLevel
from bec_lib.messages import LogMessage from bec_lib.messages import LogMessage
from bec_lib.redis_connector import StreamMessage
from qtpy.QtCore import QDateTime from qtpy.QtCore import QDateTime
from bec_widgets.widgets.utility.logpanel._util import ( from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel, TimestampUpdate
log_time,
replace_escapes,
simple_color_format,
)
from bec_widgets.widgets.utility.logpanel.logpanel import DEFAULT_LOG_COLORS, LogPanel
from .client_mocks import mocked_client 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 = [ TEST_LOG_MESSAGES = [
LogMessage( {"data": msg}
metadata={}, for msg in [
log_type="debug", LogMessage(
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(
metadata={}, metadata={},
log_type="debug", log_type="debug",
log_msg={ log_msg={
"text": "datetime | debug | test log message", "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", "service_name": "ScanServer",
}, },
) )
log_panel._log_manager._process_incoming_log_msg( )
msg.content, msg.metadata, _override_slot_params={"verify_sender": False} log_panel._model.log_queue._proc_update()
) qtbot.waitUntil(lambda: log_panel._model.rowCount() == 4, timeout=500)
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()
+47 -13
View File
@@ -3,6 +3,7 @@ from types import SimpleNamespace
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints from bec_lib.endpoints import MessageEndpoints
from bec_lib.messages import AvailableResourceMessage, ScanHistoryMessage from bec_lib.messages import AvailableResourceMessage, ScanHistoryMessage
from qtpy.QtCore import QModelIndex, Qt from qtpy.QtCore import QModelIndex, Qt
@@ -255,11 +256,10 @@ scan_history = ScanHistoryMessage(
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def scan_control(qtbot, mocked_client): # , mock_dev): def scan_control(qtbot, mocked_client: BECClient):
mocked_client.connector._redis_conn.flushall()
mocked_client.connector.set(MessageEndpoints.available_scans(), available_scans_message) mocked_client.connector.set(MessageEndpoints.available_scans(), available_scans_message)
mocked_client.connector.xadd( mocked_client.connector.xadd(MessageEndpoints.scan_history(), msg_dict={"data": scan_history})
topic=MessageEndpoints.scan_history(), msg_dict={"data": scan_history}
)
widget = ScanControl(client=mocked_client) widget = ScanControl(client=mocked_client)
qtbot.addWidget(widget) qtbot.addWidget(widget)
qtbot.waitExposed(widget) qtbot.waitExposed(widget)
@@ -501,16 +501,29 @@ def test_changing_scans_remember_parameters(scan_control, mocked_client):
assert grid_kwargs["burst_at_each_point"] == kwargs["burst_at_each_point"] assert grid_kwargs["burst_at_each_point"] == kwargs["burst_at_each_point"]
def test_get_scan_parameters_from_redis(scan_control, mocked_client): def test_get_scan_parameters_from_redis(qtbot, scan_control: ScanControl, mocked_client):
scan_control.comboBox_scan_selection.setCurrentIndex(-1)
assert "line_scan" in [
scan_control.comboBox_scan_selection.itemText(i)
for i in range(scan_control.comboBox_scan_selection.count())
]
scan_name = "line_scan" scan_name = "line_scan"
scan_control.comboBox_scan_selection.setCurrentText(scan_name) scan_control.comboBox_scan_selection.setCurrentText(scan_name)
qtbot.wait(100)
slot_hit = False
def mock_request(*args):
ScanControl.request_last_executed_scan_parameters(scan_control, *args)
nonlocal slot_hit
slot_hit = True
scan_control.request_last_executed_scan_parameters = mock_request
# Trigger restore of parameters from history
scan_control.toggle.checked = True scan_control.toggle.checked = True
args, kwargs = scan_control.get_scan_parameters(bec_object=False) qtbot.waitUntil(lambda: slot_hit, timeout=1000)
args = ["samx", 0.0, 2.0]
assert args == ["samx", 0.0, 2.0] kwargs = {
assert kwargs == {
"steps": 10, "steps": 10,
"relative": False, "relative": False,
"exp_time": 2.0, "exp_time": 2.0,
@@ -518,6 +531,10 @@ def test_get_scan_parameters_from_redis(scan_control, mocked_client):
"metadata": {"comment": "", "sample_name": "", "scan_name": "line_scan"}, "metadata": {"comment": "", "sample_name": "", "scan_name": "line_scan"},
} }
qtbot.waitUntil(
lambda: scan_control.get_scan_parameters(bec_object=False) == (args, kwargs), timeout=5000
)
TEST_MD = { TEST_MD = {
"comment": "", "comment": "",
@@ -585,7 +602,7 @@ def test_scan_metadata_is_passed_to_scan_function(scan_control: ScanControl):
scans.grid_scan.assert_called_once_with(metadata=TEST_MD) scans.grid_scan.assert_called_once_with(metadata=TEST_MD)
def test_restore_parameters_with_fewer_arg_bundles(scan_control, qtbot): def test_restore_parameters_with_fewer_arg_bundles(scan_control: ScanControl, qtbot):
""" """
Ensure that when more argument bundles are present than exist in the Ensure that when more argument bundles are present than exist in the
stored history, restoring parameters regenerates the arg box to the stored history, restoring parameters regenerates the arg box to the
@@ -593,19 +610,36 @@ def test_restore_parameters_with_fewer_arg_bundles(scan_control, qtbot):
This is a check for the previous infinite loop bug. This is a check for the previous infinite loop bug.
""" """
# Select the scan type that has history with only one arg bundle # Select the scan type that has history with only one arg bundle
scan_control.comboBox_scan_selection.setCurrentText("line_scan") scan_control.comboBox_scan_selection.setCurrentIndex(-1)
assert "line_scan" in [
scan_control.comboBox_scan_selection.itemText(i)
for i in range(scan_control.comboBox_scan_selection.count())
]
scan_control.current_scan = "line_scan"
qtbot.waitUntil(lambda: scan_control.arg_box.count_arg_rows() == 1, timeout=1000)
# Manually add bundles so we end up with three rows # Manually add bundles so we end up with three rows
while scan_control.arg_box.count_arg_rows() < 3: while scan_control.arg_box.count_arg_rows() < 3:
scan_control.arg_box.add_widget_bundle() scan_control.arg_box.add_widget_bundle()
assert scan_control.arg_box.count_arg_rows() == 3 assert scan_control.arg_box.count_arg_rows() == 3
scan_control.client.connector.xadd(
MessageEndpoints.scan_history(), msg_dict={"data": scan_history}
)
slot_hit = False
def mock_request(*args):
ScanControl.request_last_executed_scan_parameters(scan_control, *args)
nonlocal slot_hit
slot_hit = True
scan_control.request_last_executed_scan_parameters = mock_request
# Trigger restore of parameters from history # Trigger restore of parameters from history
scan_control.toggle.checked = True scan_control.toggle.checked = True
qtbot.wait(200)
qtbot.waitUntil(lambda: slot_hit, timeout=1000)
# After restore, arg_box should have only one bundle (the history size) # After restore, arg_box should have only one bundle (the history size)
assert scan_control.arg_box.count_arg_rows() == 1 qtbot.waitUntil(lambda: scan_control.arg_box.count_arg_rows() == 1, timeout=1000)
# Verify that the restored parameter values match the history # Verify that the restored parameter values match the history
args, kwargs = scan_control.get_scan_parameters(bec_object=False) args, kwargs = scan_control.get_scan_parameters(bec_object=False)