1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-18 14:25:37 +02:00

Compare commits

...

6 Commits

Author SHA1 Message Date
semantic-release
6b65a94c81 2.8.3
Automatically generated by python-semantic-release
2025-05-30 09:03:15 +00:00
bf172b8431 fix: guard plugin repo import in e2e test 2025-05-30 11:02:14 +02:00
05329ab50f test(e2e): add tests involving plugin repo 2025-05-28 20:39:51 +02:00
b225a7cc90 refactor: store modules with widget search 2025-05-28 13:05:28 +02:00
semantic-release
3d8af05688 2.8.2
Automatically generated by python-semantic-release
2025-05-27 14:44:05 +00:00
0bdd4e86a2 fix(image_roi): rois are invertible by default, fixes resizing bug when adding from ROI manager 2025-05-27 16:43:22 +02:00
19 changed files with 234 additions and 77 deletions

View File

@@ -12,6 +12,7 @@ jobs:
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PLUGIN_REPO_BRANCH: main # Set the branch you want for the plugin repo
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
@@ -39,10 +40,11 @@ jobs:
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
git clone --branch $OPHYD_DEVICES_BRANCH https://github.com/bec-project/ophyd_devices.git
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
echo -e "\033[35;1m Using branch $PLUGIN_REPO_BRANCH of bec_testing_plugin \033[0;m";
git clone --branch $PLUGIN_REPO_BRANCH https://github.com/bec-project/bec_testing_plugin.git
cd ./bec
conda create -q -n test-environment python=3.11
source ./bin/install_bec_dev.sh -t
cd ../
pip install -e ./ophyd_devices
pip install -e .[dev,pyside6]
pip install -e ./ophyd_devices -e .[dev,pyside6] -e ./bec_testing_plugin
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end

View File

