diff --git a/bec_widgets/applications/bw_launch.py b/bec_widgets/applications/bw_launch.py new file mode 100644 index 00000000..c73bfaf6 --- /dev/null +++ b/bec_widgets/applications/bw_launch.py @@ -0,0 +1,6 @@ +from bec_widgets.widgets.containers.dock.dock_area import BECDockArea + + +def dock_area(name: str | None = None): + _dock_area = BECDockArea(name=name) + return _dock_area diff --git a/bec_widgets/applications/launch_dialog.ui b/bec_widgets/applications/launch_dialog.ui new file mode 100644 index 00000000..586cb400 --- /dev/null +++ b/bec_widgets/applications/launch_dialog.ui @@ -0,0 +1,35 @@ + + + Form + + + + 0 + 0 + 400 + 300 + + + + Form + + + + + + PushButton + + + + + + + PushButton + + + + + + + + diff --git a/bec_widgets/applications/launch_window.py b/bec_widgets/applications/launch_window.py new file mode 100644 index 00000000..5618dde5 --- /dev/null +++ b/bec_widgets/applications/launch_window.py @@ -0,0 +1,171 @@ +import os + +from bec_lib.logger import bec_logger +from qtpy.QtCore import QSize +from qtpy.QtGui import QAction, QActionGroup +from qtpy.QtWidgets import QApplication, QMainWindow, QSizePolicy, QStyle + +import bec_widgets +from bec_widgets.cli.rpc.rpc_register import RPCRegister +from bec_widgets.utils import UILoader +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.colors import apply_theme +from bec_widgets.utils.container_utils import WidgetContainerUtils +from bec_widgets.widgets.containers.dock.dock_area import BECDockArea +from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin + +logger = bec_logger.logger +MODULE_PATH = os.path.dirname(bec_widgets.__file__) + + +class LaunchWindow(BECWidget, QMainWindow): + def __init__(self, gui_id: str = None, *args, **kwargs): + BECWidget.__init__(self, gui_id=gui_id, **kwargs) + QMainWindow.__init__(self, *args, **kwargs) + + self.app = QApplication.instance() + + self.resize(500, 300) + self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + + self._init_ui() + + def _init_ui(self): + # Set the window title + self.setWindowTitle("BEC Launcher") + + # Load ui file + ui_file_path = os.path.join(MODULE_PATH, "applications/launch_dialog.ui") + self.load_ui(ui_file_path) + + # Set Menu and Status bar + self._setup_menu_bar() + + # BEC Specific UI + self._init_bec_specific_ui() + + # TODO can be implemented for toolbar + def load_ui(self, ui_file): + loader = UILoader(self) + self.ui = loader.loader(ui_file) + self.setCentralWidget(self.ui) + self.ui.open_dock_area.setText("Open Dock Area") + self.ui.open_dock_area.clicked.connect(lambda: self.launch("dock_area")) + + def _init_bec_specific_ui(self): + if getattr(self.app, "gui_id", None): + self.statusBar().showMessage(f"App ID: {self.app.gui_id}") + else: + logger.warning( + "Application is not a BECApplication instance. Status bar will not show App ID. Please initialize the application with BECApplication." + ) + + def list_app_hierarchy(self): + """ + List the hierarchy of the application. + """ + self.app.list_hierarchy() + + def _setup_menu_bar(self): + """ + Setup the menu bar for the main window. + """ + menu_bar = self.menuBar() + + ######################################## + # Theme menu + theme_menu = menu_bar.addMenu("Theme") + + theme_group = QActionGroup(self) + light_theme_action = QAction("Light Theme", self, checkable=True) + dark_theme_action = QAction("Dark Theme", self, checkable=True) + theme_group.addAction(light_theme_action) + theme_group.addAction(dark_theme_action) + theme_group.setExclusive(True) + + theme_menu.addAction(light_theme_action) + theme_menu.addAction(dark_theme_action) + + # Connect theme actions + light_theme_action.triggered.connect(lambda: self.change_theme("light")) + dark_theme_action.triggered.connect(lambda: self.change_theme("dark")) + + # Set the default theme + # TODO can be fetched from app + dark_theme_action.setChecked(True) + + ######################################## + # Help menu + help_menu = menu_bar.addMenu("Help") + + help_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxQuestion) + bug_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxInformation) + + bec_docs = QAction("BEC Docs", self) + bec_docs.setIcon(help_icon) + widgets_docs = QAction("BEC Widgets Docs", self) + widgets_docs.setIcon(help_icon) + bug_report = QAction("Bug Report", self) + bug_report.setIcon(bug_icon) + + bec_docs.triggered.connect(BECWebLinksMixin.open_bec_docs) + widgets_docs.triggered.connect(BECWebLinksMixin.open_bec_widgets_docs) + bug_report.triggered.connect(BECWebLinksMixin.open_bec_bug_report) + + help_menu.addAction(bec_docs) + help_menu.addAction(widgets_docs) + help_menu.addAction(bug_report) + + debug_bar = menu_bar.addMenu(f"DEBUG {self.__class__.__name__}") + list_hierarchy = QAction("List App Hierarchy", self) + list_hierarchy.triggered.connect(self.list_app_hierarchy) + debug_bar.addAction(list_hierarchy) + + def change_theme(self, theme): + apply_theme(theme) + + def launch( + self, + launch_script: str, + name: str | None = None, + geometry: tuple[int, int, int, int] | None = None, + ) -> "BECDockArea": + """Create a new dock area. + + Args: + name(str): The name of the dock area. + geometry(tuple): The geometry parameters to be passed to the dock area. + Returns: + BECDockArea: The newly created dock area. + """ + from bec_widgets.applications.bw_launch import dock_area + + with RPCRegister.delayed_broadcast() as rpc_register: + existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea) + if name is not None: + if name in existing_dock_areas: + raise ValueError( + f"Name {name} must be unique for dock areas, but already exists: {existing_dock_areas}." + ) + else: + name = "dock_area" + name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas) + dock_area = dock_area(name) # BECDockArea(name=name) + dock_area.resize(dock_area.minimumSizeHint()) + # TODO Should we simply use the specified name as title here? + dock_area.window().setWindowTitle(f"BEC - {name}") + logger.info(f"Created new dock area: {name}") + logger.info(f"Existing dock areas: {geometry}") + if geometry is not None: + dock_area.setGeometry(*geometry) + dock_area.show() + return dock_area + + def show_launcher(self): + self.show() + + def hide_launcher(self): + self.hide() + + def cleanup(self): + super().close() diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index cc715a05..32463267 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -678,6 +678,14 @@ class DarkModeButton(RPCBase): """ +class DemoApp(RPCBase): + @rpc_call + def remove(self): + """ + Cleanup the BECConnector + """ + + class DeviceBrowser(RPCBase): @rpc_call def remove(self): @@ -3557,3 +3565,60 @@ class WebsiteWidget(RPCBase): """ Go forward in the history """ + + +class WindowWithUi(RPCBase): + """This is just testing app wiht UI file which could be connected to RPC.""" + + @rpc_call + def new_dock_area( + self, name: str | None = None, geometry: tuple[int, int, int, int] | None = None + ) -> "BECDockArea": + """ + Create a new dock area. + + Args: + name(str): The name of the dock area. + geometry(tuple): The geometry parameters to be passed to the dock area. + Returns: + BECDockArea: The newly created dock area. + """ + + @property + @rpc_call + def all_connections(self) -> list: + """ + None + """ + + @rpc_call + def change_theme(self, theme): + """ + None + """ + + @property + @rpc_call + def dock_area(self): + """ + None + """ + + @rpc_call + def register_all_rpc(self): + """ + None + """ + + @property + @rpc_call + def widget_list(self) -> list: + """ + Return a list of all widgets in the application. + """ + + @rpc_call + def list_app_hierarchy(self): + """ + List the hierarchy of the application. + """ diff --git a/bec_widgets/cli/client_utils.py b/bec_widgets/cli/client_utils.py index 81aee9fb..adf32e43 100644 --- a/bec_widgets/cli/client_utils.py +++ b/bec_widgets/cli/client_utils.py @@ -10,11 +10,11 @@ import threading import time from contextlib import contextmanager from threading import Lock -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Literal, TypeAlias from bec_lib.endpoints import MessageEndpoints from bec_lib.logger import bec_logger -from bec_lib.utils.import_utils import lazy_import, lazy_import_from +from bec_lib.utils.import_utils import lazy_import_from from rich.console import Console from rich.table import Table @@ -28,7 +28,11 @@ else: logger = bec_logger.logger -IGNORE_WIDGETS = ["BECDockArea", "BECDock"] +IGNORE_WIDGETS = ["LaunchWindow"] + +RegistryState: TypeAlias = dict[ + Literal["gui_id", "name", "widget_class", "config", "__rpc__"], str | bool | dict +] # pylint: disable=redefined-outer-scope @@ -67,7 +71,7 @@ def _get_output(process, logger) -> None: def _start_plot_process( - gui_id: str, gui_class: type, gui_class_id: str, config: dict | str, logger=None + gui_id: str, gui_class_id: str, config: dict | str, gui_class: str = "launcher", logger=None ) -> tuple[subprocess.Popen[str], threading.Thread | None]: """ Start the plot in a new process. @@ -82,7 +86,7 @@ def _start_plot_process( "--id", gui_id, "--gui_class", - gui_class.__name__, + gui_class, "--gui_class_id", gui_class_id, "--hide", @@ -199,21 +203,25 @@ class BECGuiClient(RPCBase): self._auto_updates_enabled = True self._auto_updates = None self._killed = False - self._top_level: dict[str, client.BECDockArea] = {} + self._top_level: dict[str, RPCReference] = {} self._startup_timeout = 0 self._gui_started_timer = None self._gui_started_event = threading.Event() self._process = None self._process_output_processing_thread = None - self._exposed_widgets = [] - self._server_registry = {} - self._ipython_registry = {} + self._server_registry: dict[str, RegistryState] = {} + self._ipython_registry: dict[str, RPCReference] = {} self.available_widgets = AvailableWidgetsNamespace() #################### #### Client API #### #################### + @property + def launcher(self) -> RPCBase: + """The launcher object.""" + return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, name="launcher") + def connect_to_gui_server(self, gui_id: str) -> None: """Connect to a GUI server""" # Unregister the old callback @@ -221,21 +229,25 @@ class BECGuiClient(RPCBase): MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update ) self._gui_id = gui_id - # Get the registry state - msgs = self._client.connector.xread( - MessageEndpoints.gui_registry_state(self._gui_id), count=1 - ) - if msgs: - self._handle_registry_update(msgs[0]) + + # reset the namespace + self._update_dynamic_namespace({}) + self._server_registry = {} + self._top_level = {} + self._ipython_registry = {} + # Register the new callback self._client.connector.register( - MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update + MessageEndpoints.gui_registry_state(self._gui_id), + cb=self._handle_registry_update, + parent=self, + from_start=True, ) @property def windows(self) -> dict: """Dictionary with dock areas in the GUI.""" - return self._top_level + return {widget._name: widget for widget in self._top_level.values()} @property def window_list(self) -> list: @@ -275,12 +287,12 @@ class BECGuiClient(RPCBase): self.start(wait=True) if wait: with wait_for_server(self): - rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self) + rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self) widget = rpc_client._run_rpc( - "new_dock_area", name, geometry + "launch", "dock_area", name, geometry ) # pylint: disable=protected-access return widget - rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self) + rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self) widget = rpc_client._run_rpc( "new_dock_area", name, geometry ) # pylint: disable=protected-access @@ -352,11 +364,13 @@ class BECGuiClient(RPCBase): # Wait for 'bec' gui to be registered, this may take some time # After 60s timeout. Should this raise an exception on timeout? while time.time() < time.time() + timeout: - if len(list(self._server_registry.keys())) == 0: + if len(list(self._server_registry.keys())) < 2 or not hasattr( + self, self._default_dock_name + ): time.sleep(0.1) else: break - self._do_show_all() + self._gui_started_event.set() def _start_server(self, wait: bool = False) -> None: @@ -369,7 +383,6 @@ class BECGuiClient(RPCBase): self._gui_started_event.clear() self._process, self._process_output_processing_thread = _start_plot_process( self._gui_id, - self.__class__, gui_class_id=self._default_dock_name, config=self._client._service_config.config, # pylint: disable=protected-access logger=logger, @@ -380,7 +393,7 @@ class BECGuiClient(RPCBase): if callable(callback): callback() finally: - threading.current_thread().cancel() + threading.current_thread().cancel() # type: ignore self._gui_started_timer = RepeatTimer( 0.5, lambda: self._gui_is_alive() and gui_started_callback(self._gui_post_startup) @@ -390,25 +403,25 @@ class BECGuiClient(RPCBase): if wait: self._gui_started_event.wait() - def _dump(self): - rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self) - return rpc_client._run_rpc("_dump") - def _start(self, wait: bool = False) -> None: self._killed = False self._client.connector.register( - MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update + MessageEndpoints.gui_registry_state(self._gui_id), + cb=self._handle_registry_update, + parent=self, ) return self._start_server(wait=wait) - def _handle_registry_update(self, msg: StreamMessage) -> None: + @staticmethod + def _handle_registry_update(msg: StreamMessage, parent: BECGuiClient) -> None: # This was causing a deadlock during shutdown, not sure why. # with self._lock: + self = parent self._server_registry = msg["data"].state - self._update_dynamic_namespace() + self._update_dynamic_namespace(self._server_registry) def _do_show_all(self): - rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self) + rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self) rpc_client._run_rpc("show") # pylint: disable=protected-access for window in self._top_level.values(): window.show() @@ -419,112 +432,54 @@ class BECGuiClient(RPCBase): def _hide_all(self): with wait_for_server(self): - rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self) + rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self) rpc_client._run_rpc("hide") # pylint: disable=protected-access if not self._killed: for window in self._top_level.values(): window.hide() - def _update_dynamic_namespace(self): - """Update the dynamic name space""" - # Clear the top level - self._top_level.clear() - # First we update the name space based on the new registry state - self._add_registry_to_namespace() - # Then we clear the ipython registry from old objects - self._cleanup_ipython_registry() + def _update_dynamic_namespace(self, server_registry: dict): + """ + Update the dynamic name space with the given server registry. + Setting the server registry to an empty dictionary will remove all widgets from the namespace. - def _cleanup_ipython_registry(self): - """Cleanup the ipython registry""" - names_in_registry = list(self._ipython_registry.keys()) - names_in_server_state = list(self._server_registry.keys()) - remove_ids = list(set(names_in_registry) - set(names_in_server_state)) - for widget_id in remove_ids: - self._ipython_registry.pop(widget_id) - self._cleanup_rpc_references_on_rpc_base(remove_ids) - # Clear the exposed widgets - self._exposed_widgets.clear() # No longer needed I think + Args: + server_registry (dict): The server registry + """ + top_level_widgets: dict[str, RPCReference] = {} + for gui_id, state in server_registry.items(): + widget = self._add_widget(state, self) + if widget is None: + # ignore widgets that are not supported + continue + # get all top-level widgets. These are widgets that have no parent + if not state["config"].get("parent_id"): + top_level_widgets[gui_id] = widget - def _cleanup_rpc_references_on_rpc_base(self, remove_ids: list[str]) -> None: - """Cleanup the rpc references on the RPCBase object""" - if not remove_ids: - return - for widget in self._ipython_registry.values(): - to_delete = [] - for attr_name, gui_id in widget._rpc_references.items(): - if gui_id in remove_ids: - to_delete.append(attr_name) - for attr_name in to_delete: - if hasattr(widget, attr_name): - delattr(widget, attr_name) - if attr_name.startswith("elements."): - delattr(widget.elements, attr_name.split(".")[1]) - widget._rpc_references.pop(attr_name) + remove_from_registry = [] + for gui_id, widget in self._ipython_registry.items(): + if gui_id not in server_registry: + remove_from_registry.append(gui_id) + widget._refresh_references() + for gui_id in remove_from_registry: + self._ipython_registry.pop(gui_id) - def _set_dynamic_attributes(self, obj: object, name: str, value: Any) -> None: - """Add an object to the namespace""" - setattr(obj, name, value) - - def _update_rpc_references(self, widget: RPCBase, name: str, gui_id: str) -> None: - """Update the RPC references""" - widget._rpc_references[name] = gui_id - - def _add_registry_to_namespace(self) -> None: - """Add registry to namespace""" - # Add dock areas - dock_area_states = [ - state - for state in self._server_registry.values() - if state["widget_class"] == "BECDockArea" + removed_widgets = [ + widget._name for widget in self._top_level.values() if widget._is_deleted() ] - for state in dock_area_states: - dock_area_ref = self._add_widget(state, self) - dock_area = self._ipython_registry.get(dock_area_ref._gui_id) - if not hasattr(dock_area, "elements"): - self._set_dynamic_attributes(dock_area, "elements", WidgetNameSpace()) - self._set_dynamic_attributes(self, dock_area.widget_name, dock_area_ref) - # Keep track of rpc references on RPCBase object - self._update_rpc_references(self, dock_area.widget_name, dock_area_ref._gui_id) - # Add dock_area to the top level - self._top_level[dock_area_ref.widget_name] = dock_area_ref - self._exposed_widgets.append(dock_area_ref._gui_id) - # Add docks - dock_states = [ - state - for state in self._server_registry.values() - if state["config"].get("parent_id", "") == dock_area_ref._gui_id - ] - for state in dock_states: - dock_ref = self._add_widget(state, dock_area) - dock = self._ipython_registry.get(dock_ref._gui_id) - self._set_dynamic_attributes(dock_area, dock_ref.widget_name, dock_ref) - # Keep track of rpc references on RPCBase object - self._update_rpc_references(dock_area, dock_ref.widget_name, dock_ref._gui_id) - # Keep track of exposed docks - self._exposed_widgets.append(dock_ref._gui_id) + for widget_name in removed_widgets: + # the check is not strictly necessary, but better safe + # than sorry; who knows what the user has done + if hasattr(self, widget_name): + delattr(self, widget_name) - # Add widgets - widget_states = [ - state - for state in self._server_registry.values() - if state["config"].get("parent_id", "") == dock_ref._gui_id - ] - for state in widget_states: - widget_ref = self._add_widget(state, dock) - self._set_dynamic_attributes(dock, widget_ref.widget_name, widget_ref) - self._set_dynamic_attributes( - dock_area.elements, widget_ref.widget_name, widget_ref - ) - # Keep track of rpc references on RPCBase object - self._update_rpc_references( - dock_area, f"elements.{widget_ref.widget_name}", widget_ref._gui_id - ) - self._update_rpc_references(dock, widget_ref.widget_name, widget_ref._gui_id) - # Keep track of exposed widgets - self._exposed_widgets.append(widget_ref._gui_id) + for gui_id, widget_ref in top_level_widgets.items(): + setattr(self, widget_ref._name, widget_ref) - def _add_widget(self, state: dict, parent: object) -> RPCReference: + self._top_level = top_level_widgets + + def _add_widget(self, state: dict, parent: object) -> RPCReference | None: """Add a widget to the namespace Args: @@ -533,12 +488,11 @@ class BECGuiClient(RPCBase): """ name = state["name"] gui_id = state["gui_id"] - 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 + if state["widget_class"] in IGNORE_WIDGETS: + return + widget_class = getattr(client, state["widget_class"], None) + if widget_class is None: + return obj = self._ipython_registry.get(gui_id) if obj is None: widget = widget_class(gui_id=gui_id, name=name, parent=parent) diff --git a/bec_widgets/cli/rpc/rpc_base.py b/bec_widgets/cli/rpc/rpc_base.py index 75cc2a5b..3f5facdc 100644 --- a/bec_widgets/cli/rpc/rpc_base.py +++ b/bec_widgets/cli/rpc/rpc_base.py @@ -14,6 +14,9 @@ if TYPE_CHECKING: # pragma: no cover from bec_lib import messages from bec_lib.connector import MessageObject + from bec_widgets.cli.client_utils import BECGuiClient + + import bec_widgets.cli.client as client else: client = lazy_import("bec_widgets.cli.client") # avoid circular import @@ -88,10 +91,11 @@ class RPCReference: def __init__(self, registry: dict, gui_id: str) -> None: self._registry = registry self._gui_id = gui_id + self._name = self._registry[self._gui_id]._name @check_for_deleted_widget def __getattr__(self, name): - if name in ["_registry", "_gui_id"]: + if name in ["_registry", "_gui_id", "_is_deleted", "_name"]: return super().__getattribute__(name) return self._registry[self._gui_id].__getattribute__(name) @@ -114,6 +118,9 @@ class RPCReference: return [] return self._registry[self._gui_id].__dir__() + def _is_deleted(self) -> bool: + return self._gui_id not in self._registry + class RPCBase: def __init__( @@ -152,7 +159,7 @@ class RPCBase: return self._name @property - def _root(self): + def _root(self) -> BECGuiClient: """ Get the root widget. This is the BECFigure widget that holds the anchor gui_id. @@ -163,7 +170,7 @@ class RPCBase: parent = parent._parent return parent - def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs) -> Any: + def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=300, **kwargs) -> Any: """ Run the RPC call. @@ -236,13 +243,14 @@ class RPCBase: cls = getattr(client, cls) # The namespace of the object will be updated dynamically on the client side - # Therefor it is important to check if the object is already in the registry + # Therefore it is important to check if the object is already in the registry # If yes, we return the reference to the object, otherwise we create a new object # pylint: disable=protected-access if msg_result["gui_id"] in self._root._ipython_registry: return RPCReference(self._root._ipython_registry, msg_result["gui_id"]) ret = cls(parent=self, **msg_result) self._root._ipython_registry[ret._gui_id] = ret + self._refresh_references() obj = RPCReference(self._root._ipython_registry, ret._gui_id) return obj # return ret @@ -258,3 +266,22 @@ class RPCBase: if heart.status == messages.BECStatus.RUNNING: return True return False + + def _refresh_references(self): + """ + Refresh the references. + """ + with self._root._lock: + references = {} + for key, val in self._root._server_registry.items(): + parent_id = val["config"].get("parent_id") + if parent_id == self._gui_id: + references[key] = {"gui_id": val["config"]["gui_id"], "name": val["name"]} + removed_references = set(self._rpc_references.keys()) - set(references.keys()) + for key in removed_references: + delattr(self, self._rpc_references[key]["name"]) + self._rpc_references = references + for key, val in references.items(): + setattr( + self, val["name"], RPCReference(self._root._ipython_registry, val["gui_id"]) + ) diff --git a/bec_widgets/cli/rpc/rpc_register.py b/bec_widgets/cli/rpc/rpc_register.py index 4bfa3f9d..75eb1b78 100644 --- a/bec_widgets/cli/rpc/rpc_register.py +++ b/bec_widgets/cli/rpc/rpc_register.py @@ -1,7 +1,7 @@ from __future__ import annotations from functools import wraps -from threading import Lock, RLock +from threading import RLock from typing import TYPE_CHECKING, Callable from weakref import WeakValueDictionary @@ -77,7 +77,7 @@ class RPCRegister: self._rpc_register[rpc.gui_id] = rpc @broadcast_update - def remove_rpc(self, rpc: str): + def remove_rpc(self, rpc: BECConnector): """ Remove an RPC object from the register. @@ -113,7 +113,7 @@ class RPCRegister: return connections def get_names_of_rpc_by_class_type( - self, cls: BECWidget | BECConnector | BECDock | BECDockArea + self, cls: type[BECWidget] | type[BECConnector] | type[BECDock] | type[BECDockArea] ) -> list[str]: """Get all the names of the widgets. @@ -170,6 +170,7 @@ class RPCRegisterBroadcast: def __exit__(self, *exc): """Exit the context manager""" + self._call_depth -= 1 # Remove nested calls if self._call_depth == 0: # Last one to exit is repsonsible for broadcasting self.rpc_register._skip_broadcast = False diff --git a/bec_widgets/cli/server.py b/bec_widgets/cli/server.py index aadecb43..9f0480d7 100644 --- a/bec_widgets/cli/server.py +++ b/bec_widgets/cli/server.py @@ -1,189 +1,27 @@ from __future__ import annotations -import functools +import argparse import json +import os import signal import sys -import traceback -import types -from contextlib import contextmanager, redirect_stderr, redirect_stdout -from typing import Union +from contextlib import redirect_stderr, redirect_stdout +from typing import cast -from bec_lib.endpoints import MessageEndpoints from bec_lib.logger import bec_logger from bec_lib.service_config import ServiceConfig -from bec_lib.utils.import_utils import lazy_import -from qtpy.QtCore import Qt, QTimer -from redis.exceptions import RedisError +from qtpy.QtCore import QSize, Qt +from qtpy.QtGui import QIcon +from qtpy.QtWidgets import QApplication +import bec_widgets +from bec_widgets.applications.launch_window import LaunchWindow from bec_widgets.cli.rpc.rpc_register import RPCRegister -from bec_widgets.utils import BECDispatcher -from bec_widgets.utils.bec_connector import BECConnector -from bec_widgets.utils.error_popups import ErrorPopupUtility -from bec_widgets.widgets.containers.dock import BECDockArea -from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow +from bec_widgets.utils.bec_dispatcher import BECDispatcher -messages = lazy_import("bec_lib.messages") logger = bec_logger.logger - -@contextmanager -def rpc_exception_hook(err_func): - """This context replaces the popup message box for error display with a specific hook""" - # get error popup utility singleton - popup = ErrorPopupUtility() - # save current setting - old_exception_hook = popup.custom_exception_hook - - # install err_func, if it is a callable - # IMPORTANT, Keep self here, because this method is overwriting the custom_exception_hook - # of the ErrorPopupUtility (popup instance) class. - def custom_exception_hook(self, exc_type, value, tb, **kwargs): - err_func({"error": popup.get_error_message(exc_type, value, tb)}) - - popup.custom_exception_hook = types.MethodType(custom_exception_hook, popup) - - try: - yield popup - finally: - # restore state of error popup utility singleton - popup.custom_exception_hook = old_exception_hook - - -class BECWidgetsCLIServer: - - def __init__( - self, - gui_id: str, - dispatcher: BECDispatcher = None, - client=None, - config=None, - gui_class: type[BECDockArea] = BECDockArea, - gui_class_id: str = "bec", - ) -> None: - self.status = messages.BECStatus.BUSY - self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher - self.client = self.dispatcher.client if client is None else client - self.client.start() - self.gui_id = gui_id - # register broadcast callback - self.rpc_register = RPCRegister() - self.rpc_register.add_callback(self.broadcast_registry_update) - - self.dispatcher.connect_slot( - self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id) - ) - - # Setup QTimer for heartbeat - self._heartbeat_timer = QTimer() - self._heartbeat_timer.timeout.connect(self.emit_heartbeat) - self._heartbeat_timer.start(200) - - self.status = messages.BECStatus.RUNNING - with RPCRegister.delayed_broadcast(): - self.gui = gui_class(parent=None, name=gui_class_id, gui_id=gui_class_id) - logger.success(f"Server started with gui_id: {self.gui_id}") - # Create initial object -> BECFigure or BECDockArea - - def on_rpc_update(self, msg: dict, metadata: dict): - request_id = metadata.get("request_id") - logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}") - with rpc_exception_hook(functools.partial(self.send_response, request_id, False)): - try: - obj = self.get_object_from_config(msg["parameter"]) - method = msg["action"] - args = msg["parameter"].get("args", []) - 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: {traceback.format_exc()}") - self.send_response(request_id, False, {"error": str(e)}) - else: - logger.debug(f"RPC instruction executed successfully: {res}") - self.send_response(request_id, True, {"result": res}) - - def send_response(self, request_id: str, accepted: bool, msg: dict): - self.client.connector.set_and_publish( - MessageEndpoints.gui_instruction_response(request_id), - messages.RequestResponseMessage(accepted=accepted, message=msg), - expire=60, - ) - - def get_object_from_config(self, config: dict): - gui_id = config.get("gui_id") - obj = self.rpc_register.get_rpc_by_id(gui_id) - if obj is None: - raise ValueError(f"Object with gui_id {gui_id} not found") - return obj - - def run_rpc(self, obj, method, args, kwargs): - # Run with rpc registry broadcast, but only once - with RPCRegister.delayed_broadcast(): - logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}") - method_obj = getattr(obj, method) - # check if the method accepts args and kwargs - if not callable(method_obj): - if not args: - res = method_obj - else: - setattr(obj, method, args[0]) - res = None - else: - res = method_obj(*args, **kwargs) - - if isinstance(res, list): - res = [self.serialize_object(obj) for obj in res] - elif isinstance(res, dict): - res = {key: self.serialize_object(val) for key, val in res.items()} - else: - res = self.serialize_object(res) - return res - - def serialize_object(self, obj): - if isinstance(obj, BECConnector): - config = obj.config.model_dump() - config["parent_id"] = obj.parent_id # add parent_id to config - return { - "gui_id": obj.gui_id, - "name": ( - obj._name if hasattr(obj, "_name") else obj.__class__.__name__ - ), # pylint: disable=protected-access - "widget_class": obj.__class__.__name__, - "config": config, - "__rpc__": True, - } - return obj - - def emit_heartbeat(self): - logger.trace(f"Emitting heartbeat for {self.gui_id}") - try: - self.client.connector.set( - MessageEndpoints.gui_heartbeat(self.gui_id), - messages.StatusMessage(name=self.gui_id, status=self.status, info={}), - expire=10, - ) - except RedisError as exc: - logger.error(f"Error while emitting heartbeat: {exc}") - - def broadcast_registry_update(self, connections: dict): - """ - Broadcast the updated registry to all clients. - """ - - # We only need to broadcast the dock areas - data = {key: self.serialize_object(val) for key, val in connections.items()} - self.client.connector.xadd( - MessageEndpoints.gui_registry_state(self.gui_id), - msg_dict={"data": messages.GUIRegistryStateMessage(state=data)}, - max_size=1, # only single message in stream - ) - - def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector - self.status = messages.BECStatus.IDLE - self._heartbeat_timer.stop() - self.emit_heartbeat() - logger.info("Succeded in shutting down gui") - self.client.shutdown() +MODULE_PATH = os.path.dirname(bec_widgets.__file__) class SimpleFileLikeFromLogOutputFunc: @@ -204,40 +42,136 @@ class SimpleFileLikeFromLogOutputFunc: return -def _start_server( - gui_id: str, gui_class: BECDockArea, gui_class_id: str = "bec", config: str | None = None -): - if config: - try: - config = json.loads(config) - service_config = ServiceConfig(config=config) - except (json.JSONDecodeError, TypeError): - service_config = ServiceConfig(config_path=config) - else: - # if no config is provided, use the default config - service_config = ServiceConfig() +class GUIServer: + """ + This class is used to start the BEC GUI and is the main entry point for launching BEC Widgets in a subprocess. + """ - # bec_logger.configure( - # service_config.redis, - # QtRedisConnector, - # service_name="BECWidgetsCLIServer", - # service_config=service_config.service_config, - # ) - server = BECWidgetsCLIServer( - gui_id=gui_id, config=service_config, gui_class=gui_class, gui_class_id=gui_class_id - ) - return server + def __init__(self, args): + self.config = args.config + self.gui_id = args.id + self.gui_class = args.gui_class + self.gui_class_id = args.gui_class_id + self.hide = args.hide + self.app: QApplication | None = None + self.launcher_window: LaunchWindow | None = None + self.dispatcher: BECDispatcher | None = None + + def start(self): + """ + Start the GUI server. + """ + bec_logger.level = bec_logger.LOGLEVEL.INFO + if self.hide: + # pylint: disable=protected-access + bec_logger._stderr_log_level = bec_logger.LOGLEVEL.ERROR + bec_logger._update_sinks() + + with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)): # type: ignore + with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)): # type: ignore + self._run() + + def _get_service_config(self) -> ServiceConfig: + if self.config: + try: + config = json.loads(self.config) + service_config = ServiceConfig(config=config) + except (json.JSONDecodeError, TypeError): + service_config = ServiceConfig(config_path=config) + else: + # if no config is provided, use the default config + service_config = ServiceConfig() + return service_config + + def _turn_off_the_lights(self, connections: dict): + """ + If there is only one connection remaining, it is the launcher, so we show it. + Once the launcher is closed as the last window, we quit the application. + """ + self.launcher_window = cast(LaunchWindow, self.launcher_window) + + if len(connections) <= 1: + self.launcher_window.show() + self.launcher_window.activateWindow() + self.launcher_window.raise_() + if self.app: + self.app.setQuitOnLastWindowClosed(True) + else: + self.launcher_window.hide() + if self.app: + self.app.setQuitOnLastWindowClosed(False) + + def _run(self): + """ + Run the GUI server. + """ + self.app = QApplication(sys.argv) + self.app.setApplicationName("BEC") + self.app.gui_id = self.gui_id # type: ignore + self.setup_bec_icon() + + service_config = self._get_service_config() + self.dispatcher = BECDispatcher(config=service_config) + self.dispatcher.start_cli_server(gui_id=self.gui_id) + + self.launcher_window = LaunchWindow(gui_id=f"{self.gui_id}:launcher") + self.launcher_window.setAttribute(Qt.WA_ShowWithoutActivating) # type: ignore + + self.app.aboutToQuit.connect(self.shutdown) + self.app.setQuitOnLastWindowClosed(False) + + register = RPCRegister() + register.callbacks.append(self._turn_off_the_lights) + register.broadcast() + + if self.gui_class: + # If the server is started with a specific gui class, we launch it. + # This will automatically hide the launcher. + self.launcher_window.launch(self.gui_class, name=self.gui_class_id) + + def sigint_handler(*args): + # display message, for people to let it terminate gracefully + print("Caught SIGINT, exiting") + # Widgets should be all closed. + with RPCRegister.delayed_broadcast(): + for widget in QApplication.instance().topLevelWidgets(): # type: ignore + widget.close() + if self.app: + self.app.quit() + + # gui.bec.close() + # win.shutdown() + signal.signal(signal.SIGINT, sigint_handler) + signal.signal(signal.SIGTERM, sigint_handler) + + sys.exit(self.app.exec()) + + def setup_bec_icon(self): + """ + Set the BEC icon for the application + """ + if self.app is None: + return + icon = QIcon() + icon.addFile( + os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"), + size=QSize(48, 48), + ) + self.app.setWindowIcon(icon) + + def shutdown(self): + """ + Shutdown the GUI server. + """ + if self.dispatcher: + self.dispatcher.stop_cli_server() + self.dispatcher.disconnect_all() def main(): - import argparse - import os - - from qtpy.QtCore import QSize - from qtpy.QtGui import QIcon - from qtpy.QtWidgets import QApplication - - import bec_widgets + """ + Main entry point for subprocesses that start a GUI server. + """ parser = argparse.ArgumentParser(description="BEC Widgets CLI Server") parser.add_argument("--id", type=str, default="test", help="The id of the server") @@ -257,69 +191,12 @@ def main(): args = parser.parse_args() - bec_logger.level = bec_logger.LOGLEVEL.INFO - if args.hide: - # pylint: disable=protected-access - bec_logger._stderr_log_level = bec_logger.LOGLEVEL.ERROR - bec_logger._update_sinks() - - if args.gui_class == "BECDockArea": - gui_class = BECDockArea - else: - print( - "Please specify a valid gui_class to run. Use -h for help." - "\n Starting with default gui_class BECFigure." - ) - gui_class = BECDockArea - - with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)): - with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)): - app = QApplication(sys.argv) - # set close on last window, only if not under control of client ; - # indeed, Qt considers a hidden window a closed window, so if all windows - # are hidden by default it exits - app.setQuitOnLastWindowClosed(not args.hide) - module_path = os.path.dirname(bec_widgets.__file__) - icon = QIcon() - icon.addFile( - os.path.join(module_path, "assets", "app_icons", "bec_widgets_icon.png"), - size=QSize(48, 48), - ) - app.setWindowIcon(icon) - # store gui id within QApplication object, to make it available to all widgets - app.gui_id = args.id - - # args.id = "abff6" - server = _start_server(args.id, gui_class, args.gui_class_id, args.config) - - win = BECMainWindow(gui_id=f"{server.gui_id}:window") - win.setAttribute(Qt.WA_ShowWithoutActivating) - win.setWindowTitle("BEC") - - RPCRegister().add_rpc(win) - gui = server.gui - win.setCentralWidget(gui) - if not args.hide: - win.show() - - app.aboutToQuit.connect(server.shutdown) - - def sigint_handler(*args): - # display message, for people to let it terminate gracefully - print("Caught SIGINT, exiting") - # Widgets should be all closed. - with RPCRegister.delayed_broadcast(): - for widget in QApplication.instance().topLevelWidgets(): - widget.close() - app.quit() - - # gui.bec.close() - # win.shutdown() - signal.signal(signal.SIGINT, sigint_handler) - signal.signal(signal.SIGTERM, sigint_handler) - - sys.exit(app.exec()) + server = GUIServer(args) + server.start() if __name__ == "__main__": + # import sys + + # sys.argv = ["bec_widgets", "--gui_class", "MainWindow"] main() diff --git a/bec_widgets/utils/bec_dispatcher.py b/bec_widgets/utils/bec_dispatcher.py index c50a3948..ed5bcf21 100644 --- a/bec_widgets/utils/bec_dispatcher.py +++ b/bec_widgets/utils/bec_dispatcher.py @@ -1,6 +1,8 @@ from __future__ import annotations import collections +import random +import string from collections.abc import Callable from typing import TYPE_CHECKING, Union @@ -17,6 +19,8 @@ logger = bec_logger.logger if TYPE_CHECKING: from bec_lib.endpoints import EndpointInfo + from bec_widgets.utils.cli_server import CLIServer + class QtThreadSafeCallback(QObject): cb_signal = pyqtSignal(dict, dict) @@ -73,14 +77,16 @@ class BECDispatcher: _instance = None _initialized = False + client: BECClient + cli_server: CLIServer | None = None - def __new__(cls, client=None, config: str = None, *args, **kwargs): + def __new__(cls, client=None, config: str | ServiceConfig | None = None, *args, **kwargs): if cls._instance is None: cls._instance = super(BECDispatcher, cls).__new__(cls) cls._initialized = False return cls._instance - def __init__(self, client=None, config: str | ServiceConfig = None): + def __init__(self, client=None, config: str | ServiceConfig | None = None): if self._initialized: return @@ -112,6 +118,9 @@ class BECDispatcher: @classmethod def reset_singleton(cls): + """ + Reset the singleton instance of the BECDispatcher. + """ cls._instance = None cls._initialized = False @@ -178,4 +187,49 @@ class BECDispatcher: *args: Arbitrary positional arguments **kwargs: Arbitrary keyword arguments """ + # pylint: disable=protected-access self.disconnect_topics(self.client.connector._topics_cb) + + def start_cli_server(self, gui_id: str | None = None): + """ + Start the CLI server. + + Args: + gui_id(str, optional): The GUI ID. Defaults to None. If None, a unique identifier will be generated. + """ + # pylint: disable=import-outside-toplevel + from bec_widgets.utils.cli_server import CLIServer + + if gui_id is None: + gui_id = self.generate_unique_identifier() + + if not self.client.started: + logger.error("Cannot start CLI server without a running client") + return + self.cli_server = CLIServer(gui_id, dispatcher=self, client=self.client) + logger.success("Started CLI server with gui_id: {gui_id}") + + def stop_cli_server(self): + """ + Stop the CLI server. + """ + if self.cli_server is None: + logger.error("Cannot stop CLI server without starting it first") + return + self.cli_server.shutdown() + self.cli_server = None + logger.success("Stopped CLI server") + + @staticmethod + def generate_unique_identifier(length: int = 4) -> str: + """ + Generate a unique identifier for the application. + + Args: + length: The length of the identifier. Defaults to 4. + + Returns: + str: The unique identifier. + """ + allowed_chars = string.ascii_lowercase + string.digits + return "".join(random.choices(allowed_chars, k=length)) diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index b68fdd2d..f2c150ed 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import darkdetect from bec_lib.logger import bec_logger @@ -56,7 +56,6 @@ class BECWidget(BECConnector): """ if not isinstance(self, QWidget): raise RuntimeError(f"{repr(self)} is not a subclass of QWidget") - super().__init__( client=client, config=config, @@ -78,6 +77,13 @@ class BECWidget(BECConnector): logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}") self._connect_to_theme_change() + def _ensure_bec_app(self): + # pylint: disable=import-outside-toplevel + from bec_widgets.utils.bec_qapp import BECApplication + + app = BECApplication.from_qapplication() + return app + def _connect_to_theme_change(self): """Connect to the theme change signal.""" qapp = QApplication.instance() diff --git a/bec_widgets/utils/cli_server.py b/bec_widgets/utils/cli_server.py new file mode 100644 index 00000000..ad06633b --- /dev/null +++ b/bec_widgets/utils/cli_server.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import functools +import traceback +import types +from contextlib import contextmanager +from typing import TYPE_CHECKING + +from bec_lib.client import BECClient +from bec_lib.endpoints import MessageEndpoints +from bec_lib.logger import bec_logger +from bec_lib.utils.import_utils import lazy_import +from qtpy.QtCore import QTimer +from redis.exceptions import RedisError + +from bec_widgets.cli.rpc.rpc_register import RPCRegister +from bec_widgets.utils import BECDispatcher +from bec_widgets.utils.bec_connector import BECConnector +from bec_widgets.utils.error_popups import ErrorPopupUtility + +if TYPE_CHECKING: + from bec_lib import messages +else: + messages = lazy_import("bec_lib.messages") +logger = bec_logger.logger + + +@contextmanager +def rpc_exception_hook(err_func): + """This context replaces the popup message box for error display with a specific hook""" + # get error popup utility singleton + popup = ErrorPopupUtility() + # save current setting + old_exception_hook = popup.custom_exception_hook + + # install err_func, if it is a callable + # IMPORTANT, Keep self here, because this method is overwriting the custom_exception_hook + # of the ErrorPopupUtility (popup instance) class. + def custom_exception_hook(self, exc_type, value, tb, **kwargs): + err_func({"error": popup.get_error_message(exc_type, value, tb)}) + + popup.custom_exception_hook = types.MethodType(custom_exception_hook, popup) + + try: + yield popup + finally: + # restore state of error popup utility singleton + popup.custom_exception_hook = old_exception_hook + + +class CLIServer: + + client: BECClient + + def __init__( + self, + gui_id: str, + dispatcher: BECDispatcher | None = None, + client: BECClient | None = None, + config=None, + gui_class_id: str = "bec", + ) -> None: + self.status = messages.BECStatus.BUSY + self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher + self.client = self.dispatcher.client if client is None else client + self.client.start() + self.gui_id = gui_id + # register broadcast callback + self.rpc_register = RPCRegister() + self.rpc_register.add_callback(self.broadcast_registry_update) + + self.dispatcher.connect_slot( + self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id) + ) + + # Setup QTimer for heartbeat + self._heartbeat_timer = QTimer() + self._heartbeat_timer.timeout.connect(self.emit_heartbeat) + self._heartbeat_timer.start(200) + + self.status = messages.BECStatus.RUNNING + logger.success(f"Server started with gui_id: {self.gui_id}") + + def on_rpc_update(self, msg: dict, metadata: dict): + request_id = metadata.get("request_id") + if request_id is None: + logger.error("Received RPC instruction without request_id") + return + logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}") + with rpc_exception_hook(functools.partial(self.send_response, request_id, False)): + try: + obj = self.get_object_from_config(msg["parameter"]) + method = msg["action"] + args = msg["parameter"].get("args", []) + kwargs = msg["parameter"].get("kwargs", {}) + res = self.run_rpc(obj, method, args, kwargs) + except Exception: + content = traceback.format_exc() + logger.error(f"Error while executing RPC instruction: {content}") + self.send_response(request_id, False, {"error": content}) + else: + logger.debug(f"RPC instruction executed successfully: {res}") + self.send_response(request_id, True, {"result": res}) + + def send_response(self, request_id: str, accepted: bool, msg: dict): + self.client.connector.set_and_publish( + MessageEndpoints.gui_instruction_response(request_id), + messages.RequestResponseMessage(accepted=accepted, message=msg), + expire=60, + ) + + def get_object_from_config(self, config: dict): + gui_id = config.get("gui_id") + obj = self.rpc_register.get_rpc_by_id(gui_id) + if obj is None: + raise ValueError(f"Object with gui_id {gui_id} not found") + return obj + + def run_rpc(self, obj, method, args, kwargs): + # Run with rpc registry broadcast, but only once + with RPCRegister.delayed_broadcast(): + logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}") + method_obj = getattr(obj, method) + # check if the method accepts args and kwargs + if not callable(method_obj): + if not args: + res = method_obj + else: + setattr(obj, method, args[0]) + res = None + else: + res = method_obj(*args, **kwargs) + + if isinstance(res, list): + res = [self.serialize_object(obj) for obj in res] + elif isinstance(res, dict): + res = {key: self.serialize_object(val) for key, val in res.items()} + else: + res = self.serialize_object(res) + return res + + def serialize_object(self, obj): + if isinstance(obj, BECConnector): + config = obj.config.model_dump() + config["parent_id"] = obj.parent_id # add parent_id to config + return { + "gui_id": obj.gui_id, + "name": ( + obj._name if hasattr(obj, "_name") else obj.__class__.__name__ + ), # pylint: disable=protected-access + "widget_class": obj.__class__.__name__, + "config": config, + "__rpc__": True, + } + return obj + + def emit_heartbeat(self): + logger.trace(f"Emitting heartbeat for {self.gui_id}") + try: + self.client.connector.set( + MessageEndpoints.gui_heartbeat(self.gui_id), + messages.StatusMessage(name=self.gui_id, status=self.status, info={}), + expire=10, + ) + except RedisError as exc: + logger.error(f"Error while emitting heartbeat: {exc}") + + def broadcast_registry_update(self, connections: dict): + """ + Broadcast the updated registry to all clients. + """ + + # We only need to broadcast the dock areas + data = {key: self.serialize_object(val) for key, val in connections.items()} + logger.info(f"Broadcasting registry update: {data} for {self.gui_id}") + self.client.connector.xadd( + MessageEndpoints.gui_registry_state(self.gui_id), + msg_dict={"data": messages.GUIRegistryStateMessage(state=data)}, + max_size=1, # only single message in stream + ) + + def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector + self.status = messages.BECStatus.IDLE + self._heartbeat_timer.stop() + self.emit_heartbeat() + logger.info("Succeded in shutting down CLI server") + self.client.shutdown() diff --git a/bec_widgets/utils/toolbar.py b/bec_widgets/utils/toolbar.py index 10be145b..ce042bfc 100644 --- a/bec_widgets/utils/toolbar.py +++ b/bec_widgets/utils/toolbar.py @@ -858,7 +858,7 @@ class MainWindow(QMainWindow): # pragma: no cover # For theme testing - self.dark_button = DarkModeButton(toolbar=True) + self.dark_button = DarkModeButton(parent=self, toolbar=True) dark_mode_action = WidgetAction(label=None, widget=self.dark_button) self.toolbar.add_action("dark_mode", dark_mode_action, self) diff --git a/bec_widgets/utils/ui_loader.py b/bec_widgets/utils/ui_loader.py index 90fec46e..83299cfc 100644 --- a/bec_widgets/utils/ui_loader.py +++ b/bec_widgets/utils/ui_loader.py @@ -1,11 +1,14 @@ -import os +import inspect +from bec_lib.logger import bec_logger from qtpy import PYQT6, PYSIDE6, QT_VERSION from qtpy.QtCore import QFile, QIODevice from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo from bec_widgets.utils.plugin_utils import get_custom_classes +logger = bec_logger.logger + if PYSIDE6: from PySide6.QtUiTools import QUiLoader @@ -18,10 +21,19 @@ if PYSIDE6: def createWidget(self, class_name, parent=None, name=""): if class_name in self.custom_widgets: - widget = self.custom_widgets[class_name](parent) + + # check if the custom widget has a parent_id argument + if "parent_id" in inspect.signature(self.custom_widgets[class_name]).parameters: + gui_id = getattr(self.baseinstance, "gui_id", None) + widget = self.custom_widgets[class_name](self.baseinstance, parent_id=gui_id) + else: + logger.warning( + f"Custom widget {class_name} does not have a parent_id argument. " + ) + widget = self.custom_widgets[class_name](self.baseinstance) widget.setObjectName(name) return widget - return super().createWidget(class_name, parent, name) + return super().createWidget(class_name, self.baseinstance, name) class UILoader: @@ -51,7 +63,7 @@ class UILoader: Returns: QWidget: The loaded widget. """ - + parent = parent or self.parent loader = CustomUiLoader(parent, self.custom_widgets) file = QFile(ui_file) if not file.open(QIODevice.ReadOnly): diff --git a/bec_widgets/utils/widget_io.py b/bec_widgets/utils/widget_io.py index 6e4f583d..75d090e5 100644 --- a/bec_widgets/utils/widget_io.py +++ b/bec_widgets/utils/widget_io.py @@ -18,6 +18,7 @@ from qtpy.QtWidgets import ( QWidget, ) +from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch @@ -275,39 +276,81 @@ class WidgetHierarchy: grab_values: bool = False, prefix: str = "", exclude_internal_widgets: bool = True, + only_bec_widgets: bool = False, + show_parent: bool = True, ) -> None: """ Print the widget hierarchy to the console. Args: - widget: Widget to print the hierarchy of + widget: Widget to print the hierarchy of. indent(int, optional): Level of indentation. grab_values(bool,optional): Whether to grab the values of the widgets. - prefix(stc,optional): Custom string prefix for indentation. + prefix(str,optional): Custom string prefix for indentation. exclude_internal_widgets(bool,optional): Whether to exclude internal widgets (e.g. QComboBox in PyQt6). + only_bec_widgets(bool, optional): Whether to print only widgets that are instances of BECWidget. + show_parent(bool, optional): Whether to display which BECWidget is the parent of each discovered BECWidget. """ - widget_info = f"{widget.__class__.__name__} ({widget.objectName()})" - if grab_values: - value = WidgetIO.get_value(widget, ignore_errors=True) - value_str = f" [value: {value}]" if value is not None else "" - widget_info += value_str + # Decide if this particular widget is to be printed + is_bec = isinstance(widget, BECWidget) + print_this = (not only_bec_widgets) or is_bec - print(prefix + widget_info) + # If it is a BECWidget and we're showing the parent, climb the chain to find the nearest BECWidget ancestor + if show_parent and is_bec: + ancestor = WidgetHierarchy._get_becwidget_ancestor(widget) + if ancestor is not None: + parent_info = f" parent={ancestor.__class__.__name__}" + else: + parent_info = " parent=None" + else: + parent_info = "" + if print_this: + widget_info = f"{widget.__class__.__name__} ({widget.objectName()}){parent_info}" + if grab_values: + value = WidgetIO.get_value(widget, ignore_errors=True) + value_str = f" [value: {value}]" if value is not None else "" + widget_info += value_str + print(prefix + widget_info) + + # Always recurse so we can discover deeper BECWidgets even if the current widget is not a BECWidget children = widget.children() - for child in children: + for i, child in enumerate(children): + # Possibly skip known internal child widgets of a QComboBox if ( exclude_internal_widgets and isinstance(widget, QComboBox) and child.__class__.__name__ in ["QFrame", "QBoxLayout", "QListView"] ): continue + child_prefix = prefix + " " arrow = "├─ " if child != children[-1] else "└─ " + + # Regardless of whether child is BECWidget or not, keep recursing, or we might miss deeper BECWidgets WidgetHierarchy.print_widget_hierarchy( - child, indent + 1, grab_values, prefix=child_prefix + arrow + child, + indent + 1, + grab_values=grab_values, + prefix=child_prefix + arrow, + exclude_internal_widgets=exclude_internal_widgets, + only_bec_widgets=only_bec_widgets, + show_parent=show_parent, ) + @staticmethod + def _get_becwidget_ancestor(widget): + """ + Climb the parent chain to find the nearest BECWidget above this widget. + Returns None if none is found. + """ + parent = widget.parent() + while parent is not None: + if isinstance(parent, BECWidget): + return parent + parent = parent.parent() + return None + @staticmethod def export_config_to_dict( widget: QWidget, diff --git a/bec_widgets/widgets/containers/dock/dock_area.py b/bec_widgets/widgets/containers/dock/dock_area.py index 83a9eb2a..5bcd87e6 100644 --- a/bec_widgets/widgets/containers/dock/dock_area.py +++ b/bec_widgets/widgets/containers/dock/dock_area.py @@ -91,8 +91,10 @@ class BECDockArea(BECWidget, QWidget): self._instructions_visible = True + self.dark_mode_button = DarkModeButton(parent=self, parent_id=self.gui_id, toolbar=True) self.dock_area = DockArea() self.toolbar = ModularToolBar( + parent=self, actions={ "menu_plots": ExpandableMenuAction( label="Add Plot ", @@ -172,7 +174,7 @@ class BECDockArea(BECWidget, QWidget): self.spacer = QWidget() self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.toolbar.addWidget(self.spacer) - self.toolbar.addWidget(DarkModeButton(toolbar=True)) + self.toolbar.addWidget(self.dark_mode_button) self._hook_toolbar() def minimumSizeHint(self): @@ -432,6 +434,8 @@ class BECDockArea(BECWidget, QWidget): self.delete_all() self.toolbar.close() self.toolbar.deleteLater() + self.dark_mode_button.close() + self.dark_mode_button.deleteLater() self.dock_area.close() self.dock_area.deleteLater() super().cleanup() diff --git a/bec_widgets/widgets/containers/main_window/addons/__init__.py b/bec_widgets/widgets/containers/main_window/addons/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/containers/main_window/addons/web_links.py b/bec_widgets/widgets/containers/main_window/addons/web_links.py new file mode 100644 index 00000000..619e6d1e --- /dev/null +++ b/bec_widgets/widgets/containers/main_window/addons/web_links.py @@ -0,0 +1,15 @@ +import webbrowser + + +class BECWebLinksMixin: + @staticmethod + def open_bec_docs(): + webbrowser.open("https://beamline-experiment-control.readthedocs.io/en/latest/") + + @staticmethod + def open_bec_widgets_docs(): + webbrowser.open("https://bec.readthedocs.io/projects/bec-widgets/en/latest/") + + @staticmethod + def open_bec_bug_report(): + webbrowser.open("https://gitlab.psi.ch/groups/bec/-/issues/") diff --git a/bec_widgets/widgets/containers/main_window/example_app.ui b/bec_widgets/widgets/containers/main_window/example_app.ui new file mode 100644 index 00000000..972d5c30 --- /dev/null +++ b/bec_widgets/widgets/containers/main_window/example_app.ui @@ -0,0 +1,42 @@ + + + Form + + + + 0 + 0 + 824 + 1234 + + + + Form + + + + + + + + + + + + + + + + BECDockArea + QWidget +
dock_area
+
+ + Waveform + QWidget +
waveform
+
+
+ + +
diff --git a/bec_widgets/widgets/containers/main_window/general_app.ui b/bec_widgets/widgets/containers/main_window/general_app.ui new file mode 100644 index 00000000..3a70bc77 --- /dev/null +++ b/bec_widgets/widgets/containers/main_window/general_app.ui @@ -0,0 +1,262 @@ + + + MainWindow + + + + 0 + 0 + 1718 + 1139 + + + + MainWindow + + + QTabWidget::TabShape::Rounded + + + + + + + 0 + + + + Dock Area + + + + 2 + + + 1 + + + 2 + + + 2 + + + + + + + + + + + + Visual Studio Code + + + + 2 + + + 1 + + + 2 + + + 2 + + + + + + + + + + + + + + 0 + 0 + 1718 + 31 + + + + + Help + + + + + + + + Theme + + + + + + + + + + + Scan Control + + + 2 + + + + + + + + + + + + BEC Service Status + + + 2 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + Scan Queue + + + 2 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + + + + + + + + + + BEC Docs + + + + + + + + BEC Widgets Docs + + + + + + + + Bug Report + + + + + true + + + Light + + + + + true + + + Dark + + + + + + WebsiteWidget + QWebEngineView +
website_widget
+
+ + BECQueue + QTableWidget +
bec_queue
+
+ + ScanControl + QWidget +
scan_control
+
+ + VSCodeEditor + WebsiteWidget +
vs_code_editor
+
+ + BECStatusBox + QWidget +
bec_status_box
+
+ + BECDockArea + QWidget +
dock_area
+
+ + QWebEngineView + +
QtWebEngineWidgets/QWebEngineView
+
+
+ + +
diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index bc529939..4f2a2ab7 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -1,10 +1,20 @@ +import os +from typing import TYPE_CHECKING + from bec_lib.logger import bec_logger -from qtpy.QtWidgets import QApplication, QMainWindow +from qtpy.QtGui import QAction, QActionGroup +from qtpy.QtWidgets import QApplication, QMainWindow, QStyle from bec_widgets.cli.rpc.rpc_register import RPCRegister +from bec_widgets.utils import UILoader +from bec_widgets.utils.bec_qapp import BECApplication from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.container_utils import WidgetContainerUtils -from bec_widgets.widgets.containers.dock.dock_area import BECDockArea +from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin + +if TYPE_CHECKING: + from bec_widgets.widgets.containers.dock.dock_area import BECDockArea logger = bec_logger.logger @@ -14,6 +24,102 @@ class BECMainWindow(BECWidget, QMainWindow): BECWidget.__init__(self, gui_id=gui_id, **kwargs) QMainWindow.__init__(self, *args, **kwargs) + self.app = QApplication.instance() + + # self._upgrade_qapp() #TODO consider to make upgrade function to any QApplication to BECQApplication + self._init_ui() + + def _init_ui(self): + # Set the window title + self.setWindowTitle("BEC") + + # Set Menu and Status bar + self._setup_menu_bar() + + # BEC Specific UI + self._init_bec_specific_ui() + # self.ui = UILoader + # ui_file_path = os.path.join(os.path.dirname(__file__), "general_app.ui") + # self.load_ui(ui_file_path) + + # TODO can be implemented for toolbar + def load_ui(self, ui_file): + loader = UILoader(self) + self.ui = loader.loader(ui_file) + self.setCentralWidget(self.ui) + + def _init_bec_specific_ui(self): + if getattr(self.app, "is_bec_app", False): + self.statusBar().showMessage(f"App ID: {self.app.gui_id}") + else: + logger.warning( + "Application is not a BECApplication instance. Status bar will not show App ID. Please initialize the application with BECApplication." + ) + + def list_app_hierarchy(self): + """ + List the hierarchy of the application. + """ + self.app.list_hierarchy() + + def _setup_menu_bar(self): + """ + Setup the menu bar for the main window. + """ + menu_bar = self.menuBar() + + ######################################## + # Theme menu + theme_menu = menu_bar.addMenu("Theme") + + theme_group = QActionGroup(self) + light_theme_action = QAction("Light Theme", self, checkable=True) + dark_theme_action = QAction("Dark Theme", self, checkable=True) + theme_group.addAction(light_theme_action) + theme_group.addAction(dark_theme_action) + theme_group.setExclusive(True) + + theme_menu.addAction(light_theme_action) + theme_menu.addAction(dark_theme_action) + + # Connect theme actions + light_theme_action.triggered.connect(lambda: self.change_theme("light")) + dark_theme_action.triggered.connect(lambda: self.change_theme("dark")) + + # Set the default theme + # TODO can be fetched from app + dark_theme_action.setChecked(True) + + ######################################## + # Help menu + help_menu = menu_bar.addMenu("Help") + + help_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxQuestion) + bug_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxInformation) + + bec_docs = QAction("BEC Docs", self) + bec_docs.setIcon(help_icon) + widgets_docs = QAction("BEC Widgets Docs", self) + widgets_docs.setIcon(help_icon) + bug_report = QAction("Bug Report", self) + bug_report.setIcon(bug_icon) + + bec_docs.triggered.connect(BECWebLinksMixin.open_bec_docs) + widgets_docs.triggered.connect(BECWebLinksMixin.open_bec_widgets_docs) + bug_report.triggered.connect(BECWebLinksMixin.open_bec_bug_report) + + help_menu.addAction(bec_docs) + help_menu.addAction(widgets_docs) + help_menu.addAction(bug_report) + + debug_bar = menu_bar.addMenu(f"DEBUG {self.__class__.__name__}") + list_hierarchy = QAction("List App Hierarchy", self) + list_hierarchy.triggered.connect(self.list_app_hierarchy) + debug_bar.addAction(list_hierarchy) + + def change_theme(self, theme): + apply_theme(theme) + def _dump(self): """Return a dictionary with informations about the application state, for use in tests""" # TODO: ModularToolBar and something else leak top-level widgets (3 or 4 QMenu + 2 QWidget); @@ -40,7 +146,7 @@ class BECMainWindow(BECWidget, QMainWindow): def new_dock_area( self, name: str | None = None, geometry: tuple[int, int, int, int] | None = None - ) -> BECDockArea: + ) -> "BECDockArea": """Create a new dock area. Args: @@ -49,6 +155,8 @@ class BECMainWindow(BECWidget, QMainWindow): Returns: BECDockArea: The newly created dock area. """ + from bec_widgets.widgets.containers.dock.dock_area import BECDockArea + with RPCRegister.delayed_broadcast() as rpc_register: existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea) if name is not None: @@ -59,7 +167,7 @@ class BECMainWindow(BECWidget, QMainWindow): else: name = "dock_area" name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas) - dock_area = BECDockArea(name=name) + dock_area = WindowWithUi() # BECDockArea(name=name) dock_area.resize(dock_area.minimumSizeHint()) # TODO Should we simply use the specified name as title here? dock_area.window().setWindowTitle(f"BEC - {name}") @@ -72,3 +180,71 @@ class BECMainWindow(BECWidget, QMainWindow): def cleanup(self): super().close() + + +class WindowWithUi(BECMainWindow): + """ + This is just testing app wiht UI file which could be connected to RPC. + + """ + + USER_ACCESS = [ + "new_dock_area", + "all_connections", + "change_theme", + "dock_area", + "register_all_rpc", + "widget_list", + "list_app_hierarchy", + ] + + def __init__(self, *args, name: str = None, **kwargs): + super().__init__(*args, **kwargs) + if name is None: + name = self.__class__.__name__ + else: + if not WidgetContainerUtils.has_name_valid_chars(name): + raise ValueError(f"Name {name} contains invalid characters.") + self._name = name if name else self.__class__.__name__ + ui_file_path = os.path.join(os.path.dirname(__file__), "example_app.ui") + self.load_ui(ui_file_path) + + def load_ui(self, ui_file): + loader = UILoader(self) + self.ui = loader.loader(ui_file) + self.setCentralWidget(self.ui) + + # TODO actually these propertiers are not much exposed now in the real CLI + @property + def dock_area(self): + dock_area = self.ui.dock_area + return dock_area + + @property + def all_connections(self) -> list: + all_connections = self.rpc_register.list_all_connections() + all_connections_keys = list(all_connections.keys()) + return all_connections_keys + + def register_all_rpc(self): + app = QApplication.instance() + app.register_all() + + @property + def widget_list(self) -> list: + """Return a list of all widgets in the application.""" + app = QApplication.instance() + all_widgets = app.list_all_bec_widgets() + return all_widgets + + +if __name__ == "__main__": + import sys + + app = QApplication(sys.argv) + print(id(app)) + # app = BECApplication(sys.argv) + # print(id(app)) + main_window = WindowWithUi() + main_window.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/plots/multi_waveform/toolbar_bundles/monitor_selection.py b/bec_widgets/widgets/plots/multi_waveform/toolbar_bundles/monitor_selection.py index 4ba2eaf8..434da400 100644 --- a/bec_widgets/widgets/plots/multi_waveform/toolbar_bundles/monitor_selection.py +++ b/bec_widgets/widgets/plots/multi_waveform/toolbar_bundles/monitor_selection.py @@ -29,7 +29,9 @@ class MultiWaveformSelectionToolbarBundle(ToolbarBundle): # Monitor Selection self.monitor = DeviceComboBox( - device_filter=BECDeviceFilter.DEVICE, readout_priority_filter=ReadoutPriority.ASYNC + device_filter=BECDeviceFilter.DEVICE, + readout_priority_filter=ReadoutPriority.ASYNC, + parent_id=self.target_widget.gui_id, ) self.monitor.addItem("", None) self.monitor.setCurrentText("") @@ -38,7 +40,7 @@ class MultiWaveformSelectionToolbarBundle(ToolbarBundle): self.add_action("monitor", WidgetAction(widget=self.monitor, adjust_size=False)) # Colormap Selection - self.colormap_widget = BECColorMapWidget(cmap="magma") + self.colormap_widget = BECColorMapWidget(cmap="magma", parent_id=self.target_widget.gui_id) self.add_action("color_map", WidgetAction(widget=self.colormap_widget, adjust_size=False)) # Connect slots, a device will be connected upon change of any combobox diff --git a/bec_widgets/widgets/plots/waveform/waveform.py b/bec_widgets/widgets/plots/waveform/waveform.py index c9970eb8..d683e57c 100644 --- a/bec_widgets/widgets/plots/waveform/waveform.py +++ b/bec_widgets/widgets/plots/waveform/waveform.py @@ -18,6 +18,7 @@ from bec_widgets.utils.colors import Colors, set_theme from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.settings_dialog import SettingsDialog from bec_widgets.utils.toolbar import MaterialIconAction +from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog from bec_widgets.widgets.plots.plot_base import PlotBase from bec_widgets.widgets.plots.waveform.curve import Curve, CurveConfig, DeviceSignal @@ -1736,7 +1737,7 @@ class Waveform(PlotBase): super().cleanup() -class DemoApp(QMainWindow): # pragma: no cover +class DemoApp(BECMainWindow): # pragma: no cover def __init__(self): super().__init__() self.setWindowTitle("Waveform Demo") @@ -1759,9 +1760,9 @@ class DemoApp(QMainWindow): # pragma: no cover if __name__ == "__main__": # pragma: no cover import sys - from qtpy.QtWidgets import QApplication + from bec_widgets.utils.bec_qapp import BECApplication - app = QApplication(sys.argv) + app = BECApplication(sys.argv) set_theme("dark") widget = DemoApp() widget.show() diff --git a/tests/end-2-end/test_bec_dock_rpc_e2e.py b/tests/end-2-end/test_bec_dock_rpc_e2e.py index 01758902..e78f43e3 100644 --- a/tests/end-2-end/test_bec_dock_rpc_e2e.py +++ b/tests/end-2-end/test_bec_dock_rpc_e2e.py @@ -144,7 +144,7 @@ def test_ring_bar(qtbot, connected_client_gui_obj): def test_rpc_gui_obj(connected_client_gui_obj, qtbot): gui = connected_client_gui_obj - assert len(gui.windows) == 1 + qtbot.waitUntil(lambda: len(gui.windows) == 1, timeout=3000) assert gui.windows["bec"] is gui.bec mw = gui.bec assert mw.__class__.__name__ == "RPCReference" @@ -155,22 +155,6 @@ def test_rpc_gui_obj(connected_client_gui_obj, qtbot): assert gui._ipython_registry[xw._gui_id].__class__.__name__ == "BECDockArea" assert len(gui.windows) == 2 - gui_info = gui._dump() - mw_info = gui_info[mw._gui_id] - assert mw_info["title"] == "BEC" - assert mw_info["visible"] - xw_info = gui_info[xw._gui_id] - assert xw_info["title"] == "BEC - X" - assert xw_info["visible"] - - gui.hide() - gui_info = gui._dump() # - assert not any(windows["visible"] for windows in gui_info.values()) - - gui.show() - gui_info = gui._dump() - assert all(windows["visible"] for windows in gui_info.values()) - assert gui._gui_is_alive() gui.kill_server() assert not gui._gui_is_alive() @@ -186,10 +170,8 @@ def test_rpc_gui_obj(connected_client_gui_obj, qtbot): qtbot.waitUntil(wait_for_gui_started, timeout=3000) # gui.windows should have bec with gui_id 'bec' assert len(gui.windows) == 1 - assert gui.windows["bec"]._gui_id == mw._gui_id + # communication should work, main dock area should have same id and be visible - gui_info = gui._dump() - assert gui_info[mw._gui_id]["visible"] yw = gui.new("Y") assert len(gui.windows) == 2 diff --git a/tests/end-2-end/test_plotting_framework_e2e.py b/tests/end-2-end/test_plotting_framework_e2e.py index 2e647ed8..c09ef82d 100644 --- a/tests/end-2-end/test_plotting_framework_e2e.py +++ b/tests/end-2-end/test_plotting_framework_e2e.py @@ -60,18 +60,14 @@ def test_rpc_plotting_shortcuts_init_configs(qtbot, connected_client_gui_obj): # check if the correct devices are set # Curve - assert c1._config["signal"] == { + assert c1._config_dict["signal"] == { "dap": None, "name": "bpm4i", "entry": "bpm4i", "dap_oversample": 1, } - assert c1._config["source"] == "device" - assert c1._config["label"] == "bpm4i-bpm4i" - - # Image Item - assert im_item._config["monitor"] == "eiger" - assert im_item._config["source"] == "auto" + assert c1._config_dict["source"] == "device" + assert c1._config_dict["label"] == "bpm4i-bpm4i" def test_rpc_waveform_scan(qtbot, bec_client_lib, connected_client_gui_obj): @@ -93,6 +89,7 @@ def test_rpc_waveform_scan(qtbot, bec_client_lib, connected_client_gui_obj): status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False) status.wait() + # FIXME if this gets flaky, we wait for status.scan.scan_id to be in client.history[-1] and then fetch data from history item = queue.scan_storage.storage[-1] last_scan_data = item.live_data if hasattr(item, "live_data") else item.data diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index c3f616c9..23dfd303 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -28,8 +28,10 @@ def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unus print("Test failed, skipping cleanup checks") return - testable_qtimer_class.check_all_stopped(qtbot) + # qapp = BECApplication() + # qapp.shutdown() + testable_qtimer_class.check_all_stopped(qtbot) qapp = QApplication.instance() qapp.processEvents() if hasattr(qapp, "os_listener") and qapp.os_listener: diff --git a/tests/unit_tests/test_client_utils.py b/tests/unit_tests/test_client_utils.py index 12ce2434..8c6ff3bb 100644 --- a/tests/unit_tests/test_client_utils.py +++ b/tests/unit_tests/test_client_utils.py @@ -31,7 +31,7 @@ def test_rpc_call_new_dock(cli_dock_area): ) def test_client_utils_start_plot_process(config, call_config): with mock.patch("bec_widgets.cli.client_utils.subprocess.Popen") as mock_popen: - _start_plot_process("gui_id", BECDockArea, "bec", config) + _start_plot_process("gui_id", "bec", config, gui_class="BECDockArea") command = [ "bec-gui-server", "--id", @@ -82,7 +82,6 @@ def test_client_utils_passes_client_config_to_server(bec_dispatcher): ) # the started event will not be set, wait=True would block forever mock_start_plot.assert_called_once_with( "gui_id", - BECGuiClient, gui_class_id="bec", config=mixin._client._service_config.config, logger=mock.ANY, diff --git a/tests/unit_tests/test_rpc_server.py b/tests/unit_tests/test_rpc_server.py index 5056948a..6234440a 100644 --- a/tests/unit_tests/test_rpc_server.py +++ b/tests/unit_tests/test_rpc_server.py @@ -1,45 +1,56 @@ +import argparse from unittest import mock import pytest +from bec_lib.service_config import ServiceConfig -from bec_widgets.cli.server import _start_server -from bec_widgets.widgets.containers.dock import BECDockArea +from bec_widgets.cli.server import GUIServer @pytest.fixture -def mocked_cli_server(): - with mock.patch("bec_widgets.cli.server.BECWidgetsCLIServer") as mock_server: - with mock.patch("bec_widgets.cli.server.ServiceConfig") as mock_config: - with mock.patch("bec_lib.logger.bec_logger.configure") as mock_logger: - yield mock_server, mock_config, mock_logger +def gui_server(): + args = argparse.Namespace( + config=None, id="gui_id", gui_class="LaunchWindow", gui_class_id="bec", hide=False + ) + return GUIServer(args=args) -def test_rpc_server_start_server_without_service_config(mocked_cli_server): +def test_gui_server_start_server_without_service_config(gui_server): """ Test that the server is started with the correct arguments. """ - mock_server, mock_config, _ = mocked_cli_server + assert gui_server.config is None + assert gui_server.gui_id == "gui_id" + assert gui_server.gui_class == "LaunchWindow" + assert gui_server.gui_class_id == "bec" + assert gui_server.hide is False - _start_server("gui_id", BECDockArea, config=None) - mock_server.assert_called_once_with( - gui_id="gui_id", config=mock_config(), gui_class=BECDockArea, gui_class_id="bec" - ) + +def test_gui_server_get_service_config(gui_server): + """ + Test that the server is started with the correct arguments. + """ + assert gui_server._get_service_config().config is ServiceConfig().config @pytest.mark.parametrize( - "config, call_config", + "connections, hide", [ - ("/path/to/config.yml", {"config_path": "/path/to/config.yml"}), - ({"key": "value"}, {"config": {"key": "value"}}), + ({}, False), + ({"launcher": mock.MagicMock()}, False), + ({"launcher": mock.MagicMock(), "dock_area": mock.MagicMock()}, True), ], ) -def test_rpc_server_start_server_with_service_config(mocked_cli_server, config, call_config): - """ - Test that the server is started with the correct arguments. - """ - mock_server, mock_config, _ = mocked_cli_server - config = mock_config(**call_config) - _start_server("gui_id", BECDockArea, config=config) - mock_server.assert_called_once_with( - gui_id="gui_id", config=config, gui_class=BECDockArea, gui_class_id="bec" - ) +def test_gui_server_turns_off_the_lights(gui_server, connections, hide): + with mock.patch.object(gui_server, "launcher_window") as mock_launcher_window: + with mock.patch.object(gui_server, "app") as mock_app: + gui_server._turn_off_the_lights(connections) + + if not hide: + mock_launcher_window.show.assert_called_once() + mock_launcher_window.activateWindow.assert_called_once() + mock_launcher_window.raise_.assert_called_once() + mock_app.setQuitOnLastWindowClosed.assert_called_once_with(True) + else: + mock_launcher_window.hide.assert_called_once() + mock_app.setQuitOnLastWindowClosed.assert_called_once_with(False)