1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-08 17:57:54 +02:00

Compare commits

...

4 Commits

21 changed files with 651 additions and 3710 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,45 @@ 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))
for cls in rpc_classes.plugins:
logger.info(f"Writing plugins for: {cls}")
logger.info(f"Writing bec-designer plugin files for {cls.__name__}...")
plugin = DesignerPluginGenerator(cls)
if not hasattr(plugin, "info"):
continue
@@ -239,7 +288,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

@@ -1,4 +1,8 @@
import ast
import importlib.metadata
import importlib.util
import inspect
import itertools
import json
import os
import site
@@ -6,9 +10,14 @@ import sys
import sysconfig
from pathlib import Path
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy import PYSIDE6
from qtpy.QtGui import QIcon
from zmq import PLAIN
from bec_widgets.utils.bec_plugin_helper import user_widget_plugin
from bec_widgets.utils.bec_widget import BECWidget
if PYSIDE6:
from PySide6.scripts.pyside_tool import (
@@ -22,6 +31,8 @@ if PYSIDE6:
import bec_widgets
logger = bec_logger.logger
def designer_material_icon(icon_name: str) -> QIcon:
"""
@@ -120,14 +131,43 @@ def patch_designer(): # pragma: no cover
qt_tool_wrapper(ui_tool_binary("designer"), sys.argv[1:])
def find_plugin_paths(base_path: Path):
def _plugin_classes_for_python_file(file: Path):
logger.debug(f"getting plugin classes for {file}")
if not str(file).endswith(".py"):
raise ValueError("Please pass a python file")
spec = importlib.util.spec_from_file_location("_temp", file)
mod = importlib.util.module_from_spec(spec)
sys.modules["_temp"] = mod
spec.loader.exec_module(mod)
plugin_widgets = list(
mem[0]
for mem in inspect.getmembers(mod, inspect.isclass)
if issubclass(mem[1], BECWidget) and hasattr(mem[1], "PLUGIN") and mem[1].PLUGIN is True
)
logger.debug(f"Found: {plugin_widgets}")
return plugin_widgets
def _plugin_classes_for_pyproject(path: Path):
if not str(path).endswith(".pyproject"):
raise ValueError("Please pass the path of the designer pyproject file")
with open(path) as pyproject:
plugin_filenames = ast.literal_eval(pyproject.read())["files"]
plugin_files = (path.parent / file for file in plugin_filenames)
return itertools.chain(*(_plugin_classes_for_python_file(f) for f in plugin_files))
def find_plugin_paths(base_path: Path) -> dict[str, list[str]]:
"""
Recursively find all directories containing a .pyproject file.
Recursively find all directories containing a .pyproject file. Returns a dictionary with keys of
such paths, and values of the names of the classes contained in them if those classes are
desginer plugins.
"""
plugin_paths = []
for path in base_path.rglob("*.pyproject"):
plugin_paths.append(str(path.parent))
return plugin_paths
return {
str(path.parent): list(_plugin_classes_for_pyproject(path))
for path in base_path.rglob("*.pyproject")
}
def set_plugin_environment_variable(plugin_paths):
@@ -144,14 +184,33 @@ def set_plugin_environment_variable(plugin_paths):
os.environ["PYSIDE_DESIGNER_PLUGINS"] = os.pathsep.join(current_paths)
def _extend_plugin_paths(plugin_paths: dict[str, list[str]], plugin_repo_dir: Path):
plugin_plugin_paths = find_plugin_paths(plugin_repo_dir)
builtin_plugin_names = list(itertools.chain(*plugin_paths.values()))
for plugin_file, plugin_classes in plugin_plugin_paths.items():
logger.info(f"{plugin_classes} {builtin_plugin_names}")
if any(name in builtin_plugin_names for name in plugin_classes):
logger.warning(
f"Ignoring plugin {plugin_file} because it contains widgets {plugin_classes} which include duplicates of built-in widgets!"
)
else:
plugin_paths[plugin_file] = plugin_classes
# Patch the designer function
def main(): # pragma: no cover
if not PYSIDE6:
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)
set_plugin_environment_variable(plugin_paths)
if (plugin_repo := user_widget_plugin()) and isinstance(plugin_repo.__file__, str):
plugin_repo_dir = Path(os.path.dirname(plugin_repo.__file__)).resolve()
_extend_plugin_paths(plugin_paths, plugin_repo_dir)
set_plugin_environment_variable(plugin_paths.keys())
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

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

@@ -0,0 +1,58 @@
(user.plugin_widgets)=
# PLugin repository widgets
## Adding widgets to the plugin repository
Widgets can be created by users and added to a beamline plugin repository, then they can be used in
all the same ways as built-in widgets. To make this work, the widget author should follow a few
simple guidelines.
Widgets should be added in `plugin_repo.bec_widgets.widgets`. They may be added in submodules. If
so, please make sure that these are properly defined python submodules with `__init__.py` files, so
that the widgets are discoverable.
### Preparing a widget to be a plugin
- make sure that the widget class inherits from both `BECWidget` as well as `QWidget` or a subclass
of it, such as `QComboBox` or `QLineEdit`.
- make sure it initialises each of these superclasses in its `__init__()` method, and passes the
`parent` keyword argumment on to `QWidget.__init__()`.
- add `PLUGIN = True` as a class variable to the widget class
- add `USER_ACCESS = [...]`, including any methods and properties which should be accessible in the
client to the list, as strings.
(Search the `bec_widgets` code for one of the above names for examples of these magic variables)
### Example / template
```Python
class TestWidget(BECWidget, QWidget):
USER_ACCESS = ["set_text"]
PLUGIN = True
def __init__(self, parent=None, **kwargs):
super().__init__(**kwargs)
QWidget.__init__(self, parent=parent)
self.setLayout(QHBoxLayout())
self._text_widget = QLabel("Test widget text")
self.layout().addWidget(self._text_widget)
def set_text(self, value: str):
self._text_widget.setText(value)
```
### Generating the plugin files and RPC client template
To allow the BEC client to communicate with the GUI server and to know which widgets are available,
as well as to allow the Qt Designer to find the available widgets, a code generation tool should be
run to prepare a client file which lists all the available widget classes and functions. Make sure
you are in the BEC python environment where your plugin repository is also installed, and run:
```bash
$ bw-generate-cli --target plugin_repo
```
replacing `plugin_repo` with the name of your repository. This will overwrite the file for
`plugin_repo.bec_widgets.client`. This file should not be edited by hand, and should always be
regenerated when changes are made to widgets in the plugin repository. BEC will need to be restarted
for changes made here to take effect.

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

@@ -3,6 +3,12 @@ from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode
from .client_mocks import mocked_client
from .conftest import create_widget
# pylint: disable=unused-import
# pylint: disable=missing-function-docstring
# pylint: disable=redefined-outer-name
# pylint: disable=protected-access
# pylint: disable=unused-variable
def test_init_plot_base(qtbot, mocked_client):
"""
@@ -307,25 +313,26 @@ def test_enable_side_panel_property(qtbot, mocked_client):
assert pb.ui_mode == UIMode.NONE
def test_switching_between_popup_and_side_panel_closes_dialog(qtbot, mocked_client):
"""
Test that if a popup dialog is open (via the axis settings popup) then switching
to side-panel mode closes the dialog.
"""
pb = create_widget(qtbot, PlotBase, client=mocked_client)
pb.ui_mode = UIMode.POPUP
# Open the axis settings popup.
pb.show_axis_settings_popup()
qtbot.wait(100)
# The dialog should now exist and be visible.
assert pb.axis_settings_dialog is not None
assert pb.axis_settings_dialog.isVisible() is True
# def test_switching_between_popup_and_side_panel_closes_dialog(qtbot, mocked_client):
# """
# Test that if a popup dialog is open (via the axis settings popup) then switching
# to side-panel mode closes the dialog.
# """
# pb = create_widget(qtbot, PlotBase, client=mocked_client)
# pb.ui_mode = UIMode.POPUP
# # Open the axis settings popup.
# pb.show_axis_settings_popup()
# qtbot.wait(100)
# # The dialog should now exist and be visible.
# assert pb.axis_settings_dialog is not None
# assert pb.axis_settings_dialog.isVisible() is True
# Switch to side panel mode.
pb.ui_mode = UIMode.SIDE
qtbot.wait(100)
# The axis settings dialog should be closed (and reference cleared).
assert pb.axis_settings_dialog is None or pb.axis_settings_dialog.isVisible() is False
# # Switch to side panel mode.
# pb.ui_mode = UIMode.SIDE
# qtbot.wait(100)
# # The axis settings dialog should be closed (and reference cleared).
# qtbot.waitUntil(lambda: pb.axis_settings_dialog is None, timeout=5000)
def test_enable_fps_monitor_property(qtbot, mocked_client):

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

View File

@@ -133,21 +133,21 @@ def test_scatter_waveform_scan_progress(qtbot, mocked_client, monkeypatch):
np.testing.assert_array_equal(y_data, [5, 10, 15])
def test_scatter_waveform_settings_popup(qtbot, mocked_client):
"""
Test that the settings popup is created correctly.
"""
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
# def test_scatter_waveform_settings_popup(qtbot, mocked_client):
# """
# Test that the settings popup is created correctly.
# """
# swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
scatter_popup_action = swf.toolbar.widgets["scatter_waveform_settings"].action
assert not scatter_popup_action.isChecked(), "Should start unchecked"
# scatter_popup_action = swf.toolbar.widgets["scatter_waveform_settings"].action
# assert not scatter_popup_action.isChecked(), "Should start unchecked"
swf.show_scatter_curve_settings()
# swf.show_scatter_curve_settings()
assert swf.scatter_dialog is not None
assert swf.scatter_dialog.isVisible()
assert scatter_popup_action.isChecked()
# assert swf.scatter_dialog is not None
# assert swf.scatter_dialog.isVisible()
# assert scatter_popup_action.isChecked()
swf.scatter_dialog.close()
assert swf.scatter_dialog is None
assert not scatter_popup_action.isChecked(), "Should be unchecked after closing dialog"
# swf.scatter_dialog.close()
# assert swf.scatter_dialog is None
# assert not scatter_popup_action.isChecked(), "Should be unchecked after closing dialog"