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