0
0
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:
2025-03-13 10:31:25 +01:00
parent b4925918f7
commit ca2bb4f9b4
19 changed files with 512 additions and 3671 deletions

View File

@ -1 +0,0 @@
from .client import *

View File

@ -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):

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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()

View 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()
...

View File

@ -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.

View File

@ -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())

View File

@ -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

View File

@ -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

View 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 == {}

View 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

View File

@ -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

View File

@ -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

View File

@ -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