diff --git a/bec_widgets/applications/views/developer_view/developer_view.py b/bec_widgets/applications/views/developer_view/developer_view.py index 38b27d434..3dbda099a 100644 --- a/bec_widgets/applications/views/developer_view/developer_view.py +++ b/bec_widgets/applications/views/developer_view/developer_view.py @@ -133,8 +133,4 @@ def get_ide_plotting(): exclusive=True, ) _app.show() - # developer_view.show() - # developer_view.setWindowTitle("Developer View") - # developer_view.resize(1920, 1080) - # developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime sys.exit(app.exec_()) diff --git a/bec_widgets/applications/views/developer_view/developer_widget.py b/bec_widgets/applications/views/developer_view/developer_widget.py index a30ca295d..e5f1f9a44 100644 --- a/bec_widgets/applications/views/developer_view/developer_widget.py +++ b/bec_widgets/applications/views/developer_view/developer_widget.py @@ -4,9 +4,10 @@ import markdown from bec_lib.endpoints import MessageEndpoints +from bec_lib.messages import ProcedureRequestMessage from bec_lib.script_executor import upload_script from bec_qthemes import material_icon -from qtpy.QtGui import QKeySequence, QShortcut +from qtpy.QtGui import QKeySequence, QShortcut # type: ignore from qtpy.QtWidgets import QTextEdit from bec_widgets.utils.error_popups import SafeSlot @@ -16,6 +17,7 @@ from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea from bec_widgets.widgets.containers.qt_ads import CDockWidget +from bec_widgets.widgets.control.procedure_control.procedure_panel import ProcedurePanel from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget from bec_widgets.widgets.editors.web_console.web_console import BECShell, WebConsole @@ -125,6 +127,9 @@ def __init__(self, parent=None, **kwargs): self._current_script_id: str | None = None self.script_editor_tab = None + self.procedures = ProcedurePanel(self) + self.procedures.setObjectName("Procedure Control") + self._initialize_layout() # Connect editor signals @@ -183,24 +188,16 @@ def _initialize_layout(self) -> None: ) # Plotting area on the right with signature help tabbed alongside - self.plotting_ads_dock = self.new( - self.plotting_ads, - where="right", - closable=False, - floatable=False, - movable=False, - return_dock=True, - title_buttons={"float": True}, - ) - self.signature_dock = self.new( - self.signature_help, - closable=False, - floatable=False, - movable=False, - tab_with=self.plotting_ads_dock, - return_dock=True, - title_buttons={"float": False, "close": False}, - ) + _r_panel = { + "closable": False, + "floatable": False, + "movable": False, + "return_dock": True, + "title_buttons": {"float": True}, + } + self.plotting_dock = self.new(self.plotting_ads, where="right", **_r_panel) + self.signature_dock = self.new(self.signature_help, **_r_panel, tab_with=self.plotting_dock) + self.procedure_dock = self.new(self.procedures, **_r_panel, tab_with=self.plotting_dock) self.set_layout_ratios(horizontal=[2, 5, 3], vertical=[7, 3]) @@ -233,6 +230,16 @@ def init_developer_toolbar(self): run_action.action.triggered.connect(self.on_execute) self.toolbar.components.add_safe("run", run_action) + submit_action = MaterialIconAction( + icon_name="animated_images", + tooltip="Run current file as a BEC procedure", + label_text="Run on server", + filled=True, + parent=self, + ) + submit_action.action.triggered.connect(self.on_submit_procedure) + self.toolbar.components.add_safe("run_proc", submit_action) + stop_action = MaterialIconAction( icon_name="stop", tooltip="Stop current execution", @@ -246,6 +253,7 @@ def init_developer_toolbar(self): execution_bundle = ToolbarBundle("execution", self.toolbar.components) execution_bundle.add_action("run") execution_bundle.add_action("stop") + execution_bundle.add_action("run_proc") self.toolbar.add_bundle(execution_bundle) vim_action = MaterialIconAction( @@ -305,24 +313,41 @@ def _on_save_enabled_update(self, enabled: bool): self.toolbar.components.get_action("save").action.setEnabled(enabled) self.toolbar.components.get_action("save_as").action.setEnabled(enabled) - @SafeSlot() - def on_execute(self): - """Upload and run the currently focused script in the Monaco editor.""" + def _try_upload(self) -> str | None: self.script_editor_tab = self.monaco.last_focused_editor if not self.script_editor_tab: - return - widget = self.script_editor_tab.widget() - if not isinstance(widget, MonacoWidget): - return + return None + if not isinstance(widget := self.script_editor_tab.widget(), MonacoWidget): + return None if widget.modified: # Save the file before execution if there are unsaved changes self.monaco.save_file() if widget.modified: # If still modified, user likely cancelled save dialog - return - self.current_script_id = upload_script(self.client.connector, widget.get_text()) - self.console.write(f'bec._run_script("{self.current_script_id}")') + return None + return upload_script(self.client.connector, widget.get_text()) + + @SafeSlot() + def on_execute(self): + """Upload and run the currently focused script in the Monaco editor.""" print(f"Uploaded script with ID: {self.current_script_id}") + if (script_id := self._try_upload()) is not None: + self.current_script_id = script_id + self.console.write(f'bec._run_script("{self.current_script_id}")') + print(f"Uploaded script with ID: {self.current_script_id}") + + @SafeSlot() + def on_submit_procedure(self): + """Upload and run the currently focused script in the Monaco editor as a procedure.""" + if (script_id := self._try_upload()) is not None: + self.current_script_id = script_id + print(f"Uploaded script with ID: {self.current_script_id}") + self.client.connector.xadd( + MessageEndpoints.procedure_request(), + ProcedureRequestMessage( + identifier="run_script", args_kwargs=((self.current_script_id,), {}) + ).model_dump(), + ) @SafeSlot() def on_stop(self): diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 622f8cb50..6e5042da5 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -4787,6 +4787,188 @@ def screenshot(self, file_name: "str | None" = None): """ +class ProcedurePanel(RPCBase): + @rpc_call + def new( + self, + widget: "QWidget | str", + *, + closable: "bool" = True, + floatable: "bool" = True, + movable: "bool" = True, + start_floating: "bool" = False, + floating_state: "Mapping[str, object] | None" = None, + where: "Literal['left', 'right', 'top', 'bottom'] | None" = None, + on_close: "Callable[[CDockWidget, QWidget], None] | None" = None, + tab_with: "CDockWidget | QWidget | str | None" = None, + relative_to: "CDockWidget | QWidget | str | None" = None, + return_dock: "bool" = False, + show_title_bar: "bool | None" = None, + title_buttons: "Mapping[str, bool] | Sequence[str] | str | None" = None, + show_settings_action: "bool | None" = False, + promote_central: "bool" = False, + dock_icon: "QIcon | None" = None, + apply_widget_icon: "bool" = True, + object_name: "str | None" = None, + **widget_kwargs, + ) -> "QWidget | CDockWidget | BECWidget": + """ + Create a new widget (or reuse an instance) and add it as a dock. + + Args: + widget(QWidget | str): Instance or registered widget type string. + closable(bool): Whether the dock is closable. + floatable(bool): Whether the dock is floatable. + movable(bool): Whether the dock is movable. + start_floating(bool): Whether to start the dock floating. + floating_state(Mapping | None): Optional floating geometry metadata to apply when floating. + where(Literal["left", "right", "top", "bottom"] | None): Dock placement hint relative to the dock area (ignored when + ``relative_to`` is provided without an explicit value). + on_close(Callable[[CDockWidget, QWidget], None] | None): Optional custom close handler accepting (dock, widget). + tab_with(CDockWidget | QWidget | str | None): Existing dock (or widget/name) to tab the new dock alongside. + relative_to(CDockWidget | QWidget | str | None): Existing dock (or widget/name) used as the positional anchor. + When supplied and ``where`` is ``None``, the new dock inherits the + anchor's current dock area. + return_dock(bool): When True, return the created dock instead of the widget. + show_title_bar(bool | None): Explicitly show or hide the dock area's title bar. + title_buttons(Mapping[str, bool] | Sequence[str] | str | None): Mapping or iterable describing which title bar buttons should + remain visible. Provide a mapping of button names (``"float"``, + ``"close"``, ``"menu"``, ``"auto_hide"``, ``"minimize"``) to booleans, + or a sequence of button names to hide. + show_settings_action(bool | None): Control whether a dock settings/property action should + be installed. Defaults to ``False`` for the basic dock area; subclasses + such as `AdvancedDockArea` override the default to ``True``. + promote_central(bool): When True, promote the created dock to be the dock manager's + central widget (useful for editor stacks or other root content). + dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``. + Provide a `QIcon` (e.g. from ``material_icon``). When ``None`` (default), + the widget's ``ICON_NAME`` attribute is used when available. + apply_widget_icon(bool): When False, skip automatically resolving the icon from + the widget's ``ICON_NAME`` (useful for callers who want no icon and do not pass one explicitly). + object_name(str | None): Optional object name to assign to the created widget. + **widget_kwargs: Additional keyword arguments passed to the widget constructor + when creating by type name. + + Returns: + The widget instance by default, or the created `CDockWidget` when `return_dock` is True. + """ + + @rpc_call + def dock_map(self) -> "dict[str, CDockWidget]": + """ + Return the dock widgets map as dictionary with names as keys. + """ + + @rpc_call + def dock_list(self) -> "list[CDockWidget]": + """ + Return the list of dock widgets. + """ + + @rpc_call + def widget_map(self, bec_widgets_only: "bool" = True) -> "dict[str, QWidget]": + """ + Return a dictionary mapping widget names to their corresponding widgets. + + Args: + bec_widgets_only(bool): If True, only include widgets that are BECConnector instances. + """ + + @rpc_call + def widget_list(self, bec_widgets_only: "bool" = True) -> "list[QWidget]": + """ + Return a list of widgets contained in the dock area. + + Args: + bec_widgets_only(bool): If True, only include widgets that are BECConnector instances. + """ + + @rpc_call + def attach_all(self): + """ + Re-attach floating docks back into the dock manager. + """ + + @rpc_call + def delete_all(self): + """ + Delete all docks and their associated widgets. + """ + + @rpc_call + def delete(self, object_name: "str") -> "bool": + """ + Remove a widget from the dock area by its object name. + + Args: + object_name: The object name of the widget to remove. + + Returns: + bool: True if the widget was found and removed, False otherwise. + + Raises: + ValueError: If no widget with the given object name is found. + + Example: + >>> dock_area.delete("my_widget") + True + """ + + @rpc_call + def set_layout_ratios( + self, + *, + horizontal: "Sequence[float] | Mapping[int | str, float] | None" = None, + vertical: "Sequence[float] | Mapping[int | str, float] | None" = None, + splitter_overrides: "Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]] | None" = None, + ) -> "None": + """ + Adjust splitter ratios in the dock layout. + + Args: + horizontal: Weights applied to every horizontal splitter encountered. + vertical: Weights applied to every vertical splitter encountered. + splitter_overrides: Optional overrides targeting specific splitters identified + by their index path (e.g. ``{0: [1, 2], (1, 0): [3, 5]}``). Paths are zero-based + indices following the splitter hierarchy, starting from the root splitter. + + Example: + To build three columns with custom per-column ratios:: + + area.set_layout_ratios( + horizontal=[1, 2, 1], # column widths + splitter_overrides={ + 0: [1, 2], # column 0 (two rows) + 1: [3, 2, 1], # column 1 (three rows) + 2: [1], # column 2 (single row) + }, + ) + """ + + @rpc_call + def describe_layout(self) -> "list[dict[str, Any]]": + """ + Return metadata describing splitter paths, orientations, and contained docks. + + Useful for determining the keys to use in `set_layout_ratios(splitter_overrides=...)`. + """ + + @rpc_call + def print_layout_structure(self) -> "None": + """ + Pretty-print the current splitter paths to stdout. + """ + + @rpc_call + def set_central_dock(self, dock: "CDockWidget | QWidget | str") -> "None": + """ + Promote an existing dock to be the dock manager's central widget. + + Args: + dock(CDockWidget | QWidget | str): Dock reference to promote. + """ + + class RectangularROI(RPCBase): """Defines a rectangular Region of Interest (ROI) with additional functionality.""" diff --git a/bec_widgets/widgets/control/procedure_control/__init__.py b/bec_widgets/widgets/control/procedure_control/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/widgets/control/procedure_control/procedure_control.py b/bec_widgets/widgets/control/procedure_control/procedure_control.py new file mode 100644 index 000000000..e78bcf2c0 --- /dev/null +++ b/bec_widgets/widgets/control/procedure_control/procedure_control.py @@ -0,0 +1,341 @@ +import operator +from functools import partial, reduce +from typing import Literal + +from bec_lib.endpoints import MessageEndpoints +from bec_lib.logger import bec_logger +from bec_lib.messages import ( + ProcedureExecutionMessage, + ProcedureQNotifMessage, + ProcedureRequestMessage, +) +from bec_lib.procedures.helper import FrontendProcedureHelper +from bec_qthemes._icon.material_icons import material_icon +from pydantic import BaseModel, ConfigDict +from qtpy.QtCore import QSize, Qt, Signal +from qtpy.QtWidgets import ( + QDialog, + QDialogButtonBox, + QHBoxLayout, + QPushButton, + QToolButton, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.bec_connector import ConnectionConfig +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeSlot + +logger = bec_logger.logger + +_icon = partial(material_icon, size=(20, 20), convert_to_pixmap=False, filled=False) + +_ActionTypes = Literal["abort", "delete", "resubmit"] + + +class _BaseConfig(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + actions: set[_ActionTypes] + child_actions: set[_ActionTypes] + actions_column: int = 3 + params_column: int = 2 + helper: FrontendProcedureHelper + tree: QTreeWidget + active_queue: bool = False + + +class _QueueConfig(BaseModel): + queue: str + base: _BaseConfig + msgs: list[ProcedureExecutionMessage] + + +class _ItemConfig(BaseModel): + base: _BaseConfig + msg: ProcedureExecutionMessage + + +class _ActionItem(QTreeWidgetItem): + ABORT_BUTTON_COLOR = DELETE_BUTTON_COLOR = "#CC181E" + RESUBMIT_BUTTON_COLOR = "#2266BB" + ACTION_TYPE: Literal["parent", "child"] = "child" + + def __init__(self, parent, strings: list[str], config: _BaseConfig): + super().__init__(parent, strings) + self._tree = config.tree + self._config = config + self._init_actions() + + def _init_actions(self): + """Create the actions widget in the given column.""" + self.actions_widget = QWidget() + actions_layout = QHBoxLayout(self.actions_widget) + actions_layout.setContentsMargins(0, 0, 0, 0) + actions_layout.setSpacing(0) + + def button(icon, color, slot, tooltip): + button = QToolButton(self.actions_widget) + setattr(self, icon, button) + icon = _icon(icon, color=color) + button.setIcon(icon) + button.clicked.connect(slot) + actions_layout.addWidget(button) + button.setToolTip(tooltip) + + actions = ( + self._config.actions if self.ACTION_TYPE == "parent" else self._config.child_actions + ) + if "abort" in actions: + button("cancel_presentation", self.ABORT_BUTTON_COLOR, self._abort_self, "abort") + if "delete" in actions: + button("delete", self.DELETE_BUTTON_COLOR, self._delete_self, "delete") + if "resubmit" in actions: + button("autorenew", self.RESUBMIT_BUTTON_COLOR, self._resubmit_self, "resubmit") + + self._tree.setItemWidget(self, self._config.actions_column, self.actions_widget) + + @SafeSlot() + def _abort_self(self): ... + @SafeSlot() + def _delete_self(self): ... + @SafeSlot() + def _resubmit_self(self): ... + + +class JobItem(_ActionItem): + def __init__(self, parent, strings: list[str], config: _ItemConfig): + super().__init__(parent, strings, config.base) + self._msg = config.msg + self._init_params_display() + + def queue(self): + return self._msg.queue + + def _init_params_display(self): + self.setText(self._config.params_column, self._short_params_text()) + self.setToolTip(self._config.params_column, self._long_params_html()) + + def _short_params_text(self): + a, k = self._msg.args_kwargs + args = f"{a}, " if a else "" + kwargs = f"{k}".strip("{}") if k else "" + return args + kwargs + + def _long_params_html(self): + a, k = self._msg.args_kwargs + args = "Positional arguments:
" + ", ".join(str(arg) for arg in a) if a else "" + kwargs = ( + reduce( + operator.add, + (f" {k}: {v}
" for k, v in k.items()), + "Keyword arguments:
", + ) + if k + else "" + ) + return args + kwargs + + @SafeSlot() + def _abort_self(self): + self._config.helper.request.abort_execution(self._msg.execution_id) + + @SafeSlot() + def _delete_self(self): + self._config.helper.request.clear_unhandled_execution(self._msg.execution_id) + + @SafeSlot() + def _resubmit_self(self): + self._config.helper.request.clear_unhandled_execution(self._msg.execution_id) + self._config.helper.request.procedure( + identifier=self._msg.identifier, + queue=self._msg.queue, + args_kwargs=self._msg.args_kwargs, + ) + + +class QueueItem(_ActionItem): + ACTION_TYPE = "parent" + + def __init__(self, parent, strings: list[str], config: _QueueConfig): + super().__init__(parent, strings, config.base) + self._queue = config.queue + self.update(config.msgs) + + def clear(self): + for i in reversed(range(self.childCount())): + self.removeChild(self.child(i)) + + def update(self, msgs: list[ProcedureExecutionMessage]): + if self._config.active_queue: + active = self._config.helper.get.running_procedures() + for msg in active: + if msg.queue == self._queue: + JobItem( + self, [msg.identifier, "RUNNING"], _ItemConfig(base=self._config, msg=msg) + ) + for msg in msgs: + JobItem( + self, + [msg.identifier, "PENDING" if self._config.active_queue else "ABORTED"], + _ItemConfig(base=self._config, msg=msg), + ) + + def queue(self): + return self._queue + + @SafeSlot() + def _abort_self(self): + self._config.helper.request.abort_queue(self._queue) + + @SafeSlot() + def _delete_self(self): + self._config.helper.request.clear_unhandled_queue(self._queue) + + +class CategoryItem(QTreeWidgetItem): + def __init__(self, parent, strings: list[str], config: _BaseConfig): + super().__init__(parent, strings) + self._queues: dict[str, QueueItem] = {} + self._tree: QTreeWidget = parent + self._config = config + + def update(self, queue: str, msgs: list[ProcedureExecutionMessage]): + if (queue_item := self._queues.get(queue)) is not None: + queue_item.clear() + queue_item.update(msgs) + if queue_item.childCount() == 0: + self.removeChild(queue_item) + del self._queues[queue] + elif msgs: + self._queues[queue] = QueueItem( + self, [queue], _QueueConfig(base=self._config, queue=queue, msgs=msgs) + ) + self._queues[queue].setExpanded(True) + + +class ProcedureControl(BECWidget, QWidget): + + RPC = False + + queue_selected = Signal(str) + + def __init__(self, parent=None, client=None, config=None, gui_id: str | None = None, **kwargs): + config = config or ConnectionConfig() + super().__init__(parent=parent, client=client, config=config, gui_id=gui_id, **kwargs) + self._conn = self.bec_dispatcher.client.connector + self._helper = FrontendProcedureHelper(self._conn) + self._setup_ui() + self.bec_dispatcher.connect_slot(self._update, MessageEndpoints.procedure_queue_notif()) + self._init_queues() + self._content.itemSelectionChanged.connect(self.on_selection_changed) + + def on_selection_changed(self): + selected_items = self._content.selectedItems() + if len(selected_items) != 1: + self.queue_selected.emit("") + return + if isinstance((item := selected_items[0]), (QueueItem, JobItem)): + self.queue_selected.emit(item.queue()) + return + self.queue_selected.emit("") + + @SafeSlot(ProcedureQNotifMessage, dict) + def _update(self, msg: dict | ProcedureQNotifMessage, _): + msg = ProcedureQNotifMessage.model_validate(msg) + if msg.queue_type == "execution": + cat_to_update = self._active_queues + read_queue = self._helper.get.exec_queue + else: + cat_to_update = self._unhandled_queues + read_queue = self._helper.get.unhandled_queue + cat_to_update.update(msg.queue_name, read_queue(msg.queue_name)) + + def _setup_ui(self): + self._layout = QVBoxLayout() + self.setLayout(self._layout) + + self._content = QTreeWidget() + self._content.setAlternatingRowColors(True) + self._content.setHeaderLabels(["name", "status", "params", "actions"]) + self._layout.addWidget(self._content) + + config = partial(_BaseConfig, helper=self._helper, tree=self._content, actions_column=3) + + self._active_queues = CategoryItem( + self._content, + ["active queues"], + config(actions={"abort"}, child_actions={"abort"}, active_queue=True), + ) + self._content.addTopLevelItem(self._active_queues) + self._active_queues.setExpanded(True) + + self._unhandled_queues = CategoryItem( + self._content, + ["unhandled queues"], + config(actions={"delete"}, child_actions={"delete", "resubmit"}), + ) + self._content.addTopLevelItem(self._unhandled_queues) + self._active_queues.setExpanded(True) + + def _init_queues(self): + for queue in self._helper.get.active_and_pending_queue_names(): + self._active_queues.update(queue, self._helper.get.exec_queue(queue)) + for queue in self._helper.get.queue_names("unhandled"): + self._unhandled_queues.update(queue, self._helper.get.unhandled_queue(queue)) + + +class ProcedureSubmissionOptionsDialog(QDialog): + """ + Dialog to customize procedure options + """ + + def __init__(self, parent=None, client=None): + super().__init__(parent) + self.setWindowTitle("Procedure execution options") + + self._setup_ui() + + def sizeHint(self) -> QSize: + return QSize(600, 800) + + def _setup_ui(self): + """Setup the dialog UI with ScanControl widget and buttons.""" + layout = QVBoxLayout(self) + + # Create the scan control widget + + # Create dialog buttons + button_box = QDialogButtonBox(Qt.Orientation.Horizontal, self) + + # Create custom buttons with appropriate text + insert_button = QPushButton("Insert") + cancel_button = QPushButton("Cancel") + + button_box.addButton(insert_button, QDialogButtonBox.ButtonRole.AcceptRole) + button_box.addButton(cancel_button, QDialogButtonBox.ButtonRole.RejectRole) + + layout.addWidget(button_box) + + # Connect button signals + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + + def accept(self): + """Override accept to generate code before closing.""" + super().accept() + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + widget = ProcedureControl() + widget.setFixedWidth(800) + widget.setFixedHeight(800) + widget.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/control/procedure_control/procedure_logs.py b/bec_widgets/widgets/control/procedure_control/procedure_logs.py new file mode 100644 index 000000000..6285d011f --- /dev/null +++ b/bec_widgets/widgets/control/procedure_control/procedure_logs.py @@ -0,0 +1,125 @@ +from bec_lib.endpoints import MessageEndpoints +from bec_lib.logger import bec_logger +from bec_lib.procedures.helper import FrontendProcedureHelper +from bec_qthemes import material_icon +from qtpy.QtGui import QFont +from qtpy.QtWidgets import ( + QComboBox, + QHBoxLayout, + QLabel, + QSizePolicy, + QTextEdit, + QToolButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.bec_connector import ConnectionConfig +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeProperty, SafeSlot + +logger = bec_logger.logger + + +class ProcedureLogs(BECWidget, QWidget): + + RPC = False + + def __init__(self, parent=None, client=None, config=None, gui_id: str | None = None, **kwargs): + config = config or ConnectionConfig() + super().__init__(parent=parent, client=client, config=config, gui_id=gui_id, **kwargs) + self._conn = self.bec_dispatcher.client.connector + self._queue: str | None = None + self._helper = FrontendProcedureHelper(self._conn) + self._setup_ui() + + @SafeSlot() + def _update_selection_box(self): + self._selection_box.clear() + self._available_streams = self._helper.get.log_queue_names() + self._selection_box.addItems(self._available_streams) + + def _setup_ui(self): + self._layout = QVBoxLayout() + self.setLayout(self._layout) + self._setup_tools() + self._setup_display() + + def _setup_tools(self): + self.tools = QWidget(self) + self._tools_layout = QHBoxLayout() + self._tools_layout.setContentsMargins(0, 0, 0, 0) + self.tools.setLayout(self._tools_layout) + self._selection_box = QComboBox() + self._update_selection_box() + self._selection_box.setCurrentIndex(-1) + self._selection_box.currentTextChanged.connect(self.set_queue) + self._refresh_button = QToolButton() + self._refresh_button.setIcon(material_icon("refresh", convert_to_pixmap=False)) + self._tools_layout.addWidget(QLabel("Select logs stream: ")) + self._tools_layout.addWidget(self._selection_box) + self._tools_layout.addWidget(self._refresh_button) + self._refresh_button.clicked.connect(self._update_selection_box) + self._layout.addWidget(self.tools) + + def _setup_display(self): + self.widget = QTextEdit(lineWrapMode=QTextEdit.LineWrapMode.NoWrap, readOnly=True) + font = QFont("Courier New") + font.setStyleHint(QFont.StyleHint.Monospace) + self.widget.setFont(font) + self.widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self._layout.addWidget(self.widget) + + @SafeSlot(dict, dict) + def _update(self, msg, _): + self.widget.append(msg.get("data").strip()) + + def _init_content(self): + self.widget.setText("") + if self._queue is None: + return + if msgs := self._conn.xread(MessageEndpoints.procedure_logs(self._queue), from_start=True): + self.widget.append("\n".join(msg.get("data").data.strip() for msg in msgs)) + + @SafeSlot() + def clear_selection_box(self, *_, **__): + self._selection_box.setCurrentIndex(-1) + + @SafeSlot(None) + @SafeSlot(str) + def set_queue(self, queue: str | None): + if queue == "": + return + self.queue = queue + self._selection_box.setCurrentIndex(-1) + + @SafeProperty(str) + def queue(self) -> str | None: + return self._queue + + @queue.setter + def queue(self, queue: str | None) -> None: + if self._queue == queue: + return + if self._queue is not None: + self.bec_dispatcher.disconnect_slot( + self._update, MessageEndpoints.procedure_logs(self._queue) + ) + self._queue = queue or None + if self._queue is not None: + self.bec_dispatcher.connect_slot( + self._update, MessageEndpoints.procedure_logs(self._queue) + ) + self._init_content() + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + widget = ProcedureLogs() + widget.queue = "primary" + widget.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/control/procedure_control/procedure_panel.py b/bec_widgets/widgets/control/procedure_control/procedure_panel.py new file mode 100644 index 000000000..1dd81d05f --- /dev/null +++ b/bec_widgets/widgets/control/procedure_control/procedure_panel.py @@ -0,0 +1,35 @@ +from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget +from bec_widgets.widgets.control.procedure_control.procedure_control import ProcedureControl +from bec_widgets.widgets.control.procedure_control.procedure_logs import ProcedureLogs + + +class ProcedurePanel(DockAreaWidget): + + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + self.procedure_control = ProcedureControl(parent=self) + self.procedure_control.setObjectName("Procedure Queue Control") + self.procedure_logs = ProcedureLogs(parent=self) + self.procedure_logs.setObjectName("Procedure Logs") + + _dock_kwargs = { + "closable": False, + "movable": False, + "floatable": False, + "title_buttons": {"float": False, "close": False, "menu": False}, + } + self.new(self.procedure_control, **_dock_kwargs) + self.new(self.procedure_logs, where="bottom", **_dock_kwargs) + + self.procedure_control.queue_selected.connect(self.procedure_logs.set_queue) + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + widget = ProcedurePanel() + widget.show() + sys.exit(app.exec()) diff --git a/tests/unit_tests/client_mocks.py b/tests/unit_tests/client_mocks.py index 613119e02..e7c51c343 100644 --- a/tests/unit_tests/client_mocks.py +++ b/tests/unit_tests/client_mocks.py @@ -6,10 +6,10 @@ import pytest from bec_lib.bec_service import messages from bec_lib.endpoints import MessageEndpoints -from bec_lib.redis_connector import RedisConnector from bec_lib.scan_history import ScanHistory from bec_widgets.tests.utils import DEVICES, DMMock, FakePositioner, Positioner +from bec_widgets.utils.bec_dispatcher import QtRedisConnector def fake_redis_server(host, port, **kwargs): @@ -19,7 +19,7 @@ def fake_redis_server(host, port, **kwargs): @pytest.fixture(scope="function") def mocked_client(bec_dispatcher): - connector = RedisConnector("localhost:1", redis_cls=fake_redis_server) + connector = QtRedisConnector("localhost:1", redis_cls=fake_redis_server) # Create a MagicMock object client = MagicMock() # TODO change to real BECClient diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 6f81a4cf8..8bd061d1c 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -1,5 +1,6 @@ import json import time +from typing import Callable import h5py import numpy as np diff --git a/tests/unit_tests/test_procedure_control.py b/tests/unit_tests/test_procedure_control.py new file mode 100644 index 000000000..593eb86ff --- /dev/null +++ b/tests/unit_tests/test_procedure_control.py @@ -0,0 +1,108 @@ +from time import sleep +from typing import Callable +from unittest.mock import MagicMock, patch + +import pytest +from bec_lib.messages import ProcedureExecutionMessage, ProcedureRequestMessage +from bec_lib.procedures.helper import BackendProcedureHelper +from bec_server.procedures.manager import ProcedureManager +from bec_server.procedures.procedure_registry import register +from bec_server.procedures.worker_base import ProcedureWorker + +from bec_widgets.widgets.control.procedure_control.procedure_control import ( + ProcedureControl, + QueueItem, +) + +from .client_mocks import mocked_client + + +class MockWorker(ProcedureWorker): + def _kill_process(self): ... + + def _run_task(self, item): + sleep(0.1) + + def _setup_execution_environment(self): ... + + def abort(self): ... + + def abort_execution(self, execution_id: str): ... + + +@pytest.fixture(scope="module", autouse=True) +def register_test_proc(): + register("test", lambda: None) + + +@pytest.fixture +def proc_ctrl_w_helper(qtbot, mocked_client: MagicMock): + proc_ctrl = ProcedureControl(client=mocked_client) + qtbot.addWidget(proc_ctrl) + with patch( + "bec_server.procedures.manager.RedisConnector", lambda _: proc_ctrl.client.connector + ): + manager = ProcedureManager(MagicMock(), MockWorker) + yield proc_ctrl, BackendProcedureHelper(proc_ctrl.client.connector) + manager.shutdown() + + +@pytest.fixture +def req_msg(): + return ProcedureRequestMessage(identifier="test") + + +@pytest.fixture +def exec_msg(): + return lambda id: ProcedureExecutionMessage(identifier="test", queue="test", execution_id=id) + + +def test_add_proc(qtbot, proc_ctrl_w_helper: tuple[ProcedureControl, BackendProcedureHelper]): + proc_ctrl, helper = proc_ctrl_w_helper + assert proc_ctrl._active_queues.childCount() == 0 + helper.request.procedure("test") + qtbot.waitUntil(lambda: proc_ctrl._active_queues.childCount() != 0, timeout=500) + + +def test_abort(qtbot, proc_ctrl_w_helper: tuple[ProcedureControl, BackendProcedureHelper]): + proc_ctrl, helper = proc_ctrl_w_helper + assert proc_ctrl._active_queues.childCount() == 0 + helper.request.procedure("test") + qtbot.waitUntil(lambda: proc_ctrl._active_queues.childCount() != 0, timeout=500) + + assert proc_ctrl._unhandled_queues.childCount() == 0 + queue: QueueItem = proc_ctrl._active_queues.child(0) + queue.child(0).actions_widget.layout().itemAt(0).widget().click() + qtbot.waitUntil(lambda: proc_ctrl._active_queues.childCount() == 0, timeout=500) + qtbot.waitUntil(lambda: proc_ctrl._unhandled_queues.childCount() != 0, timeout=500) + + +def test_delete( + qtbot, + proc_ctrl_w_helper: tuple[ProcedureControl, BackendProcedureHelper], + exec_msg: Callable[[str], ProcedureExecutionMessage], +): + proc_ctrl, helper = proc_ctrl_w_helper + [helper.push.unhandled("test", exec_msg("abcd")) for _ in range(3)] + qtbot.waitUntil(lambda: proc_ctrl._unhandled_queues.childCount() != 0, timeout=500) + queue: QueueItem = proc_ctrl._unhandled_queues.child(0) + assert queue.childCount() == 3 + queue.actions_widget.layout().itemAt(0).widget().click() + qtbot.waitUntil(lambda: helper.get.unhandled_queue("test") == [], timeout=500) + qtbot.waitUntil(lambda: proc_ctrl._unhandled_queues.childCount() == 0, timeout=500) + + +def test_resubmit( + qtbot, + proc_ctrl_w_helper: tuple[ProcedureControl, BackendProcedureHelper], + exec_msg: Callable[[str], ProcedureExecutionMessage], +): + proc_ctrl, helper = proc_ctrl_w_helper + [helper.push.unhandled("test", exec_msg(f"abcd{i}")) for i in range(3)] + qtbot.waitUntil(lambda: proc_ctrl._unhandled_queues.childCount() != 0, timeout=500) + queue: QueueItem = proc_ctrl._unhandled_queues.child(0) + assert queue.childCount() == 3 + assert proc_ctrl._active_queues.childCount() == 0 + queue.child(0).actions_widget.layout().itemAt(1).widget().click() + qtbot.waitUntil(lambda: len(helper.get.unhandled_queue("test")) == 2, timeout=500) + qtbot.waitUntil(lambda: proc_ctrl._active_queues.childCount() != 0, timeout=500)