diff --git a/bec_widgets/cli/designer_plugins.py b/bec_widgets/cli/designer_plugins.py new file mode 100644 index 00000000..86bfafa1 --- /dev/null +++ b/bec_widgets/cli/designer_plugins.py @@ -0,0 +1,118 @@ +# This file was automatically generated by generate_cli.py +# type: ignore +from __future__ import annotations + +# pylint: skip-file + +designer_plugins = { + "AbortButton": ("bec_widgets.widgets.control.buttons.button_abort.button_abort", "AbortButton"), + "BECColorMapWidget": ( + "bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget", + "BECColorMapWidget", + ), + "BECMainWindow": ("bec_widgets.widgets.containers.main_window.main_window", "BECMainWindow"), + "BECProgressBar": ( + "bec_widgets.widgets.progress.bec_progressbar.bec_progressbar", + "BECProgressBar", + ), + "BECQueue": ("bec_widgets.widgets.services.bec_queue.bec_queue", "BECQueue"), + "BECShell": ("bec_widgets.widgets.editors.bec_console.bec_console", "BECShell"), + "BECSpinBox": ("bec_widgets.widgets.utility.spinbox.decimal_spinbox", "BECSpinBox"), + "BECStatusBox": ("bec_widgets.widgets.services.bec_status_box.bec_status_box", "BECStatusBox"), + "BecConsole": ("bec_widgets.widgets.editors.bec_console.bec_console", "BecConsole"), + "ColorButton": ("bec_widgets.widgets.utility.visual.color_button.color_button", "ColorButton"), + "ColorButtonNative": ( + "bec_widgets.widgets.utility.visual.color_button_native.color_button_native", + "ColorButtonNative", + ), + "ColormapSelector": ( + "bec_widgets.widgets.utility.visual.colormap_selector.colormap_selector", + "ColormapSelector", + ), + "DapComboBox": ("bec_widgets.widgets.dap.dap_combo_box.dap_combo_box", "DapComboBox"), + "DarkModeButton": ( + "bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button", + "DarkModeButton", + ), + "DeviceBrowser": ( + "bec_widgets.widgets.services.device_browser.device_browser", + "DeviceBrowser", + ), + "DeviceComboBox": ( + "bec_widgets.widgets.control.device_input.device_combobox.device_combobox", + "DeviceComboBox", + ), + "DeviceLineEdit": ( + "bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit", + "DeviceLineEdit", + ), + "Heatmap": ("bec_widgets.widgets.plots.heatmap.heatmap", "Heatmap"), + "IDEExplorer": ("bec_widgets.widgets.utility.ide_explorer.ide_explorer", "IDEExplorer"), + "Image": ("bec_widgets.widgets.plots.image.image", "Image"), + "LMFitDialog": ("bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog", "LMFitDialog"), + "LogPanel": ("bec_widgets.widgets.utility.logpanel.logpanel", "LogPanel"), + "Minesweeper": ("bec_widgets.widgets.games.minesweeper", "Minesweeper"), + "MonacoWidget": ("bec_widgets.widgets.editors.monaco.monaco_widget", "MonacoWidget"), + "MotorMap": ("bec_widgets.widgets.plots.motor_map.motor_map", "MotorMap"), + "MultiWaveform": ("bec_widgets.widgets.plots.multi_waveform.multi_waveform", "MultiWaveform"), + "PdfViewerWidget": ("bec_widgets.widgets.utility.pdf_viewer.pdf_viewer", "PdfViewerWidget"), + "PositionIndicator": ( + "bec_widgets.widgets.control.device_control.position_indicator.position_indicator", + "PositionIndicator", + ), + "PositionerBox": ( + "bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box", + "PositionerBox", + ), + "PositionerBox2D": ( + "bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d", + "PositionerBox2D", + ), + "PositionerControlLine": ( + "bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line", + "PositionerControlLine", + ), + "PositionerGroup": ( + "bec_widgets.widgets.control.device_control.positioner_group.positioner_group", + "PositionerGroup", + ), + "ResetButton": ("bec_widgets.widgets.control.buttons.button_reset.button_reset", "ResetButton"), + "ResumeButton": ( + "bec_widgets.widgets.control.buttons.button_resume.button_resume", + "ResumeButton", + ), + "RingProgressBar": ( + "bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar", + "RingProgressBar", + ), + "SBBMonitor": ("bec_widgets.widgets.editors.sbb_monitor.sbb_monitor", "SBBMonitor"), + "ScanControl": ("bec_widgets.widgets.control.scan_control.scan_control", "ScanControl"), + "ScanMetadata": ("bec_widgets.widgets.editors.scan_metadata.scan_metadata", "ScanMetadata"), + "ScanProgressBar": ( + "bec_widgets.widgets.progress.scan_progressbar.scan_progressbar", + "ScanProgressBar", + ), + "ScatterWaveform": ( + "bec_widgets.widgets.plots.scatter_waveform.scatter_waveform", + "ScatterWaveform", + ), + "SignalComboBox": ( + "bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox", + "SignalComboBox", + ), + "SignalLabel": ("bec_widgets.widgets.utility.signal_label.signal_label", "SignalLabel"), + "SignalLineEdit": ( + "bec_widgets.widgets.control.device_input.signal_line_edit.signal_line_edit", + "SignalLineEdit", + ), + "SpinnerWidget": ("bec_widgets.widgets.utility.spinner.spinner", "SpinnerWidget"), + "StopButton": ("bec_widgets.widgets.control.buttons.stop_button.stop_button", "StopButton"), + "TextBox": ("bec_widgets.widgets.editors.text_box.text_box", "TextBox"), + "ToggleSwitch": ("bec_widgets.widgets.utility.toggle.toggle", "ToggleSwitch"), + "Waveform": ("bec_widgets.widgets.plots.waveform.waveform", "Waveform"), + "WebsiteWidget": ("bec_widgets.widgets.editors.website.website", "WebsiteWidget"), + "WidgetFinderComboBox": ( + "bec_widgets.widgets.utility.widget_finder.widget_finder", + "WidgetFinderComboBox", + ), +} diff --git a/bec_widgets/utils/bec_plugin_helper.py b/bec_widgets/utils/bec_plugin_helper.py index 242adbdc..50071bd5 100644 --- a/bec_widgets/utils/bec_plugin_helper.py +++ b/bec_widgets/utils/bec_plugin_helper.py @@ -4,6 +4,7 @@ import importlib.metadata import inspect import pkgutil import traceback +from functools import lru_cache from importlib import util as importlib_util from importlib.machinery import FileFinder, ModuleSpec, SourceFileLoader from types import ModuleType @@ -11,7 +12,11 @@ from typing import Generator from bec_lib.logger import bec_logger -from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo +from bec_widgets.utils.plugin_utils import ( + BECClassContainer, + BECClassInfo, + rpc_widget_registry_from_source, +) logger = bec_logger.logger @@ -53,6 +58,14 @@ def _submodule_by_name(module: ModuleType, name: str): return None +def _submodule_spec_by_name(module: ModuleType, name: str) -> ModuleSpec | None: + for module_info in pkgutil.iter_modules(module.__path__): + if module_info.name != name or not isinstance(module_info.module_finder, FileFinder): + continue + return module_info.module_finder.find_spec(module_info.name) + return None + + 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 @@ -90,16 +103,55 @@ def get_plugin_client_module() -> ModuleType | None: return _submodule_by_name(plugin, "client") if (plugin := user_widget_plugin()) else None +def get_plugin_designer_module() -> ModuleType | None: + """If there is a plugin repository installed, return the designer module.""" + return ( + _submodule_by_name(plugin, "designer_plugins") if (plugin := user_widget_plugin()) else None + ) + + +@lru_cache +def get_plugin_rpc_widget_registry() -> dict[str, tuple[str, str]]: + """If there is a plugin repository installed, return the RPC widget registry.""" + plugin = user_widget_plugin() + if plugin is None: + return {} + + client_spec = _submodule_spec_by_name(plugin, "client") + if client_spec is not None and client_spec.origin: + try: + return rpc_widget_registry_from_source(client_spec.origin) + except (OSError, SyntaxError) as exc: + logger.warning(f"Could not parse plugin RPC widget registry: {exc}") + + client_module = get_plugin_client_module() + if client_module is None: + return {} + registry = {} + for plugin_name, plugin_class in inspect.getmembers(client_module, inspect.isclass): + if hasattr(plugin_class, "_IMPORT_MODULE"): + registry[plugin_name] = (plugin_class._IMPORT_MODULE, plugin_class.__name__) + return registry + + +@lru_cache +def get_plugin_designer_registry() -> dict[str, tuple[str, str]]: + """If there is a plugin repository installed, return the designer plugin registry.""" + designer_module = get_plugin_designer_module() + if designer_module and hasattr(designer_module, "designer_plugins"): + return designer_module.designer_plugins + return {} + + 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 BECClassContainer() + return BECClassContainer() if __name__ == "__main__": # pragma: no cover - + widgets = get_plugin_rpc_widget_registry() client = get_plugin_client_module() print(get_all_plugin_widgets()) ... diff --git a/bec_widgets/utils/generate_cli.py b/bec_widgets/utils/generate_cli.py index 5aa8a494..c31d9290 100644 --- a/bec_widgets/utils/generate_cli.py +++ b/bec_widgets/utils/generate_cli.py @@ -14,7 +14,11 @@ import isort from bec_lib.logger import bec_logger from qtpy.QtCore import Property as QtProperty -from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator, plugin_filenames +from bec_widgets.utils.generate_designer_plugin import ( + DesignerPluginGenerator, + DesignerPluginInfo, + plugin_filenames, +) from bec_widgets.utils.plugin_utils import BECClassContainer, get_custom_classes logger = bec_logger.logger @@ -250,6 +254,50 @@ class {class_name}(RPCBase):\n""" file.write(formatted_content) +def write_designer_plugins(plugin_infos: list[DesignerPluginInfo], file_name: str): + """ + Write a registry of Qt widget classes with designer plugins. + + Args: + plugin_infos(list[DesignerPluginInfo]): The designer plugin metadata to write. + file_name(str): The name of the file to write to. + """ + plugin_infos = sorted(plugin_infos, key=lambda info: info.plugin_name_pascal) + content = """# This file was automatically generated by generate_cli.py +# type: ignore +from __future__ import annotations + +# pylint: skip-file + +designer_plugins = { +""" + for info in plugin_infos: + widget_module = info.plugin_class.__module__ + widget_class = info.plugin_name_pascal + content += f' "{info.plugin_name_pascal}": ("{widget_module}", "{widget_class}"),\n' + + content += """ +} +""" + + try: + formatted_content = black.format_str(content, mode=black.Mode(line_length=100)) + except black.NothingChanged: + formatted_content = content + + config = isort.Config( + profile="black", + line_length=100, + multi_line_output=3, + include_trailing_comma=False, + known_first_party=["bec_widgets"], + ) + formatted_content = isort.code(formatted_content, config=config) + + with open(file_name, "w", encoding="utf-8") as file: + file.write(formatted_content) + + def main(): """ Main entry point for the script, controlled by command line arguments. @@ -303,6 +351,8 @@ def main(): else: non_overwrite_classes = [] + designer_plugin_infos = [] + for cls in rpc_classes.plugins: logger.info(f"Writing bec-designer plugin files for {cls.__name__}...") @@ -310,21 +360,30 @@ def main(): logger.error( f"Not writing plugin files for {cls.__name__} because a built-in widget with that name exists" ) + continue plugin = DesignerPluginGenerator(cls) - if not hasattr(plugin, "info"): + if not hasattr(plugin, "info") or plugin.excluded: continue def _exists(file: str): return os.path.exists(os.path.join(plugin.info.base_path, file)) if any(_exists(file) for file in plugin_filenames(plugin.info.plugin_name_snake)): + if _exists(plugin.filenames.plugin): + designer_plugin_infos.append(plugin.info) logger.debug( f"Skipping generation of extra plugin files for {plugin.info.plugin_name_snake} - at least one file out of 'plugin.py', 'pyproject', and 'register_{plugin.info.plugin_name_snake}.py' already exists." ) continue plugin.run() + designer_plugin_infos.append(plugin.info) + + # Write designer_plugins.py with plugin import metadata for all widgets with designer plugins. + designer_plugins_path = module_dir / client_subdir / "designer_plugins.py" + logger.info(f"Generating designer plugin registry at {designer_plugins_path}") + write_designer_plugins(designer_plugin_infos, str(designer_plugins_path)) if __name__ == "__main__": # pragma: no cover diff --git a/bec_widgets/utils/generate_designer_plugin.py b/bec_widgets/utils/generate_designer_plugin.py index 4495f0cd..12a95ea3 100644 --- a/bec_widgets/utils/generate_designer_plugin.py +++ b/bec_widgets/utils/generate_designer_plugin.py @@ -63,6 +63,10 @@ class DesignerPluginGenerator: def filenames(self): return plugin_filenames(self.info.plugin_name_snake) + @property + def excluded(self): + return self._excluded + def run(self, validate=True): if self._excluded: print(f"Plugin {self.widget.__name__} is excluded from generation.") diff --git a/bec_widgets/utils/plugin_utils.py b/bec_widgets/utils/plugin_utils.py index c9c367ae..0e4e082f 100644 --- a/bec_widgets/utils/plugin_utils.py +++ b/bec_widgets/utils/plugin_utils.py @@ -1,56 +1,22 @@ from __future__ import annotations +import ast import importlib import inspect import os from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path from typing import TYPE_CHECKING, Iterable -from bec_lib.plugin_helper import _get_available_plugins -from qtpy.QtWidgets import QWidget - -from bec_widgets.utils.bec_connector import BECConnector -from bec_widgets.utils.bec_widget import BECWidget - if TYPE_CHECKING: # pragma: no cover + from qtpy.QtWidgets import QWidget + + from bec_widgets.utils.bec_connector import BECConnector + from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates -def get_plugin_widgets() -> dict[str, BECConnector]: - """ - Get all available widgets from the plugin directory. Widgets are classes that inherit from BECConnector. - The plugins are provided through python plugins and specified in the respective pyproject.toml file using - the following key: - - [project.entry-points."bec.widgets.user_widgets"] - plugin_widgets = "path.to.plugin.module" - - e.g. - [project.entry-points."bec.widgets.user_widgets"] - plugin_widgets = "pxiii_bec.bec_widgets.widgets" - - assuming that the widgets module for the package pxiii_bec is located at pxiii_bec/bec_widgets/widgets and - contains the widgets to be loaded within the pxiii_bec/bec_widgets/widgets/__init__.py file. - - Returns: - dict[str, BECConnector]: A dictionary of widget names and their respective classes. - """ - modules = _get_available_plugins("bec.widgets.user_widgets") - loaded_plugins = {} - print(modules) - for module in modules: - mods = inspect.getmembers(module, predicate=_filter_plugins) - for name, mod_cls in mods: - if name in loaded_plugins: - print(f"Duplicated widgets plugin {name}.") - loaded_plugins[name] = mod_cls - return loaded_plugins - - -def _filter_plugins(obj): - return inspect.isclass(obj) and issubclass(obj, BECConnector) - - def get_plugin_auto_updates() -> dict[str, type[AutoUpdates]]: """ Get all available auto update classes from the plugin directory. AutoUpdates must inherit from AutoUpdate and be @@ -66,6 +32,8 @@ def get_plugin_auto_updates() -> dict[str, type[AutoUpdates]]: Returns: dict[str, AutoUpdates]: A dictionary of widget names and their respective classes. """ + from bec_lib.plugin_helper import _get_available_plugins + modules = _get_available_plugins("bec.widgets.auto_updates") loaded_plugins = {} for module in modules: @@ -168,6 +136,11 @@ class BECClassContainer: def _collect_classes_from_package(repo_name: str, package: str) -> BECClassContainer: """Collect classes from a package subtree (for example ``widgets`` or ``applications``).""" + from qtpy.QtWidgets import QWidget + + from bec_widgets.utils.bec_connector import BECConnector + from bec_widgets.utils.bec_widget import BECWidget + collection = BECClassContainer() try: anchor_module = importlib.import_module(f"{repo_name}.{package}") @@ -194,17 +167,18 @@ def _collect_classes_from_package(repo_name: str, package: str) -> BECClassConta for name in dir(module): obj = getattr(module, name) + if not isinstance(obj, type): + continue if not hasattr(obj, "__module__") or obj.__module__ != module.__name__: continue - if isinstance(obj, type): - class_info = BECClassInfo(name=name, module=module.__name__, file=path, obj=obj) - if issubclass(obj, BECConnector): - class_info.is_connector = True - if issubclass(obj, QWidget) or issubclass(obj, BECWidget): - class_info.is_widget = True - if hasattr(obj, "PLUGIN") and obj.PLUGIN: - class_info.is_plugin = True - collection.add_class(class_info) + class_info = BECClassInfo(name=name, module=module.__name__, file=path, obj=obj) + if issubclass(obj, BECConnector): + class_info.is_connector = True + if issubclass(obj, QWidget) or issubclass(obj, BECWidget): + class_info.is_widget = True + if hasattr(obj, "PLUGIN") and obj.PLUGIN: + class_info.is_plugin = True + collection.add_class(class_info) return collection @@ -229,3 +203,89 @@ def get_custom_classes( for package in selected_packages: collection += _collect_classes_from_package(repo_name, package) return collection + + +def _get_designer_registry() -> dict[str, tuple[str, str]]: + from bec_widgets.cli.designer_plugins import designer_plugins + + return designer_plugins + + +def _resolve_widget_from_registry(import_path: str, widget_name: str) -> type[QWidget]: + widget = importlib.import_module(import_path) + return getattr(widget, widget_name) + + +def designer_plugin_exists(name: str) -> bool: + from bec_widgets.utils.bec_plugin_helper import get_plugin_designer_registry + + internal_registry = _get_designer_registry() + external_registry = get_plugin_designer_registry() + return name in internal_registry or name in external_registry + + +def get_designer_plugin(name: str, raise_on_missing: bool = True) -> type[QWidget] | None: + from bec_widgets.utils.bec_plugin_helper import get_plugin_designer_registry + + internal_registry = _get_designer_registry() + external_registry = get_plugin_designer_registry() + if name in external_registry: + import_path, widget_name = external_registry[name] + return _resolve_widget_from_registry(import_path, widget_name) + if name in internal_registry: + import_path, widget_name = internal_registry[name] + return _resolve_widget_from_registry(import_path, widget_name) + + if raise_on_missing: + raise ValueError( + f"Designer plugin {name} not found in either internal or external registry." + ) + return None + + +def rpc_widget_registry_from_source(path: str | Path) -> dict[str, tuple[str, str]]: + """Parse a generated RPC client module and return its widget registry.""" + source_path = Path(path) + module_node = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path)) + registry = {} + for node in module_node.body: + if not isinstance(node, ast.ClassDef): + continue + for item in node.body: + if not isinstance(item, ast.Assign): + continue + if not any( + isinstance(target, ast.Name) and target.id == "_IMPORT_MODULE" + for target in item.targets + ): + continue + if isinstance(item.value, ast.Constant) and isinstance(item.value.value, str): + registry[node.name] = (item.value.value, node.name) + break + return registry + + +@lru_cache +def get_rpc_widget_registry() -> dict[str, tuple[str, str]]: + client_path = Path(__file__).resolve().parents[1] / "cli" / "client.py" + return rpc_widget_registry_from_source(client_path) + + +@lru_cache +def rpc_widget_registry() -> dict[str, tuple[str, str]]: + from bec_widgets.utils.bec_plugin_helper import get_plugin_rpc_widget_registry + + internal_registry = get_rpc_widget_registry() + external_registry = get_plugin_rpc_widget_registry() + return {**external_registry, **internal_registry} + + +def get_rpc_widget(name: str, raise_on_missing: bool = True) -> type[QWidget] | None: + registry = rpc_widget_registry() + if name in registry: + import_path, widget_name = registry[name] + return _resolve_widget_from_registry(import_path, widget_name) + + if raise_on_missing: + raise ValueError(f"RPC widget {name} not found in registry.") + return None diff --git a/bec_widgets/utils/rpc_widget_handler.py b/bec_widgets/utils/rpc_widget_handler.py index 83d9a04d..dfa58c40 100644 --- a/bec_widgets/utils/rpc_widget_handler.py +++ b/bec_widgets/utils/rpc_widget_handler.py @@ -1,40 +1,31 @@ from __future__ import annotations -from bec_widgets.cli.client_utils import IGNORE_WIDGETS -from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets -from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.plugin_utils import get_custom_classes +from typing import TYPE_CHECKING + +from bec_widgets.utils.plugin_utils import get_rpc_widget, rpc_widget_registry + +if TYPE_CHECKING: # pragma: no cover + from bec_widgets.utils.bec_widget import BECWidget class RPCWidgetHandler: """Handler class for creating widgets from RPC messages.""" def __init__(self): - self._widget_classes = None + self._widget_registry = None @property - def widget_classes(self) -> dict[str, type[BECWidget]]: + def widget_classes(self) -> dict[str, tuple[str, str]]: """ Get the available widget classes. Returns: dict: The available widget classes. """ - if self._widget_classes is None: - self.update_available_widgets() - return self._widget_classes # type: ignore - - def update_available_widgets(self): - """ - Update the available widgets. - - Returns: - None - """ - self._widget_classes = ( - get_custom_classes("bec_widgets", packages=("widgets", "applications")) - + get_all_plugin_widgets() - ).as_dict(IGNORE_WIDGETS) + registry = rpc_widget_registry() + if not registry: + return {} + return registry def create_widget(self, widget_type, **kwargs) -> BECWidget: """ @@ -48,9 +39,9 @@ class RPCWidgetHandler: Returns: widget(BECWidget): The created widget. """ - widget_class = self.widget_classes.get(widget_type) # type: ignore - if widget_class: - return widget_class(**kwargs) + widget = get_rpc_widget(widget_type, raise_on_missing=False) + if widget: + return widget(**kwargs) raise ValueError(f"Unknown widget type: {widget_type}") diff --git a/bec_widgets/utils/ui_loader.py b/bec_widgets/utils/ui_loader.py index 60b65e51..17c3bdb9 100644 --- a/bec_widgets/utils/ui_loader.py +++ b/bec_widgets/utils/ui_loader.py @@ -1,10 +1,8 @@ from bec_lib.logger import bec_logger -from qtpy import PYQT6, PYSIDE6 +from qtpy import PYSIDE6 from qtpy.QtCore import QFile, QIODevice -from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets -from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo -from bec_widgets.utils.plugin_utils import get_custom_classes +from bec_widgets.utils.plugin_utils import get_designer_plugin logger = bec_logger.logger @@ -12,16 +10,14 @@ if PYSIDE6: from qtpy.QtUiTools import QUiLoader class CustomUiLoader(QUiLoader): - def __init__(self, baseinstance, custom_widgets: dict | None = None): + def __init__(self, baseinstance): super().__init__(baseinstance) - self.custom_widgets = custom_widgets or {} - self.baseinstance = baseinstance def createWidget(self, class_name, parent=None, name=""): - if class_name in self.custom_widgets: - widget = self.custom_widgets[class_name](self.baseinstance) - return widget + widget = get_designer_plugin(class_name, raise_on_missing=False) + if widget is not None: + return widget(self.baseinstance) return super().createWidget(class_name, self.baseinstance, name) @@ -31,16 +27,9 @@ class UILoader: def __init__(self, parent=None): self.parent = parent - self.custom_widgets = ( - get_custom_classes("bec_widgets") + get_all_plugin_widgets() - ).as_dict() - - if PYSIDE6: - self.loader = self.load_ui_pyside6 - elif PYQT6: - self.loader = self.load_ui_pyqt6 - else: + if not PYSIDE6: raise ImportError("No compatible Qt bindings found.") + self.loader = self.load_ui_pyside6 def load_ui_pyside6(self, ui_file, parent=None): """ @@ -53,7 +42,7 @@ class UILoader: QWidget: The loaded widget. """ parent = parent or self.parent - loader = CustomUiLoader(parent, self.custom_widgets) + loader = CustomUiLoader(parent) file = QFile(ui_file) if not file.open(QIODevice.ReadOnly): raise IOError(f"Cannot open file: {ui_file}") @@ -61,71 +50,6 @@ class UILoader: file.close() return widget - def load_ui_pyqt6(self, ui_file, parent=None): - """ - Specific loader for PyQt6 using loadUi. - Args: - ui_file(str): Path to the .ui file. - parent(QWidget): Parent widget. - - Returns: - QWidget: The loaded widget. - """ - from PyQt6.uic.Loader.loader import DynamicUILoader - - class CustomDynamicUILoader(DynamicUILoader): - def __init__(self, package, custom_widgets: dict = None): - super().__init__(package) - self.custom_widgets = custom_widgets or {} - - def _handle_custom_widgets(self, el): - """Handle the element.""" - - def header2module(header): - """header2module(header) -> string - - Convert paths to C++ header files to according Python modules - >>> header2module("foo/bar/baz.h") - 'foo.bar.baz' - """ - - if header.endswith(".h"): - header = header[:-2] - - mpath = [] - for part in header.split("/"): - # Ignore any empty parts or those that refer to the current - # directory. - if part not in ("", "."): - if part == "..": - # We should allow this for Python3. - raise SyntaxError( - "custom widget header file name may not contain '..'." - ) - - mpath.append(part) - - return ".".join(mpath) - - for custom_widget in el: - classname = custom_widget.findtext("class") - header = custom_widget.findtext("header") - if header: - header = self._translate_bec_widgets_header(header) - self.factory.addCustomWidget( - classname, - custom_widget.findtext("extends") or "QWidget", - header2module(header), - ) - - def _translate_bec_widgets_header(self, header): - for name, value in self.custom_widgets.items(): - if header == DesignerPluginInfo.pascal_to_snake(name): - return value.__module__ - return header - - return CustomDynamicUILoader("", self.custom_widgets).loadUi(ui_file, parent) - def load_ui(self, ui_file, parent=None): """ Universal UI loader method. diff --git a/bec_widgets/widgets/plots/heatmap/heatmap.py b/bec_widgets/widgets/plots/heatmap/heatmap.py index dcaeb91e..53608d72 100644 --- a/bec_widgets/widgets/plots/heatmap/heatmap.py +++ b/bec_widgets/widgets/plots/heatmap/heatmap.py @@ -2,21 +2,16 @@ from __future__ import annotations import json from dataclasses import dataclass -from typing import Literal +from typing import TYPE_CHECKING, Literal import numpy as np import pyqtgraph as pg from bec_lib import bec_logger, messages from bec_lib.endpoints import MessageEndpoints +from bec_lib.utils.import_utils import lazy_import, lazy_import_from from pydantic import BaseModel, Field, field_validator from qtpy.QtCore import QObject, Qt, QThread, QTimer, Signal from qtpy.QtGui import QTransform -from scipy.interpolate import ( - CloughTocher2DInterpolator, - LinearNDInterpolator, - NearestNDInterpolator, -) -from scipy.spatial import cKDTree from toolz import partition from bec_widgets.utils.bec_connector import ConnectionConfig @@ -32,6 +27,22 @@ from bec_widgets.widgets.plots.plot_base import PlotBase logger = bec_logger.logger +if TYPE_CHECKING: + from scipy.interpolate import ( + CloughTocher2DInterpolator, + LinearNDInterpolator, + NearestNDInterpolator, + ) + from scipy.spatial import cKDTree +else: + + CloughTocher2DInterpolator, LinearNDInterpolator, NearestNDInterpolator = lazy_import_from( + "scipy.interpolate", + ["CloughTocher2DInterpolator", "LinearNDInterpolator", "NearestNDInterpolator"], + ) + cKDTree = lazy_import_from("scipy.spatial", ["cKDTree"]) + + class HeatmapDeviceSignal(BaseModel): """The configuration of a signal in the scatter waveform widget.""" diff --git a/bec_widgets/widgets/plots/waveform/waveform.py b/bec_widgets/widgets/plots/waveform/waveform.py index ee9a90fc..467a6138 100644 --- a/bec_widgets/widgets/plots/waveform/waveform.py +++ b/bec_widgets/widgets/plots/waveform/waveform.py @@ -10,6 +10,7 @@ from bec_lib.device import Positioner from bec_lib.endpoints import MessageEndpoints from bec_lib.lmfit_serializer import serialize_lmfit_params, serialize_param_object from bec_lib.scan_data_container import ScanDataContainer +from bec_lib.utils.import_utils import lazy_import from pydantic import Field, ValidationError, field_validator from qtpy.QtCore import Qt, QTimer, Signal from qtpy.QtWidgets import ( @@ -54,13 +55,7 @@ _DAP_PARAM = object() if TYPE_CHECKING: # pragma: no cover import lmfit # type: ignore else: - try: - import lmfit # type: ignore - except Exception as e: # pragma: no cover - logger.warning( - f"lmfit could not be imported: {e}. Custom DAP functionality will be unavailable." - ) - lmfit = None + lmfit = lazy_import("lmfit") # noinspection PyDataclass diff --git a/tests/unit_tests/test_dock_area.py b/tests/unit_tests/test_dock_area.py index 30ab3c08..63932b89 100644 --- a/tests/unit_tests/test_dock_area.py +++ b/tests/unit_tests/test_dock_area.py @@ -239,7 +239,7 @@ class TestBasicDockArea: assert basic_dock_area.widget_map(bec_widgets_only=False)["panel_bec"] is panel_bec def test_new_widget_string_creates_widget(self, basic_dock_area, qtbot): - basic_dock_area.new("DarkModeButton") + basic_dock_area.new("RingProgressBar") qtbot.waitUntil(lambda: len(basic_dock_area.dock_list()) > 0, timeout=1000) assert basic_dock_area.widget_list() @@ -652,7 +652,7 @@ class TestDockManagement: initial_count = len(advanced_dock_area.dock_list()) # Create a widget by string name - widget = advanced_dock_area.new("DarkModeButton") + widget = advanced_dock_area.new("RingProgressBar") # Wait for the dock to be created (since it's async) qtbot.wait(200) @@ -720,7 +720,7 @@ class TestDockManagement: initial_count = len(widget_map) # Create a widget - advanced_dock_area.new("DarkModeButton") + advanced_dock_area.new("RingProgressBar") qtbot.wait(200) # Check widget map updated @@ -734,7 +734,7 @@ class TestDockManagement: initial_count = len(widget_list) # Create a widget - advanced_dock_area.new("DarkModeButton") + advanced_dock_area.new("RingProgressBar") qtbot.wait(200) # Check widget list updated @@ -744,8 +744,8 @@ class TestDockManagement: def test_delete_all(self, advanced_dock_area, qtbot): """Test delete_all functionality.""" # Create multiple widgets - advanced_dock_area.new("DarkModeButton") - advanced_dock_area.new("DarkModeButton") + advanced_dock_area.new("RingProgressBar") + advanced_dock_area.new("RingProgressBar") # Wait for docks to be created qtbot.wait(200) @@ -801,7 +801,7 @@ class TestWorkspaceLocking: def test_lock_workspace_property_setter(self, advanced_dock_area, qtbot): """Test workspace_is_locked property setter.""" # Create a dock first - advanced_dock_area.new("DarkModeButton") + advanced_dock_area.new("RingProgressBar") qtbot.wait(200) # Initially unlocked @@ -926,8 +926,8 @@ class TestToolbarFunctionality: def test_attach_all_action(self, advanced_dock_area, qtbot): """Test attach_all toolbar action.""" # Create floating docks - advanced_dock_area.new("DarkModeButton", start_floating=True) - advanced_dock_area.new("DarkModeButton", start_floating=True) + advanced_dock_area.new("RingProgressBar", start_floating=True) + advanced_dock_area.new("RingProgressBar", start_floating=True) qtbot.wait(200) @@ -955,7 +955,7 @@ class TestToolbarFunctionality: # Floating entry settings.setArrayIndex(0) settings.setValue("object_name", "FloatingWaveform") - settings.setValue("widget_class", "DarkModeButton") + settings.setValue("widget_class", "RingProgressBar") settings.setValue("closable", True) settings.setValue("floatable", True) settings.setValue("movable", True) @@ -973,7 +973,7 @@ class TestToolbarFunctionality: # Anchored entry settings.setArrayIndex(1) settings.setValue("object_name", "EmbeddedWaveform") - settings.setValue("widget_class", "DarkModeButton") + settings.setValue("widget_class", "RingProgressBar") settings.setValue("closable", True) settings.setValue("floatable", True) settings.setValue("movable", True) @@ -1696,9 +1696,9 @@ class TestProfileManagement: settings = open_user_settings("test_manifest") # Create real docks - advanced_dock_area.new("DarkModeButton") - advanced_dock_area.new("DarkModeButton") - advanced_dock_area.new("DarkModeButton") + advanced_dock_area.new("RingProgressBar") + advanced_dock_area.new("RingProgressBar") + advanced_dock_area.new("RingProgressBar") # Wait for docks to be created qtbot.wait(1000) @@ -1806,7 +1806,7 @@ class TestWorkspaceProfileOperations: settings.beginWriteArray("manifest/widgets", 1) settings.setArrayIndex(0) settings.setValue("object_name", "test_widget") - settings.setValue("widget_class", "DarkModeButton") + settings.setValue("widget_class", "RingProgressBar") settings.setValue("closable", True) settings.setValue("floatable", True) settings.setValue("movable", True) @@ -1835,7 +1835,7 @@ class TestWorkspaceProfileOperations: settings.beginWriteArray("manifest/widgets", 1) settings.setArrayIndex(0) settings.setValue("object_name", "source_widget") - settings.setValue("widget_class", "DarkModeButton") + settings.setValue("widget_class", "RingProgressBar") settings.setValue("closable", True) settings.setValue("floatable", True) settings.setValue("movable", True) @@ -1844,7 +1844,7 @@ class TestWorkspaceProfileOperations: advanced_dock_area.load_profile(source_profile) qtbot.wait(500) - advanced_dock_area.new("DarkModeButton") + advanced_dock_area.new("RingProgressBar") qtbot.wait(500) class StubDialog: @@ -1883,7 +1883,7 @@ class TestWorkspaceProfileOperations: settings.beginWriteArray("manifest/widgets", 1) settings.setArrayIndex(0) settings.setValue("object_name", f"{profile}_widget") - settings.setValue("widget_class", "DarkModeButton") + settings.setValue("widget_class", "RingProgressBar") settings.setValue("closable", True) settings.setValue("floatable", True) settings.setValue("movable", True) @@ -1892,7 +1892,7 @@ class TestWorkspaceProfileOperations: advanced_dock_area.load_profile(profile_a) qtbot.wait(500) - advanced_dock_area.new("DarkModeButton") + advanced_dock_area.new("RingProgressBar") qtbot.wait(500) advanced_dock_area.load_profile(profile_b) @@ -2036,7 +2036,7 @@ class TestCleanupAndMisc: def test_apply_dock_lock(self, advanced_dock_area, qtbot): """Test _apply_dock_lock functionality.""" # Create a dock first - advanced_dock_area.new("DarkModeButton") + advanced_dock_area.new("RingProgressBar") qtbot.wait(200) # Test locking @@ -2336,8 +2336,8 @@ class TestModeTransitions: def test_mode_switching_preserves_existing_docks(self, advanced_dock_area, qtbot): """Test that mode switching doesn't affect existing docked widgets.""" # Create some widgets - advanced_dock_area.new("DarkModeButton") - advanced_dock_area.new("DarkModeButton") + advanced_dock_area.new("RingProgressBar") + advanced_dock_area.new("RingProgressBar") qtbot.wait(200) initial_dock_count = len(advanced_dock_area.dock_list()) diff --git a/tests/unit_tests/test_generate_cli_client.py b/tests/unit_tests/test_generate_cli_client.py index a5ace4a2..c2ee660e 100644 --- a/tests/unit_tests/test_generate_cli_client.py +++ b/tests/unit_tests/test_generate_cli_client.py @@ -5,7 +5,8 @@ import black import isort import pytest -from bec_widgets.utils.generate_cli import ClientGenerator +from bec_widgets.utils.generate_cli import ClientGenerator, write_designer_plugins +from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo # pylint: disable=missing-function-docstring @@ -59,6 +60,10 @@ class MockViewWithContent: """Activate view.""" +class MockDesignerWidget: + pass + + def test_client_generator_with_black_formatting(): generator = ClientGenerator(base=True) container = BECClassContainer() @@ -285,3 +290,17 @@ c = a + b""" content = file.read() assert corrected in content + + +def test_write_designer_plugins(tmp_path): + file_name = tmp_path / "designer_plugins.py" + + write_designer_plugins([DesignerPluginInfo(MockDesignerWidget)], str(file_name)) + + with open(file_name, "r", encoding="utf-8") as file: + content = file.read() + + assert '"MockDesignerWidget":' in content + assert '"tests.unit_tests.test_generate_cli_client"' in content + assert '"MockDesignerWidget"' in content + assert "MockDesignerWidgetPlugin" not in content diff --git a/tests/unit_tests/test_rpc_widget_handler.py b/tests/unit_tests/test_rpc_widget_handler.py index a8a2c210..d926dc9b 100644 --- a/tests/unit_tests/test_rpc_widget_handler.py +++ b/tests/unit_tests/test_rpc_widget_handler.py @@ -1,7 +1,6 @@ from unittest.mock import patch -from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo +from bec_widgets.utils import plugin_utils from bec_widgets.utils.rpc_widget_handler import RPCWidgetHandler @@ -10,21 +9,22 @@ def test_rpc_widget_handler(): assert "Image" in handler.widget_classes assert "RingProgressBar" in handler.widget_classes assert "BECDockArea" in handler.widget_classes - - -class _TestPluginWidget(BECWidget): ... + assert isinstance(handler.widget_classes["Image"], tuple) @patch( - "bec_widgets.utils.rpc_widget_handler.get_all_plugin_widgets", - return_value=BECClassContainer( - [ - BECClassInfo(name="DeviceComboBox", obj=_TestPluginWidget, module="", file=""), - BECClassInfo(name="NewPluginWidget", obj=_TestPluginWidget, module="", file=""), - ] - ), + "bec_widgets.utils.bec_plugin_helper.get_plugin_rpc_widget_registry", + return_value={ + "Image": ("plugin.module", "PluginImage"), + "NewPluginWidget": ("plugin.module", "NewPluginWidget"), + }, ) def test_duplicate_plugins_not_allowed(_): - handler = RPCWidgetHandler() - assert handler.widget_classes["DeviceComboBox"] is not _TestPluginWidget - assert handler.widget_classes["NewPluginWidget"] is _TestPluginWidget + plugin_utils.rpc_widget_registry.cache_clear() + + try: + handler = RPCWidgetHandler() + assert handler.widget_classes["Image"] != ("plugin.module", "PluginImage") + assert handler.widget_classes["NewPluginWidget"] == ("plugin.module", "NewPluginWidget") + finally: + plugin_utils.rpc_widget_registry.cache_clear()