Compare commits

..

1 Commits

Author SHA1 Message Date
perl_d 7b4577a00a fix: restore connector after logpanel fixture 2026-05-12 19:31:36 +02:00
10 changed files with 54 additions and 232 deletions
-24
View File
@@ -1,30 +1,6 @@
# CHANGELOG
## v3.10.0 (2026-05-13)
### Documentation
- Fix link to doc page
([`0cd000d`](https://github.com/bec-project/bec_widgets/commit/0cd000dfa1bb6f1b4d286e5aab30299361f436f6))
### Features
- Bl plugin menu in BECDockArea
([`e22ab7e`](https://github.com/bec-project/bec_widgets/commit/e22ab7e4c10552e22aaaa9dbc30d098fbfa9c49c))
## v3.9.1 (2026-05-12)
### Bug Fixes
- Logpanel fixture overwriting xread
([`3c7834b`](https://github.com/bec-project/bec_widgets/commit/3c7834b492a5d2da13689f58b20caf38dda9ac1d))
- **scan_control**: Restore scan parameters from history are fetched on demand with button
([`acd35a2`](https://github.com/bec-project/bec_widgets/commit/acd35a278660ce4962167af6237b5d12007f0774))
## v3.9.0 (2026-05-12)
### Bug Fixes
+2 -1
View File
@@ -192,7 +192,8 @@ Positioner boxes and tweak controls handle precise moves, homing, and calibratio
## Documentation
The documentation can be found [here](https://bec.readthedocs.io/).
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of
the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
## License
-9
View File
@@ -143,15 +143,6 @@ def get_plugin_designer_registry() -> dict[str, tuple[str, str]]:
return {}
@lru_cache
def get_plugin_widget_icons() -> dict[str, str]:
"""If there is a plugin repository installed, return the designer widget icon registry."""
designer_module = get_plugin_designer_module()
if designer_module and hasattr(designer_module, "widget_icons"):
return designer_module.widget_icons
return {}
def get_all_plugin_widgets() -> BECClassContainer:
"""If there is a plugin repository installed, load all widgets from it."""
if plugin := user_widget_plugin():
@@ -1,7 +1,6 @@
from __future__ import annotations
import os
from functools import lru_cache
from typing import Literal, Mapping, Sequence
import slugify
@@ -21,12 +20,7 @@ from qtpy.QtWidgets import (
import bec_widgets.widgets.containers.qt_ads as QtAds
from bec_widgets import BECWidget, SafeProperty, SafeSlot
from bec_widgets.cli.designer_plugins import widget_icons
from bec_widgets.utils.bec_plugin_helper import (
get_plugin_rpc_widget_registry,
get_plugin_widget_icons,
)
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.plugin_utils import get_rpc_widget_registry
from bec_widgets.utils.rpc_decorator import rpc_timeout
from bec_widgets.utils.rpc_widget_handler import widget_handler
from bec_widgets.utils.toolbars.actions import (
@@ -80,19 +74,6 @@ PROFILE_STATE_KEYS = {key: SETTINGS_KEYS[key] for key in ("geom", "state", "ads_
StartupProfile = Literal["restore", "skip"] | str | None
@lru_cache
def _plugin_toolbar_actions() -> dict[str, tuple[str, str, str]]:
plugin_registry = get_plugin_rpc_widget_registry()
internal_registry = get_rpc_widget_registry()
plugin_icons = get_plugin_widget_icons()
return {
widget_name: (plugin_icons.get(widget_name, "widgets"), f"Add {widget_name}", widget_name)
for widget_name in sorted(plugin_registry)
if widget_name not in internal_registry
}
class BECDockArea(DockAreaWidget):
RPC = True
PLUGIN = False
@@ -409,10 +390,6 @@ class BECDockArea(DockAreaWidget):
_build_menu("menu_devices", "Add Device Control ", device_actions)
_build_menu("menu_utils", "Add Utils ", util_actions)
plugin_actions = _plugin_toolbar_actions()
if plugin_actions:
_build_menu("menu_plugins", "Add Plugins ", plugin_actions)
# Create flat toolbar bundles for each widget type
def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]):
bundle = ToolbarBundle(f"flat_{category}", self.toolbar.components)
@@ -483,16 +460,14 @@ class BECDockArea(DockAreaWidget):
bda.add_action("dark_mode")
self.toolbar.add_bundle(bda)
# Store mappings on self for use in _hook_toolbar and _apply_toolbar_layout
self._apply_toolbar_layout()
# Store mappings on self for use in _hook_toolbar
self._ACTION_MAPPINGS = {
"menu_plots": plot_actions,
"menu_devices": device_actions,
"menu_utils": util_actions,
}
if plugin_actions:
self._ACTION_MAPPINGS["menu_plugins"] = plugin_actions
self._apply_toolbar_layout()
def _hook_toolbar(self):
def _connect_menu(menu_key: str):
@@ -501,8 +476,7 @@ class BECDockArea(DockAreaWidget):
# first two items not needed for this part
for key, (_, _, widget_type) in mapping.items():
toolbar_action = menu.actions[key]
act = toolbar_action.action
act = menu.actions[key].action
if key == "terminal":
act.triggered.connect(
lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None)
@@ -513,18 +487,12 @@ class BECDockArea(DockAreaWidget):
widget=t, closable=True, show_settings_action=False
)
)
elif menu_key == "menu_plugins":
act.triggered.connect(
lambda _, t=widget_type, a=toolbar_action: self._new_plugin_widget(t, a)
)
else:
act.triggered.connect(lambda _, t=widget_type: self.new(widget=t))
_connect_menu("menu_plots")
_connect_menu("menu_devices")
_connect_menu("menu_utils")
if "menu_plugins" in self._ACTION_MAPPINGS:
_connect_menu("menu_plugins")
def _connect_flat_actions(mapping: dict[str, tuple[str, str, str]]):
for action_id, (_, _, widget_type) in mapping.items():
@@ -539,10 +507,6 @@ class BECDockArea(DockAreaWidget):
self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all)
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
def _new_plugin_widget(self, widget_type: str, toolbar_action: MaterialIconAction) -> None:
# Created as helper method for simple tests
self.new(widget=widget_type, dock_icon=toolbar_action.get_icon())
def _set_editable(self, editable: bool) -> None:
self.workspace_is_locked = not editable
self._editable = editable
@@ -1144,10 +1108,14 @@ class BECDockArea(DockAreaWidget):
if mode_key == "user":
bundles = ["spacer_bundle", "workspace", "dock_actions"]
elif mode_key == "creator":
bundles = ["menu_plots", "menu_devices", "menu_utils"]
if "menu_plugins" in getattr(self, "_ACTION_MAPPINGS", {}):
bundles.append("menu_plugins")
bundles += ["spacer_bundle", "workspace", "dock_actions"]
bundles = [
"menu_plots",
"menu_devices",
"menu_utils",
"spacer_bundle",
"workspace",
"dock_actions",
]
elif mode_key == "plot":
bundles = ["flat_plots", "spacer_bundle", "workspace", "dock_actions"]
elif mode_key == "device":
@@ -26,6 +26,7 @@ from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
from bec_widgets.widgets.control.scan_control.scan_group_box import ScanGroupBox
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
class ScanParameterConfig(BaseModel):
@@ -83,6 +84,7 @@ class ScanControl(BECWidget, QWidget):
self.kwarg_boxes = []
self.expert_mode = False # TODO implement in the future versions
self.previous_scan = None
self.last_scan_found = None
# Widget Default Parameters
self.config.default_scan = default_scan
@@ -121,12 +123,17 @@ class ScanControl(BECWidget, QWidget):
scan_selection_layout.addWidget(self.comboBox_scan_selection, 1)
self.scan_selection_group.layout().addLayout(scan_selection_layout)
# Button to reload the last scan parameters on demand.
self.last_scan_button = QPushButton(
"Restore last scan parameters", self.scan_selection_group
# Label to reload the last scan parameters within scan selection group box
self.toggle_layout = QHBoxLayout()
self.toggle_layout.addSpacerItem(
QSpacerItem(0, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
)
self.last_scan_button.clicked.connect(self.request_last_executed_scan_parameters)
self.scan_selection_group.layout().addWidget(self.last_scan_button)
self.last_scan_label = QLabel("Restore last scan parameters", self.scan_selection_group)
self.toggle = ToggleSwitch(parent=self.scan_selection_group, checked=False)
self.toggle.enabled.connect(self.request_last_executed_scan_parameters)
self.toggle_layout.addWidget(self.last_scan_label)
self.toggle_layout.addWidget(self.toggle)
self.scan_selection_group.layout().addLayout(self.toggle_layout)
self.scan_selection_group.setSizePolicy(
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed
)
@@ -199,6 +206,7 @@ class ScanControl(BECWidget, QWidget):
"""Callback for scan selection combo box"""
selected_scan_name = self.comboBox_scan_selection.currentText()
self.scan_selected.emit(selected_scan_name)
self.request_last_executed_scan_parameters()
self.restore_scan_parameters(selected_scan_name)
@SafeSlot()
@@ -207,6 +215,10 @@ class ScanControl(BECWidget, QWidget):
"""
Requests the last executed scan parameters from BEC and restores them to the scan control widget.
"""
self.last_scan_found = False
if not self.toggle.checked:
return
current_scan = self.comboBox_scan_selection.currentText()
history = (
self.client.connector.xread(
@@ -234,6 +246,8 @@ class ScanControl(BECWidget, QWidget):
if merged and self.kwarg_boxes:
for box in self.kwarg_boxes:
box.set_parameters(merged)
self.last_scan_found = True
break
@SafeProperty(str)
@@ -482,6 +496,8 @@ class ScanControl(BECWidget, QWidget):
Args:
scan_name(str): Name of the scan to restore the parameters for.
"""
if self.last_scan_found is True:
return
scan_params = self.config.scans.get(scan_name, None)
if scan_params is None and self.previous_scan is None:
return
+1 -3
View File
@@ -1,6 +1,6 @@
[project]
name = "bec_widgets"
version = "3.10.0"
version = "3.9.0"
description = "BEC Widgets"
requires-python = ">=3.11"
classifiers = [
@@ -60,8 +60,6 @@ qtermwidget = ["pyside6_qtermwidget"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
+1 -30
View File
@@ -2,10 +2,7 @@ from importlib.machinery import FileFinder, SourceFileLoader
from types import ModuleType
from unittest import mock
from bec_widgets.utils.bec_plugin_helper import (
_all_widgets_from_all_submods,
get_plugin_widget_icons,
)
from bec_widgets.utils.bec_plugin_helper import _all_widgets_from_all_submods
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
@@ -72,29 +69,3 @@ def test_all_widgets_from_module_no_widgets():
widgets = _all_widgets_from_all_submods(module).as_dict()
assert widgets == {}
def test_get_plugin_widget_icons_from_designer_module():
designer_module = mock.MagicMock(spec=ModuleType)
designer_module.widget_icons = {"PluginWidget": "star"}
get_plugin_widget_icons.cache_clear()
try:
with mock.patch(
"bec_widgets.utils.bec_plugin_helper.get_plugin_designer_module",
return_value=designer_module,
):
assert get_plugin_widget_icons() == {"PluginWidget": "star"}
finally:
get_plugin_widget_icons.cache_clear()
def test_get_plugin_widget_icons_without_designer_module():
get_plugin_widget_icons.cache_clear()
try:
with mock.patch(
"bec_widgets.utils.bec_plugin_helper.get_plugin_designer_module", return_value=None
):
assert get_plugin_widget_icons() == {}
finally:
get_plugin_widget_icons.cache_clear()
-66
View File
@@ -11,7 +11,6 @@ from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import QDialog, QMessageBox, QWidget
import bec_widgets.widgets.containers.dock_area.basic_dock_area as basic_dock_module
import bec_widgets.widgets.containers.dock_area.dock_area as dock_area_module
import bec_widgets.widgets.containers.dock_area.profile_utils as profile_utils
from bec_widgets.widgets.containers.dock_area.basic_dock_area import (
DockAreaWidget,
@@ -69,13 +68,6 @@ def temp_profile_dir():
return os.environ["BECWIDGETS_PROFILE_DIR"]
@pytest.fixture
def clear_plugin_toolbar_actions_cache():
dock_area_module._plugin_toolbar_actions.cache_clear()
yield
dock_area_module._plugin_toolbar_actions.cache_clear()
@pytest.fixture
def module_profile_factory(monkeypatch, tmp_path):
"""Provide a helper to create synthetic module-level (read-only) profiles."""
@@ -993,64 +985,6 @@ class TestToolbarFunctionality:
# Verify save was called with the filename
mock_screenshot.save.assert_called_once_with(str(screenshot_path))
def test_plugin_toolbar_actions_empty_when_no_plugins(self, clear_plugin_toolbar_actions_cache):
"""Test that no plugin toolbar actions are produced when no plugin widgets exist."""
with patch(
"bec_widgets.widgets.containers.dock_area.dock_area.get_plugin_rpc_widget_registry",
return_value={},
):
plugin_actions = dock_area_module._plugin_toolbar_actions()
assert plugin_actions == {}
def test_plugin_toolbar_actions_include_available_plugins(
self, clear_plugin_toolbar_actions_cache
):
"""Test that plugin toolbar actions are built from RPC widgets and generated icons."""
plugin_registry = {
"FakePluginWidget": ("fake_plugin.widgets.fake_plugin_widget", "FakePluginWidget")
}
with (
patch(
"bec_widgets.widgets.containers.dock_area.dock_area.get_plugin_rpc_widget_registry",
return_value=plugin_registry,
),
patch(
"bec_widgets.widgets.containers.dock_area.dock_area.get_plugin_widget_icons",
return_value={"FakePluginWidget": "star"},
),
):
plugin_actions = dock_area_module._plugin_toolbar_actions()
assert plugin_actions == {
"FakePluginWidget": ("star", "Add FakePluginWidget", "FakePluginWidget")
}
def test_plugin_toolbar_actions_ignore_builtin_name_collisions(
self, clear_plugin_toolbar_actions_cache
):
"""Test that plugin widgets shadowed by built-ins are not added to the plugin menu."""
plugin_registry = {"Waveform": ("fake_plugin.widgets.waveform", "Waveform")}
with patch(
"bec_widgets.widgets.containers.dock_area.dock_area.get_plugin_rpc_widget_registry",
return_value=plugin_registry,
):
plugin_actions = dock_area_module._plugin_toolbar_actions()
assert plugin_actions == {}
def test_new_plugin_widget_passes_toolbar_icon_to_new(self):
"""Test that plugin widget creation passes the toolbar icon to dock creation."""
dock_area = MagicMock()
toolbar_action = MagicMock()
dock_icon = object()
toolbar_action.get_icon.return_value = dock_icon
BECDockArea._new_plugin_widget(dock_area, "FakePluginWidget", toolbar_action)
toolbar_action.get_icon.assert_called_once_with()
dock_area.new.assert_called_once_with(widget="FakePluginWidget", dock_icon=dock_icon)
class TestDockSettingsDialog:
"""Test dock settings dialog functionality."""
+10 -10
View File
@@ -62,16 +62,16 @@ TEST_LOG_MESSAGES = [
@pytest.fixture
def log_panel(qtbot, mocked_client, monkeypatch):
monkeypatch.setattr(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 log_panel(qtbot, mocked_client):
with patch.object(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):
+7 -40
View File
@@ -503,47 +503,12 @@ def test_changing_scans_remember_parameters(scan_control, mocked_client):
assert grid_kwargs["burst_at_each_point"] == kwargs["burst_at_each_point"]
def test_scan_selection_does_not_fetch_last_scan_parameters(
scan_control, mocked_client, monkeypatch
):
xread = MagicMock(wraps=mocked_client.connector.xread)
monkeypatch.setattr(mocked_client.connector, "xread", xread)
scan_control.comboBox_scan_selection.setCurrentText("line_scan")
assert scan_control.comboBox_scan_selection.currentText() == "line_scan"
scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
xread.assert_not_called()
def test_restore_last_scan_parameters_button_fetches_on_demand(
scan_control, mocked_client, monkeypatch
):
xread = MagicMock(wraps=mocked_client.connector.xread)
monkeypatch.setattr(mocked_client.connector, "xread", xread)
scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
scan_control.comboBox_scan_selection.setCurrentText("line_scan")
xread.assert_not_called()
scan_control.last_scan_button.click()
xread.assert_called_once_with(
MessageEndpoints.scan_history(), from_start=True, user_id=scan_control.object_name
)
args, kwargs = scan_control.get_scan_parameters(bec_object=False)
assert args == ["samx", 0.0, 2.0]
assert kwargs["steps"] == 10
assert kwargs["relative"] is False
assert kwargs["exp_time"] == 2
def test_get_scan_parameters_from_redis(scan_control):
@pytest.mark.skip(reason="Unreliable - GH issue #1134")
def test_get_scan_parameters_from_redis(scan_control, mocked_client):
scan_name = "line_scan"
scan_control.comboBox_scan_selection.setCurrentText(scan_name)
scan_control.last_scan_button.click()
scan_control.toggle.checked = True
args, kwargs = scan_control.get_scan_parameters(bec_object=False)
@@ -623,7 +588,8 @@ def test_scan_metadata_is_passed_to_scan_function(scan_control: ScanControl):
scans.grid_scan.assert_called_once_with(metadata=TEST_MD)
def test_restore_parameters_with_fewer_arg_bundles(scan_control):
@pytest.mark.skip(reason="Unreliable - GH issue #1134")
def test_restore_parameters_with_fewer_arg_bundles(scan_control, qtbot):
"""
Ensure that when more argument bundles are present than exist in the
stored history, restoring parameters regenerates the arg box to the
@@ -639,7 +605,8 @@ def test_restore_parameters_with_fewer_arg_bundles(scan_control):
assert scan_control.arg_box.count_arg_rows() == 3
# Trigger restore of parameters from history
scan_control.last_scan_button.click()
scan_control.toggle.checked = True
qtbot.wait(200)
# After restore, arg_box should have only one bundle (the history size)
assert scan_control.arg_box.count_arg_rows() == 1