mirror of
https://github.com/bec-project/bec_widgets.git
synced 2025-07-13 19:21:50 +02:00
feat: add loader/helper for widget plugins
This commit is contained in:
@ -1 +0,0 @@
|
||||
from .client import *
|
||||
|
@ -3,9 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from typing import Literal, Optional, overload
|
||||
import inspect
|
||||
from typing import Literal, Optional
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
|
||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
# pylint: skip-file
|
||||
|
||||
@ -20,7 +26,6 @@ _Widgets = {
|
||||
"AbortButton": "AbortButton",
|
||||
"BECColorMapWidget": "BECColorMapWidget",
|
||||
"BECDockArea": "BECDockArea",
|
||||
"BECMultiWaveformWidget": "BECMultiWaveformWidget",
|
||||
"BECProgressBar": "BECProgressBar",
|
||||
"BECQueue": "BECQueue",
|
||||
"BECStatusBox": "BECStatusBox",
|
||||
@ -34,6 +39,7 @@ _Widgets = {
|
||||
"LogPanel": "LogPanel",
|
||||
"Minesweeper": "Minesweeper",
|
||||
"MotorMap": "MotorMap",
|
||||
"MultiWaveform": "MultiWaveform",
|
||||
"PositionIndicator": "PositionIndicator",
|
||||
"PositionerBox": "PositionerBox",
|
||||
"PositionerBox2D": "PositionerBox2D",
|
||||
@ -52,7 +58,31 @@ _Widgets = {
|
||||
"Waveform": "Waveform",
|
||||
"WebsiteWidget": "WebsiteWidget",
|
||||
}
|
||||
Widgets = _WidgetsEnumType("Widgets", _Widgets)
|
||||
|
||||
|
||||
_plugin_widgets = get_all_plugin_widgets()
|
||||
plugin_client = get_plugin_client_module()
|
||||
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
|
||||
|
||||
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
|
||||
for _widget in _overlap:
|
||||
logger.warning(
|
||||
f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !"
|
||||
)
|
||||
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
|
||||
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
|
||||
if plugin_name in globals():
|
||||
conflicting_file = (
|
||||
inspect.getfile(_plugin_widgets[plugin_name])
|
||||
if plugin_name in _plugin_widgets
|
||||
else f"{plugin_client}"
|
||||
)
|
||||
logger.warning(
|
||||
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
|
||||
)
|
||||
continue
|
||||
if plugin_name not in _overlap:
|
||||
globals()[plugin_name] = plugin_class
|
||||
|
||||
|
||||
class AbortButton(RPCBase):
|
||||
|
@ -533,7 +533,12 @@ class BECGuiClient(RPCBase):
|
||||
"""
|
||||
name = state["name"]
|
||||
gui_id = state["gui_id"]
|
||||
widget_class = getattr(client, state["widget_class"])
|
||||
try:
|
||||
widget_class = getattr(client, state["widget_class"])
|
||||
except AttributeError as e:
|
||||
raise AttributeError(
|
||||
f"Failed to find user widget {state['widget_class']} in the client - did you run bw-generate-cli to generate the plugin files?"
|
||||
) from e
|
||||
obj = self._ipython_registry.get(gui_id)
|
||||
if obj is None:
|
||||
widget = widget_class(gui_id=gui_id, name=name, parent=parent)
|
||||
|
@ -34,13 +34,25 @@ else:
|
||||
|
||||
|
||||
class ClientGenerator:
|
||||
def __init__(self):
|
||||
self.header = """# This file was automatically generated by generate_cli.py\n
|
||||
def __init__(self, base=False):
|
||||
self._base = base
|
||||
base_imports = (
|
||||
"""import enum
|
||||
import inspect
|
||||
from typing import Literal, Optional
|
||||
"""
|
||||
if self._base
|
||||
else "\n"
|
||||
)
|
||||
self.header = f"""# This file was automatically generated by generate_cli.py\n
|
||||
from __future__ import annotations
|
||||
import enum
|
||||
from typing import Literal, Optional, overload
|
||||
{base_imports}
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
|
||||
{"from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module" if self._base else ""}
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
# pylint: skip-file"""
|
||||
|
||||
@ -67,6 +79,7 @@ from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
|
||||
|
||||
self.write_client_enum(rpc_top_level_classes)
|
||||
for cls in connector_classes:
|
||||
logger.debug(f"generating RPC client class for {cls.__name__}")
|
||||
self.content += "\n\n"
|
||||
self.generate_content_for_class(cls)
|
||||
|
||||
@ -74,10 +87,14 @@ from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
|
||||
"""
|
||||
Write the client enum to the content.
|
||||
"""
|
||||
self.content += """
|
||||
if self._base:
|
||||
self.content += """
|
||||
class _WidgetsEnumType(str, enum.Enum):
|
||||
\"\"\" Enum for the available widgets, to be generated programatically \"\"\"
|
||||
...
|
||||
"""
|
||||
|
||||
self.content += """
|
||||
|
||||
_Widgets = {
|
||||
"""
|
||||
@ -85,8 +102,33 @@ _Widgets = {
|
||||
self.content += f'"{cls.__name__}": "{cls.__name__}",\n '
|
||||
|
||||
self.content += """}
|
||||
Widgets = _WidgetsEnumType("Widgets", _Widgets)
|
||||
"""
|
||||
"""
|
||||
if self._base:
|
||||
self.content += """
|
||||
|
||||
_plugin_widgets = get_all_plugin_widgets()
|
||||
plugin_client = get_plugin_client_module()
|
||||
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
|
||||
|
||||
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
|
||||
for _widget in _overlap:
|
||||
logger.warning(f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !")
|
||||
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
|
||||
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
|
||||
if plugin_name in globals():
|
||||
conflicting_file = (
|
||||
inspect.getfile(_plugin_widgets[plugin_name])
|
||||
if plugin_name in _plugin_widgets
|
||||
else f"{plugin_client}"
|
||||
)
|
||||
logger.warning(
|
||||
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
|
||||
)
|
||||
continue
|
||||
if plugin_name not in _overlap:
|
||||
globals()[plugin_name] = plugin_class
|
||||
|
||||
"""
|
||||
|
||||
def generate_content_for_class(self, cls):
|
||||
"""
|
||||
@ -199,38 +241,59 @@ def main():
|
||||
|
||||
parser = argparse.ArgumentParser(description="Auto-generate the client for RPC widgets")
|
||||
parser.add_argument(
|
||||
"--module-name",
|
||||
"--target",
|
||||
action="store",
|
||||
type=str,
|
||||
default="bec_widgets",
|
||||
help="Which module to generate plugin files for (default: bec_widgets, example: my_plugin_repo.bec_widgets)",
|
||||
help="Which package to generate plugin files for. Should be installed in the local environment (example: my_plugin_repo)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.target is None:
|
||||
logger.error(
|
||||
"You must provide a target - for safety, the default of running this on bec_widgets core has been removed. To generate the client for bec_widgets, run `bw-generate-cli --target bec_widgets`"
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"BEC Widget code generation tool started with args: {args}")
|
||||
|
||||
client_subdir = "cli" if args.target == "bec_widgets" else "widgets"
|
||||
module_name = "bec_widgets" if args.target == "bec_widgets" else f"{args.target}.bec_widgets"
|
||||
|
||||
try:
|
||||
module = importlib.import_module(args.module_name)
|
||||
module = importlib.import_module(module_name)
|
||||
assert module.__file__ is not None
|
||||
module_file = Path(module.__file__)
|
||||
module_dir = module_file.parent if module_file.is_file() else module_file
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load module {args.module_name} for code generation: {e}")
|
||||
logger.error(f"Failed to load module {module_name} for code generation: {e}")
|
||||
return
|
||||
|
||||
client_path = module_dir / "client.py"
|
||||
client_path = module_dir / client_subdir / "client.py"
|
||||
|
||||
rpc_classes = get_custom_classes(args.module_name)
|
||||
rpc_classes = get_custom_classes(module_name)
|
||||
logger.info(f"Obtained classes with RPC objects: {rpc_classes!r}")
|
||||
|
||||
generator = ClientGenerator(base=args.module_name == "bec_widgets")
|
||||
logger.info(f"Generating client.py")
|
||||
generator = ClientGenerator(base=module_name == "bec_widgets")
|
||||
logger.info(f"Generating client file at {client_path}")
|
||||
generator.generate_client(rpc_classes)
|
||||
generator.write(str(client_path))
|
||||
|
||||
if module_name != "bec_widgets":
|
||||
non_overwrite_classes = list(clsinfo.name for clsinfo in get_custom_classes("bec_widgets"))
|
||||
logger.info(
|
||||
f"Not writing plugins which would conflict with builtin classes: {non_overwrite_classes}"
|
||||
)
|
||||
else:
|
||||
non_overwrite_classes = []
|
||||
|
||||
for cls in rpc_classes.plugins:
|
||||
logger.info(f"Writing plugins for: {cls}")
|
||||
logger.info(f"Writing bec-designer plugin files for {cls.__name__}...")
|
||||
|
||||
if cls.__name__ in non_overwrite_classes:
|
||||
logger.error(
|
||||
f"Not writing plugin files for {cls.__name__} because a built-in widget with that name exists"
|
||||
)
|
||||
|
||||
plugin = DesignerPluginGenerator(cls)
|
||||
if not hasattr(plugin, "info"):
|
||||
continue
|
||||
@ -239,7 +302,9 @@ def main():
|
||||
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)):
|
||||
logger.debug(f"Skipping {plugin.info.plugin_name_snake} - a file already exists.")
|
||||
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()
|
||||
|
@ -10,14 +10,14 @@ from bec_lib.client import BECClient
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib import messages
|
||||
from bec_lib.connector import MessageObject
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
else:
|
||||
client = lazy_import("bec_widgets.cli.client") # avoid circular import
|
||||
messages = lazy_import("bec_lib.messages")
|
||||
# from bec_lib.connector import MessageObject
|
||||
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
|
||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.plugin_utils import get_custom_classes
|
||||
|
||||
|
||||
class RPCWidgetHandler:
|
||||
@ -31,10 +31,8 @@ class RPCWidgetHandler:
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
from bec_widgets.utils.plugin_utils import get_custom_classes
|
||||
|
||||
clss = get_custom_classes("bec_widgets")
|
||||
self._widget_classes = {
|
||||
self._widget_classes = get_all_plugin_widgets() | {
|
||||
cls.__name__: cls for cls in clss.widgets if cls.__name__ not in IGNORE_WIDGETS
|
||||
}
|
||||
|
||||
|
@ -4,6 +4,7 @@ import functools
|
||||
import json
|
||||
import signal
|
||||
import sys
|
||||
import traceback
|
||||
import types
|
||||
from contextlib import contextmanager, redirect_stderr, redirect_stdout
|
||||
from typing import Union
|
||||
@ -95,7 +96,7 @@ class BECWidgetsCLIServer:
|
||||
kwargs = msg["parameter"].get("kwargs", {})
|
||||
res = self.run_rpc(obj, method, args, kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"Error while executing RPC instruction: {e}")
|
||||
logger.error(f"Error while executing RPC instruction: {traceback.format_exc()}")
|
||||
self.send_response(request_id, False, {"error": str(e)})
|
||||
else:
|
||||
logger.debug(f"RPC instruction executed successfully: {res}")
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -10,6 +10,8 @@ from bec_qthemes import material_icon
|
||||
from qtpy import PYSIDE6
|
||||
from qtpy.QtGui import QIcon
|
||||
|
||||
from bec_widgets.utils.bec_plugin_helper import user_widget_plugin
|
||||
|
||||
if PYSIDE6:
|
||||
from PySide6.scripts.pyside_tool import (
|
||||
_extend_path_var,
|
||||
@ -150,7 +152,12 @@ def main(): # pragma: no cover
|
||||
print("PYSIDE6 is not available in the environment. Exiting...")
|
||||
return
|
||||
base_dir = Path(os.path.dirname(bec_widgets.__file__)).resolve()
|
||||
|
||||
plugin_paths = find_plugin_paths(base_dir)
|
||||
if (plugin_repo := user_widget_plugin()) and isinstance(plugin_repo.__file__, str):
|
||||
plugin_repo_dir = Path(os.path.dirname(plugin_repo.__file__)).resolve()
|
||||
plugin_paths.extend(find_plugin_paths(plugin_repo_dir))
|
||||
|
||||
set_plugin_environment_variable(plugin_paths)
|
||||
|
||||
patch_designer()
|
||||
|
89
bec_widgets/utils/bec_plugin_helper.py
Normal file
89
bec_widgets/utils/bec_plugin_helper.py
Normal file
@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
import inspect
|
||||
import pkgutil
|
||||
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
|
||||
|
||||
|
||||
def _submodule_specs(module: ModuleType) -> tuple[ModuleSpec | None, ...]:
|
||||
"""Return specs for all submodules of the given module."""
|
||||
return tuple(
|
||||
module_info.module_finder.find_spec(module_info.name)
|
||||
for module_info in pkgutil.iter_modules(module.__path__)
|
||||
if isinstance(module_info.module_finder, FileFinder)
|
||||
)
|
||||
|
||||
|
||||
def _loaded_submodules_from_specs(
|
||||
submodule_specs: tuple[ModuleSpec | None, ...]
|
||||
) -> Generator[ModuleType, None, None]:
|
||||
"""Load all submodules from the given specs."""
|
||||
for submodule in (
|
||||
importlib_util.module_from_spec(spec) for spec in submodule_specs if spec is not None
|
||||
):
|
||||
assert isinstance(
|
||||
submodule.__loader__, SourceFileLoader
|
||||
), "Module found from FileFinder should have SourceFileLoader!"
|
||||
submodule.__loader__.exec_module(submodule)
|
||||
yield submodule
|
||||
|
||||
|
||||
def _submodule_by_name(module: ModuleType, name: str):
|
||||
for submod in _loaded_submodules_from_specs(_submodule_specs(module)):
|
||||
if submod.__name__ == name:
|
||||
return submod
|
||||
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."""
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _all_widgets_from_all_submods(module):
|
||||
"""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))
|
||||
return widgets
|
||||
|
||||
|
||||
def user_widget_plugin() -> ModuleType | None:
|
||||
plugins = importlib.metadata.entry_points(group="bec.widgets.user_widgets") # type: ignore
|
||||
return None if len(plugins) == 0 else tuple(plugins)[0].load()
|
||||
|
||||
|
||||
def get_plugin_client_module() -> ModuleType | None:
|
||||
"""If there is a plugin repository installed, return the client module."""
|
||||
return _submodule_by_name(plugin, "client") if (plugin := user_widget_plugin()) else None
|
||||
|
||||
|
||||
def get_all_plugin_widgets() -> dict[str, "type[BECWidget]"]:
|
||||
"""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 {}
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# print(get_all_plugin_widgets())
|
||||
client = get_plugin_client_module()
|
||||
...
|
@ -63,6 +63,9 @@ class BECClassContainer:
|
||||
def __repr__(self):
|
||||
return str(list(cl.name for cl in self.collection))
|
||||
|
||||
def __iter__(self):
|
||||
return self._collection.__iter__()
|
||||
|
||||
def add_class(self, class_info: BECClassInfo):
|
||||
"""
|
||||
Add a class to the collection.
|
||||
|
@ -265,8 +265,9 @@ class BECDock(BECWidget, Dock):
|
||||
"""
|
||||
return list(widget_handler.widget_classes.keys())
|
||||
|
||||
def _get_list_of_widget_name_of_parent_dock_area(self):
|
||||
docks = self.parent_dock_area.panel_list
|
||||
def _get_list_of_widget_name_of_parent_dock_area(self) -> list[str]:
|
||||
if (docks := self.parent_dock_area.panel_list) is None:
|
||||
return []
|
||||
widgets = []
|
||||
for dock in docks:
|
||||
widgets.extend(dock.elements.keys())
|
||||
|
@ -103,8 +103,8 @@ class BecLogsQueue:
|
||||
self._display_queue.append(self._line_formatter(_msg))
|
||||
if self._new_message_signal:
|
||||
self._new_message_signal.emit()
|
||||
except Exception:
|
||||
logger.warning("Error in LogPanel incoming message callback!")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error in LogPanel incoming message callback: {e}")
|
||||
|
||||
def _set_formatter_and_update_filter(self, line_formatter: LineFormatter = noop_format):
|
||||
self._line_formatter: LineFormatter = line_formatter
|
||||
|
@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from bec_widgets.cli import Image, MotorMap, Waveform
|
||||
from bec_widgets.cli.client import Image, MotorMap, Waveform
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCReference
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
61
tests/unit_tests/test_bec_plugin_helper.py
Normal file
61
tests/unit_tests/test_bec_plugin_helper.py
Normal file
@ -0,0 +1,61 @@
|
||||
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
|
||||
|
||||
|
||||
def test_all_widgets_from_module_no_submodules():
|
||||
"""
|
||||
Test _all_widgets_from_all_submodules with a module that has no submodules.
|
||||
"""
|
||||
module = mock.MagicMock(spec=ModuleType)
|
||||
|
||||
with mock.patch(
|
||||
"bec_widgets.utils.bec_plugin_helper._get_widgets_from_module",
|
||||
return_value={"TestWidget": BECWidget},
|
||||
):
|
||||
widgets = _all_widgets_from_all_submods(module)
|
||||
|
||||
assert widgets == {"TestWidget": BECWidget}
|
||||
|
||||
|
||||
def test_all_widgets_from_module_with_submodules():
|
||||
"""
|
||||
Test _all_widgets_from_all_submodules with a module that has submodules.
|
||||
"""
|
||||
module = mock.MagicMock()
|
||||
module.__path__ = ["path/to/module"]
|
||||
|
||||
submodule = mock.MagicMock()
|
||||
submodule.__loader__ = mock.MagicMock(spec=SourceFileLoader)
|
||||
|
||||
finder_mock = mock.MagicMock(spec=FileFinder, return_value=True)
|
||||
with (
|
||||
mock.patch(
|
||||
"pkgutil.iter_modules",
|
||||
return_value=[mock.MagicMock(module_finder=finder_mock, name="submodule")],
|
||||
),
|
||||
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}],
|
||||
),
|
||||
):
|
||||
widgets = _all_widgets_from_all_submods(module)
|
||||
|
||||
assert widgets == {"TestWidget": BECWidget, "SubWidget": BECWidget}
|
||||
|
||||
|
||||
def test_all_widgets_from_module_no_widgets():
|
||||
"""
|
||||
Test _all_widgets_from_all_submodules with a module that has no widgets.
|
||||
"""
|
||||
module = mock.MagicMock()
|
||||
|
||||
with mock.patch(
|
||||
"bec_widgets.utils.bec_plugin_helper._get_widgets_from_module", return_value={}
|
||||
):
|
||||
widgets = _all_widgets_from_all_submods(module)
|
||||
|
||||
assert widgets == {}
|
60
tests/unit_tests/test_client_plugin_widgets.py
Normal file
60
tests/unit_tests/test_client_plugin_widgets.py
Normal file
@ -0,0 +1,60 @@
|
||||
import enum
|
||||
import inspect
|
||||
import sys
|
||||
from importlib import reload
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
from bec_widgets.cli import client
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase
|
||||
|
||||
|
||||
class _TestGlobalPlugin(RPCBase): ...
|
||||
|
||||
|
||||
mock_client_module_globals = SimpleNamespace()
|
||||
_TestGlobalPlugin.__name__ = "Widgets"
|
||||
mock_client_module_globals.Widgets = _TestGlobalPlugin
|
||||
|
||||
|
||||
@patch("bec_lib.logger.bec_logger")
|
||||
@patch(
|
||||
"bec_widgets.utils.bec_plugin_helper.get_plugin_client_module",
|
||||
lambda: mock_client_module_globals,
|
||||
)
|
||||
def test_plugins_dont_clobber_client_globals(bec_logger: MagicMock):
|
||||
reload(client)
|
||||
bec_logger.logger.warning.assert_called_with(
|
||||
"Plugin widget Widgets from namespace(Widgets=<class 'tests.unit_tests.test_client_plugin_widgets._TestGlobalPlugin'>) conflicts with a built-in class!"
|
||||
)
|
||||
if sys.version_info >= (3, 11): # No EnumType in python3.10
|
||||
assert isinstance(client.Widgets, enum.EnumType)
|
||||
|
||||
|
||||
class _TestDuplicatePlugin(RPCBase): ...
|
||||
|
||||
|
||||
mock_client_module_duplicate = SimpleNamespace()
|
||||
_TestDuplicatePlugin.__name__ = "DeviceComboBox"
|
||||
|
||||
mock_client_module_duplicate.DeviceComboBox = _TestDuplicatePlugin
|
||||
|
||||
|
||||
@patch("bec_lib.logger.bec_logger")
|
||||
@patch(
|
||||
"bec_widgets.utils.bec_plugin_helper.get_plugin_client_module",
|
||||
lambda: mock_client_module_duplicate,
|
||||
)
|
||||
@patch(
|
||||
"bec_widgets.utils.bec_plugin_helper.get_all_plugin_widgets",
|
||||
return_value={"DeviceComboBox": _TestDuplicatePlugin},
|
||||
)
|
||||
def test_duplicate_plugins_not_allowed(_, bec_logger: MagicMock):
|
||||
reload(client)
|
||||
assert (
|
||||
call(
|
||||
f"Detected duplicate widget DeviceComboBox in plugin repo file: {inspect.getfile(_TestDuplicatePlugin)} !"
|
||||
)
|
||||
in bec_logger.logger.warning.mock_calls
|
||||
)
|
||||
assert client.BECDock is not _TestDuplicatePlugin
|
@ -1,98 +0,0 @@
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from bec_widgets.cli.generate_cli import BECClassContainer, ClientGenerator
|
||||
|
||||
|
||||
def test_client_generator_init():
|
||||
"""
|
||||
Test the initialization of the ClientGenerator class.
|
||||
"""
|
||||
generator = ClientGenerator()
|
||||
assert generator.header.startswith("# This file was automatically generated by generate_cli.py")
|
||||
assert generator.content == ""
|
||||
|
||||
|
||||
def test_generate_client():
|
||||
"""
|
||||
Test the generate_client method of the ClientGenerator class.
|
||||
"""
|
||||
generator = ClientGenerator()
|
||||
class_container = mock.MagicMock(spec=BECClassContainer)
|
||||
class_container.rpc_top_level_classes = [mock.MagicMock(RPC=True, __name__="TestClass1")]
|
||||
class_container.connector_classes = [mock.MagicMock(RPC=True, __name__="TestClass2")]
|
||||
|
||||
generator.generate_client(class_container)
|
||||
|
||||
assert '"TestClass1": "TestClass1"' in generator.content
|
||||
assert "class TestClass2(RPCBase):" in generator.content
|
||||
|
||||
|
||||
@pytest.mark.parametrize("plugin", (True, False))
|
||||
def test_write_client_enum(plugin):
|
||||
"""
|
||||
Test the write_client_enum method of the ClientGenerator class.
|
||||
"""
|
||||
generator = ClientGenerator(base=plugin)
|
||||
published_classes = [
|
||||
mock.MagicMock(__name__="TestClass1"),
|
||||
mock.MagicMock(__name__="TestClass2"),
|
||||
]
|
||||
|
||||
generator.write_client_enum(published_classes)
|
||||
|
||||
assert ("class _WidgetsEnumType(str, enum.Enum):" in generator.content) is plugin
|
||||
assert '"TestClass1": "TestClass1",' in generator.content
|
||||
assert '"TestClass2": "TestClass2",' in generator.content
|
||||
|
||||
|
||||
def test_generate_content_for_class():
|
||||
"""
|
||||
Test the generate_content_for_class method of the ClientGenerator class.
|
||||
"""
|
||||
generator = ClientGenerator()
|
||||
cls = mock.MagicMock(__name__="TestClass", USER_ACCESS=["method1"])
|
||||
method = mock.MagicMock()
|
||||
method.__name__ = "method1"
|
||||
method.__doc__ = "Test method"
|
||||
method_signature = "(self)"
|
||||
cls.method1 = method
|
||||
|
||||
with mock.patch("inspect.signature", return_value=method_signature):
|
||||
generator.generate_content_for_class(cls)
|
||||
|
||||
assert "class TestClass(RPCBase):" in generator.content
|
||||
assert "def method1(self):" in generator.content
|
||||
assert "Test method" in generator.content
|
||||
|
||||
|
||||
def test_write_is_black_formatted(tmp_path):
|
||||
"""
|
||||
Test the write method of the ClientGenerator class.
|
||||
"""
|
||||
generator = ClientGenerator()
|
||||
generator.content = """
|
||||
def test_content():
|
||||
pass
|
||||
|
||||
a=1
|
||||
b=2
|
||||
c=a+b
|
||||
"""
|
||||
|
||||
corrected = """def test_content():
|
||||
pass
|
||||
|
||||
|
||||
a = 1
|
||||
b = 2
|
||||
c = a + b"""
|
||||
file_name = tmp_path / "test_client.py"
|
||||
|
||||
generator.write(str(file_name))
|
||||
|
||||
with open(file_name, "r", encoding="utf-8") as file:
|
||||
content = file.read()
|
||||
|
||||
assert corrected in content
|
@ -1,7 +1,9 @@
|
||||
from textwrap import dedent
|
||||
from unittest import mock
|
||||
|
||||
import black
|
||||
import isort
|
||||
import pytest
|
||||
|
||||
from bec_widgets.cli.generate_cli import ClientGenerator
|
||||
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
|
||||
@ -33,7 +35,7 @@ class MockBECFigure:
|
||||
|
||||
|
||||
def test_client_generator_with_black_formatting():
|
||||
generator = ClientGenerator()
|
||||
generator = ClientGenerator(base=True)
|
||||
container = BECClassContainer()
|
||||
container.add_class(
|
||||
BECClassInfo(
|
||||
@ -68,20 +70,51 @@ def test_client_generator_with_black_formatting():
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from typing import Literal, Optional, overload
|
||||
import inspect
|
||||
from typing import Literal, Optional
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
|
||||
from bec_widgets.utils.bec_plugin_helper import (get_all_plugin_widgets,
|
||||
get_plugin_client_module)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
# pylint: skip-file
|
||||
|
||||
|
||||
class Widgets(str, enum.Enum):
|
||||
"""
|
||||
Enum for the available widgets.
|
||||
"""
|
||||
class _WidgetsEnumType(str, enum.Enum):
|
||||
"""Enum for the available widgets, to be generated programatically"""
|
||||
|
||||
MockBECFigure = "MockBECFigure"
|
||||
...
|
||||
|
||||
_Widgets = {
|
||||
"MockBECFigure": "MockBECFigure",
|
||||
}
|
||||
|
||||
|
||||
_plugin_widgets = get_all_plugin_widgets()
|
||||
plugin_client = get_plugin_client_module()
|
||||
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
|
||||
|
||||
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
|
||||
for _widget in _overlap:
|
||||
logger.warning(f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !")
|
||||
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
|
||||
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
|
||||
if plugin_name in globals():
|
||||
conflicting_file = (
|
||||
inspect.getfile(_plugin_widgets[plugin_name])
|
||||
if plugin_name in _plugin_widgets
|
||||
else f"{plugin_client}"
|
||||
)
|
||||
logger.warning(
|
||||
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
|
||||
)
|
||||
continue
|
||||
if plugin_name not in _overlap:
|
||||
globals()[plugin_name] = plugin_class
|
||||
|
||||
class MockBECFigure(RPCBase):
|
||||
@rpc_call
|
||||
@ -121,5 +154,99 @@ def test_client_generator_with_black_formatting():
|
||||
)
|
||||
|
||||
generated_output_formatted = isort.code(generated_output_formatted)
|
||||
expected_output_formatted = isort.code(expected_output_formatted)
|
||||
|
||||
assert expected_output_formatted == generated_output_formatted
|
||||
|
||||
|
||||
def test_client_generator_init():
|
||||
"""
|
||||
Test the initialization of the ClientGenerator class.
|
||||
"""
|
||||
generator = ClientGenerator()
|
||||
assert generator.header.startswith("# This file was automatically generated by generate_cli.py")
|
||||
assert generator.content == ""
|
||||
|
||||
|
||||
def test_generate_client():
|
||||
"""
|
||||
Test the generate_client method of the ClientGenerator class.
|
||||
"""
|
||||
generator = ClientGenerator()
|
||||
class_container = mock.MagicMock(spec=BECClassContainer)
|
||||
class_container.rpc_top_level_classes = [mock.MagicMock(RPC=True, __name__="TestClass1")]
|
||||
class_container.connector_classes = [mock.MagicMock(RPC=True, __name__="TestClass2")]
|
||||
|
||||
generator.generate_client(class_container)
|
||||
|
||||
assert '"TestClass1": "TestClass1"' in generator.content
|
||||
assert "class TestClass2(RPCBase):" in generator.content
|
||||
|
||||
|
||||
@pytest.mark.parametrize("plugin", (True, False))
|
||||
def test_write_client_enum(plugin):
|
||||
"""
|
||||
Test the write_client_enum method of the ClientGenerator class.
|
||||
"""
|
||||
generator = ClientGenerator(base=plugin)
|
||||
published_classes = [
|
||||
mock.MagicMock(__name__="TestClass1"),
|
||||
mock.MagicMock(__name__="TestClass2"),
|
||||
]
|
||||
|
||||
generator.write_client_enum(published_classes)
|
||||
|
||||
assert ("class _WidgetsEnumType(str, enum.Enum):" in generator.content) is plugin
|
||||
assert '"TestClass1": "TestClass1",' in generator.content
|
||||
assert '"TestClass2": "TestClass2",' in generator.content
|
||||
|
||||
|
||||
def test_generate_content_for_class():
|
||||
"""
|
||||
Test the generate_content_for_class method of the ClientGenerator class.
|
||||
"""
|
||||
generator = ClientGenerator()
|
||||
cls = mock.MagicMock(__name__="TestClass", USER_ACCESS=["method1"])
|
||||
method = mock.MagicMock()
|
||||
method.__name__ = "method1"
|
||||
method.__doc__ = "Test method"
|
||||
method_signature = "(self)"
|
||||
cls.method1 = method
|
||||
|
||||
with mock.patch("inspect.signature", return_value=method_signature):
|
||||
generator.generate_content_for_class(cls)
|
||||
|
||||
assert "class TestClass(RPCBase):" in generator.content
|
||||
assert "def method1(self):" in generator.content
|
||||
assert "Test method" in generator.content
|
||||
|
||||
|
||||
def test_write_is_black_formatted(tmp_path):
|
||||
"""
|
||||
Test the write method of the ClientGenerator class.
|
||||
"""
|
||||
generator = ClientGenerator()
|
||||
generator.content = """
|
||||
def test_content():
|
||||
pass
|
||||
|
||||
a=1
|
||||
b=2
|
||||
c=a+b
|
||||
"""
|
||||
|
||||
corrected = """def test_content():
|
||||
pass
|
||||
|
||||
|
||||
a = 1
|
||||
b = 2
|
||||
c = a + b"""
|
||||
file_name = tmp_path / "test_client.py"
|
||||
|
||||
generator.write(str(file_name))
|
||||
|
||||
with open(file_name, "r", encoding="utf-8") as file:
|
||||
content = file.read()
|
||||
|
||||
assert corrected in content
|
||||
|
@ -1,7 +1,29 @@
|
||||
import enum
|
||||
from importlib import reload
|
||||
from types import SimpleNamespace
|
||||
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.cli.rpc.rpc_widget_handler import RPCWidgetHandler
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.containers.dock.dock import BECDock
|
||||
|
||||
|
||||
def test_rpc_widget_handler():
|
||||
handler = RPCWidgetHandler()
|
||||
assert "Image" in handler.widget_classes
|
||||
assert "RingProgressBar" in handler.widget_classes
|
||||
|
||||
|
||||
class _TestPluginWidget(BECWidget): ...
|
||||
|
||||
|
||||
@patch(
|
||||
"bec_widgets.cli.rpc.rpc_widget_handler.get_all_plugin_widgets",
|
||||
return_value={"DeviceComboBox": _TestPluginWidget, "NewPluginWidget": _TestPluginWidget},
|
||||
)
|
||||
def test_duplicate_plugins_not_allowed(_):
|
||||
handler = RPCWidgetHandler()
|
||||
assert handler.widget_classes["DeviceComboBox"] is not _TestPluginWidget
|
||||
assert handler.widget_classes["NewPluginWidget"] is _TestPluginWidget
|
||||
|
Reference in New Issue
Block a user