@@ -1,6 +1,32 @@
# CHANGELOG
## v2.8.3 (2025-05-30)
### Bug Fixes
- Guard plugin repo import in e2e test
([`bf172b8`](https://github.com/bec-project/bec_widgets/commit/bf172b8431ec207f39206d2a0446908f7186858a))
### Refactoring
- Store modules with widget search
([`b225a7c`](https://github.com/bec-project/bec_widgets/commit/b225a7cc90b55697211c28d9411b6f85c8077217))
### Testing
- **e2e**: Add tests involving plugin repo
([`05329ab`](https://github.com/bec-project/bec_widgets/commit/05329ab50fe10ffc3c19ef3eb408912bb9068de3))
## v2.8.2 (2025-05-27)
### Bug Fixes
- **image_roi**: Rois are invertible by default, fixes resizing bug when adding from ROI manager
([`0bdd4e8`](https://github.com/bec-project/bec_widgets/commit/0bdd4e86a24a61b5365febcb2fcbde0532117053))
## v2.8.1 (2025-05-27)
### Bug Fixes

View File

@@ -242,7 +242,7 @@ class LaunchWindow(BECMainWindow):
)
# plugin widgets
self.available_widgets: dict[str, BECWidget] = get_all_plugin_widgets()
self.available_widgets: dict[str, type[BECWidget]] = get_all_plugin_widgets().as_dict()
if self.available_widgets:
plugin_repo_name = next(iter(self.available_widgets.values())).__module__.split(".")[0]
plugin_repo_name = plugin_repo_name.removesuffix("_bec").upper()

View File

@@ -63,7 +63,7 @@ _Widgets = {
try:
_plugin_widgets = get_all_plugin_widgets()
_plugin_widgets = get_all_plugin_widgets().as_dict()
plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)

View File

@@ -111,7 +111,7 @@ _Widgets = {
self.content += """
try:
_plugin_widgets = get_all_plugin_widgets()
_plugin_widgets = get_all_plugin_widgets().as_dict()
plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)

View File

@@ -31,10 +31,9 @@ class RPCWidgetHandler:
Returns:
None
"""
clss = get_custom_classes("bec_widgets")
self._widget_classes = get_all_plugin_widgets() | {
cls.__name__: cls for cls in clss.widgets if cls.__name__ not in IGNORE_WIDGETS
}
self._widget_classes = (
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
).as_dict(IGNORE_WIDGETS)
def create_widget(self, widget_type, **kwargs) -> BECWidget:
"""

View File

@@ -3,12 +3,17 @@ from __future__ import annotations
import importlib.metadata
import inspect
import pkgutil
import traceback
from importlib import util as importlib_util
from importlib.machinery import FileFinder, ModuleSpec, SourceFileLoader
from types import ModuleType
from typing import Generator
from bec_widgets.utils.bec_widget import BECWidget
from bec_lib.logger import bec_logger
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
logger = bec_logger.logger
def _submodule_specs(module: ModuleType) -> tuple[ModuleSpec | None, ...]:
@@ -30,7 +35,12 @@ def _loaded_submodules_from_specs(
assert isinstance(
submodule.__loader__, SourceFileLoader
), "Module found from FileFinder should have SourceFileLoader!"
submodule.__loader__.exec_module(submodule)
try:
submodule.__loader__.exec_module(submodule)
except Exception as e:
logger.error(
f"Error loading plugin {submodule}: \n{''.join(traceback.format_exception(e))}"
)
yield submodule
@@ -41,27 +51,29 @@ def _submodule_by_name(module: ModuleType, name: str):
return None
def _get_widgets_from_module(module: ModuleType) -> dict[str, "type[BECWidget]"]:
"""Find any BECWidget subclasses in the given module and return them with their names."""
def _get_widgets_from_module(module: ModuleType) -> BECClassContainer:
"""Find any BECWidget subclasses in the given module and return them with their info."""
from bec_widgets.utils.bec_widget import BECWidget # avoid circular import
return dict(
inspect.getmembers(
module,
predicate=lambda item: inspect.isclass(item)
and issubclass(item, BECWidget)
and item is not BECWidget,
)
classes = inspect.getmembers(
module,
predicate=lambda item: inspect.isclass(item)
and issubclass(item, BECWidget)
and item is not BECWidget,
)
return BECClassContainer(
BECClassInfo(name=k, module=module.__name__, file=module.__loader__.get_filename(), obj=v)
for k, v in classes
)
def _all_widgets_from_all_submods(module):
def _all_widgets_from_all_submods(module) -> BECClassContainer:
"""Recursively load submodules, find any BECWidgets, and return them all as a flat dict."""
widgets = _get_widgets_from_module(module)
if not hasattr(module, "__path__"):
return widgets
for submod in _loaded_submodules_from_specs(_submodule_specs(module)):
widgets.update(_all_widgets_from_all_submods(submod))
widgets += _all_widgets_from_all_submods(submod)
return widgets
@@ -75,15 +87,16 @@ def get_plugin_client_module() -> ModuleType | None:
return _submodule_by_name(plugin, "client") if (plugin := user_widget_plugin()) else None
def get_all_plugin_widgets() -> dict[str, "type[BECWidget]"]:
def get_all_plugin_widgets() -> BECClassContainer:
"""If there is a plugin repository installed, load all widgets from it."""
if plugin := user_widget_plugin():
return _all_widgets_from_all_submods(plugin)
else:
return {}
return BECClassContainer()
if __name__ == "__main__": # pragma: no cover
# print(get_all_plugin_widgets())
client = get_plugin_client_module()
print(get_all_plugin_widgets())
...

View File

@@ -4,7 +4,7 @@ import importlib
import inspect
import os
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Iterable
from bec_lib.plugin_helper import _get_available_plugins
from qtpy.QtWidgets import QGraphicsWidget, QWidget
@@ -90,15 +90,15 @@ class BECClassInfo:
name: str
module: str
file: str
obj: type
obj: type[BECWidget]
is_connector: bool = False
is_widget: bool = False
is_plugin: bool = False
class BECClassContainer:
def __init__(self):
self._collection: list[BECClassInfo] = []
def __init__(self, initial: Iterable[BECClassInfo] = []):
self._collection: list[BECClassInfo] = list(initial)
def __repr__(self):
return str(list(cl.name for cl in self.collection))
@@ -106,6 +106,16 @@ class BECClassContainer:
def __iter__(self):
return self._collection.__iter__()
def __add__(self, other: BECClassContainer):
return BECClassContainer((*self, *(c for c in other if c.name not in self.names)))
def as_dict(self, ignores: list[str] = []) -> dict[str, type[BECWidget]]:
"""get a dict of {name: Type} for all the entries in the collection.
Args:
ignores(list[str]): a list of class names to exclude from the dictionary."""
return {c.name: c.obj for c in self if c.name not in ignores}
def add_class(self, class_info: BECClassInfo):
"""
Add a class to the collection.
@@ -115,53 +125,44 @@ class BECClassContainer:
"""
self.collection.append(class_info)
@property
def names(self):
"""Return a list of class names"""
return [c.name for c in self]
@property
def collection(self):
"""
Get the collection of classes.
"""
"""Get the collection of classes."""
return self._collection
@property
def connector_classes(self):
"""
Get all connector classes.
"""
"""Get all connector classes."""
return [info.obj for info in self.collection if info.is_connector]
@property
def top_level_classes(self):
"""
Get all top-level classes.
"""
"""Get all top-level classes."""
return [info.obj for info in self.collection if info.is_plugin]
@property
def plugins(self):
"""
Get all plugins. These are all classes that are on the top level and are widgets.
"""
"""Get all plugins. These are all classes that are on the top level and are widgets."""
return [info.obj for info in self.collection if info.is_widget and info.is_plugin]
@property
def widgets(self):
"""
Get all widgets. These are all classes inheriting from BECWidget.
"""
"""Get all widgets. These are all classes inheriting from BECWidget."""
return [info.obj for info in self.collection if info.is_widget]
@property
def rpc_top_level_classes(self):
"""
Get all top-level classes that are RPC-enabled. These are all classes that users can choose from.
"""
"""Get all top-level classes that are RPC-enabled. These are all classes that users can choose from."""
return [info.obj for info in self.collection if info.is_plugin and info.is_connector]
@property
def classes(self):
"""
Get all classes.
"""
"""Get all classes."""
return [info.obj for info in self.collection]
@@ -197,7 +198,7 @@ def get_custom_classes(repo_name: str) -> BECClassContainer:
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
continue
if isinstance(obj, type):
class_info = BECClassInfo(name=name, module=module_name, file=path, obj=obj)
class_info = BECClassInfo(name=name, module=module.__name__, file=path, obj=obj)
if issubclass(obj, BECConnector):
class_info.is_connector = True
if issubclass(obj, BECWidget):

View File

@@ -31,12 +31,9 @@ class UILoader:
def __init__(self, parent=None):
self.parent = parent
widgets = get_custom_classes("bec_widgets").classes
self.custom_widgets = {widget.__name__: widget for widget in widgets}
plugin_widgets = get_all_plugin_widgets()
self.custom_widgets.update(plugin_widgets)
self.custom_widgets = (
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
).as_dict()
if PYSIDE6:
self.loader = self.load_ui_pyside6

View File

@@ -150,7 +150,12 @@ class BaseROI(BECConnector):
self.parent_plot_item = parent_image.plot_item
object_name = label.replace("-", "_").replace(" ", "_") if label else None
super().__init__(
object_name=object_name, config=config, gui_id=gui_id, removable=True, **pg_kwargs
object_name=object_name,
config=config,
gui_id=gui_id,
removable=True,
invertible=True,
**pg_kwargs,
)
self._label = label or "ROI"

View File

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

View File

@@ -5,11 +5,20 @@ import random
import pytest
from bec_widgets.cli.client_utils import BECGuiClient
from bec_widgets.widgets.control.scan_control import ScanControl
# pylint: disable=unused-argument
# pylint: disable=redefined-outer-name
@pytest.fixture(scope="function")
def scan_control(qtbot, bec_client_lib): # , mock_dev):
widget = ScanControl(client=bec_client_lib)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@pytest.fixture(autouse=True)
def threads_check_fixture(threads_check):
"""

View File

@@ -3,15 +3,6 @@ import time
import pytest
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.control.scan_control import ScanControl
@pytest.fixture(scope="function")
def scan_control(qtbot, bec_client_lib): # , mock_dev):
widget = ScanControl(client=bec_client_lib)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_scan_control_populate_scans_e2e(scan_control):
@@ -27,6 +18,7 @@ def test_scan_control_populate_scans_e2e(scan_control):
"monitor_scan",
"acquire",
"line_scan",
"custom_testing_scan",
]
items = [
scan_control.comboBox_scan_selection.itemText(i)

View File

@@ -0,0 +1,94 @@
import time
import pytest
try:
from bec_testing_plugin.scans.metadata_schema.custom_test_scan_schema import CustomScanSchema
except ImportError:
pytest.skip(reason="Requires plugin repo!", allow_module_level=True)
from qtpy.QtWidgets import QGridLayout
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.control.scan_control import ScanControl
@pytest.fixture(scope="function")
def scan_control(qtbot, bec_client_lib): # , mock_dev):
widget = ScanControl(client=bec_client_lib)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@pytest.mark.parametrize(
["md", "valid"],
[
({"treatment_description": "soaking", "treatment_temperature_k": 123}, True),
({"treatment_description": "soaking", "treatment_temperature_k": "wrong type"}, False),
({"treatment_description": "soaking", "wrong key": 123}, False),
(
{
"sample_name": "test sample",
"treatment_description": "soaking",
"treatment_temperature_k": 123,
},
True,
),
],
)
def test_scan_metadata_for_custom_scan(
scan_control: ScanControl, bec_client_lib, qtbot, md: dict, valid: bool
):
client = bec_client_lib
queue = client.queue
scan_name = "custom_testing_scan"
kwargs = {"exp_time": 0.01, "steps": 10, "relative": True, "burst_at_each_point": 1}
args = {"device": "samx", "start": -5, "stop": 5}
scan_control.comboBox_scan_selection.setCurrentText(scan_name)
# Set kwargs in the UI
for kwarg_box in scan_control.kwarg_boxes:
for widget in kwarg_box.widgets:
for key, value in kwargs.items():
if widget.arg_name == key:
WidgetIO.set_value(widget, value)
break
# Set args in the UI
for widget in scan_control.arg_box.widgets:
for key, value in args.items():
if widget.arg_name == key:
WidgetIO.set_value(widget, value)
break
assert scan_control._metadata_form._md_schema == CustomScanSchema
assert not scan_control.button_run_scan.isEnabled()
def do_test():
# Set the metadata
grid: QGridLayout = scan_control._metadata_form._form_grid.layout()
for i in range(grid.rowCount()): # type: ignore
field_name = grid.itemAtPosition(i, 0).widget().property("_model_field_name")
if (value_to_set := md.pop(field_name, None)) is not None:
grid.itemAtPosition(i, 1).widget().setValue(value_to_set)
# all values should be used
assert md == {}
assert scan_control.button_run_scan.isEnabled()
# Run the scan
scan_control.button_run_scan.click()
time.sleep(2)
last_scan = queue.scan_storage.storage[-1]
assert last_scan.status_message.info["scan_name"] == scan_name
assert last_scan.status_message.info["exp_time"] == kwargs["exp_time"]
assert last_scan.status_message.info["scan_motors"] == [args["device"]]
assert last_scan.status_message.info["num_points"] == kwargs["steps"]
if valid:
do_test()
else:
with pytest.raises(Exception):
do_test()

View File

@@ -2,7 +2,9 @@ from importlib.machinery import FileFinder, SourceFileLoader
from types import ModuleType
from unittest import mock
from bec_widgets.utils.bec_plugin_helper import BECWidget, _all_widgets_from_all_submods
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
def test_all_widgets_from_module_no_submodules():
@@ -39,10 +41,17 @@ def test_all_widgets_from_module_with_submodules():
mock.patch("importlib.util.module_from_spec", return_value=submodule),
mock.patch(
"bec_widgets.utils.bec_plugin_helper._get_widgets_from_module",
side_effect=[{"TestWidget": BECWidget}, {"SubWidget": BECWidget}],
side_effect=[
BECClassContainer(
[BECClassInfo(name="TestWidget", module="", obj=BECWidget, file="")]
),
BECClassContainer(
[BECClassInfo(name="SubWidget", module="", obj=BECWidget, file="")]
),
],
),
):
widgets = _all_widgets_from_all_submods(module)
widgets = _all_widgets_from_all_submods(module).as_dict()
assert widgets == {"TestWidget": BECWidget, "SubWidget": BECWidget}
@@ -54,8 +63,9 @@ def test_all_widgets_from_module_no_widgets():
module = mock.MagicMock()
with mock.patch(
"bec_widgets.utils.bec_plugin_helper._get_widgets_from_module", return_value={}
"bec_widgets.utils.bec_plugin_helper._get_widgets_from_module",
return_value=BECClassContainer([]),
):
widgets = _all_widgets_from_all_submods(module)
widgets = _all_widgets_from_all_submods(module).as_dict()
assert widgets == {}

View File

@@ -7,6 +7,7 @@ from unittest.mock import MagicMock, call, patch
from bec_widgets.cli import client
from bec_widgets.cli.rpc.rpc_base import RPCBase
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
class _TestGlobalPlugin(RPCBase): ...
@@ -47,7 +48,9 @@ mock_client_module_duplicate.DeviceComboBox = _TestDuplicatePlugin
)
@patch(
"bec_widgets.utils.bec_plugin_helper.get_all_plugin_widgets",
return_value={"DeviceComboBox": _TestDuplicatePlugin},
return_value=BECClassContainer(
[BECClassInfo(name="DeviceComboBox", obj=_TestDuplicatePlugin, module="", file="")]
),
)
def test_duplicate_plugins_not_allowed(_, bec_logger: MagicMock):
reload(client)

View File

@@ -99,7 +99,7 @@ def test_client_generator_with_black_formatting():
try:
_plugin_widgets = get_all_plugin_widgets()
_plugin_widgets = get_all_plugin_widgets().as_dict()
plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)

View File

@@ -7,6 +7,7 @@ from bec_widgets.cli import client
from bec_widgets.cli.rpc.rpc_base import RPCBase
from bec_widgets.cli.rpc.rpc_widget_handler import RPCWidgetHandler
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
from bec_widgets.widgets.containers.dock.dock import BECDock
@@ -21,7 +22,12 @@ class _TestPluginWidget(BECWidget): ...
@patch(
"bec_widgets.cli.rpc.rpc_widget_handler.get_all_plugin_widgets",
return_value={"DeviceComboBox": _TestPluginWidget, "NewPluginWidget": _TestPluginWidget},
return_value=BECClassContainer(
[
BECClassInfo(name="DeviceComboBox", obj=_TestPluginWidget, module="", file=""),
BECClassInfo(name="NewPluginWidget", obj=_TestPluginWidget, module="", file=""),
]
),
)
def test_duplicate_plugins_not_allowed(_):
handler = RPCWidgetHandler()

View File

@@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_lib.messages import AvailableResourceMessage, ScanQueueHistoryMessage, ScanQueueMessage
from qtpy.QtCore import QModelIndex, QPoint, Qt
from qtpy.QtCore import QModelIndex, Qt
from bec_widgets.utils.forms_from_types.items import StrMetadataField
from bec_widgets.utils.widget_io import WidgetIO