diff --git a/.copier-answers.yml b/.copier-answers.yml index f46a724..a77eca8 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -2,7 +2,7 @@ # It is needed to track the repo template version, and editing may break things. # This file will be overwritten by copier on template updates. -_commit: v1.4.1 +_commit: v1.5.1 _src_path: https://github.com/bec-project/plugin_copier_template.git make_commit: false project_name: debye_bec diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9418659..85659ba 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -95,8 +95,19 @@ jobs: uv pip install --system -e ./bec/bec_ipython_client uv pip install --system -e ./bec/bec_server[dev] uv pip install --system -e ./bec_widgets[dev,pyside6] - uv pip install --system -e ./debye_bec + uv pip install --system -e ./debye_bec[dev] - - name: Run Pytest with Coverage + - name: Run Tests with Coverage id: coverage - run: pytest --random-order --cov=./debye_bec --cov-config=./debye_bec/pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail ./debye_bec/tests/ || test $? -eq 5 + shell: bash + run: | + set +e + coverage run --branch --source=./debye_bec -m pytest --random-order ./debye_bec/tests/ + status=$? + # Allow pytest exit code 5 so repositories without tests do not fail CI. + if [ "$status" -ne 0 ] && [ "$status" -ne 5 ]; then + exit "$status" + fi + if [ "$status" -eq 0 ]; then + coverage report + fi diff --git a/debye_bec/bec_widgets/widgets/client.py b/debye_bec/bec_widgets/widgets/client.py index af97cf7..c974339 100644 --- a/debye_bec/bec_widgets/widgets/client.py +++ b/debye_bec/bec_widgets/widgets/client.py @@ -4,6 +4,7 @@ from __future__ import annotations from bec_lib.logger import bec_logger + from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout logger = bec_logger.logger @@ -14,6 +15,7 @@ logger = bec_logger.logger _Widgets = { "DataViewer": "DataViewer", "DigitalTwin": "DigitalTwin", + "RestartServer": "RestartServer", "ScanControlXAS": "ScanControlXAS", } @@ -66,7 +68,33 @@ class DigitalTwin(RPCBase): """ +class RestartServer(RPCBase): + """Main widget of server restart widget""" + + _IMPORT_MODULE = "debye_bec.bec_widgets.widgets.restart_server.restart_server" + + @rpc_call + def remove(self): + """ + Cleanup the BECConnector + """ + + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + + class ScanControlXAS(RPCBase): + """Main widget of Scan Control XAS""" + _IMPORT_MODULE = "debye_bec.bec_widgets.widgets.scan_control_xas.scan_control_xas" @rpc_call diff --git a/debye_bec/bec_widgets/widgets/designer_plugins.py b/debye_bec/bec_widgets/widgets/designer_plugins.py index b098ee3..77ddd10 100644 --- a/debye_bec/bec_widgets/widgets/designer_plugins.py +++ b/debye_bec/bec_widgets/widgets/designer_plugins.py @@ -7,10 +7,19 @@ from __future__ import annotations designer_plugins = { "DataViewer": ("debye_bec.bec_widgets.widgets.data_viewer.data_viewer", "DataViewer"), "DigitalTwin": ("debye_bec.bec_widgets.widgets.digital_twin.digital_twin", "DigitalTwin"), + "RestartServer": ( + "debye_bec.bec_widgets.widgets.restart_server.restart_server", + "RestartServer", + ), "ScanControlXAS": ( "debye_bec.bec_widgets.widgets.scan_control_xas.scan_control_xas", "ScanControlXAS", ), } -widget_icons = {"DataViewer": "find_in_page", "DigitalTwin": "lightbulb", "ScanControlXAS": "tune"} +widget_icons = { + "DataViewer": "find_in_page", + "DigitalTwin": "lightbulb", + "RestartServer": "restart_alt", + "ScanControlXAS": "tune", +} diff --git a/debye_bec/bec_widgets/widgets/restart_server/__init__,py b/debye_bec/bec_widgets/widgets/restart_server/__init__,py new file mode 100644 index 0000000..e69de29 diff --git a/debye_bec/bec_widgets/widgets/restart_server/register_restart_server.py b/debye_bec/bec_widgets/widgets/restart_server/register_restart_server.py new file mode 100644 index 0000000..5ac2c87 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/restart_server/register_restart_server.py @@ -0,0 +1,15 @@ +def main(): # pragma: no cover + from qtpy import PYSIDE6 + + if not PYSIDE6: + print("PYSIDE6 is not available in the environment. Cannot patch designer.") + return + from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + + from .restart_server_plugin import RestartServerPlugin + + QPyDesignerCustomWidgetCollection.addCustomWidget(RestartServerPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/debye_bec/bec_widgets/widgets/restart_server/restart_server.py b/debye_bec/bec_widgets/widgets/restart_server/restart_server.py new file mode 100644 index 0000000..c068137 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/restart_server/restart_server.py @@ -0,0 +1,207 @@ +""" +Restart Server: Custom BEC widget to restart server. +""" + +import sys + +from bec_lib import bec_logger +from bec_widgets.utils.bec_dispatcher import BECDispatcher +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.colors import apply_theme +from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel +from PySide6QtAds import CDockWidget +from qtpy.QtCore import QThread, QTimer, Signal + +# pylint: disable=E0611 +from qtpy.QtWidgets import QApplication, QDialog, QDialogButtonBox, QLabel, QVBoxLayout, QWidget + +logger = bec_logger.logger + + +class RestartServer(BECWidget, QWidget): + """ + Main widget of server restart widget. + + This widget has no visible UI of its own - it only drives the confirmation/ + progress dialog. The surrounding dock panel is hidden as soon as we can find + it, so the user only ever sees the dialog, never an empty "Restart Server" tab. + """ + + PLUGIN = True + ICON_NAME = "restart_alt" + + def __init__(self, *arg, parent=None, **kwargs): + super().__init__(parent=parent, *arg, **kwargs) + self.get_bec_shortcuts() + self.dialog = None + self._worker = None + self._dock_widget = None + QTimer.singleShot(0, self._start) + + def _start(self): + """Hide the containing dock panel, then show the confirmation dialog.""" + self._dock_widget = self._find_containing_dock() + if self._dock_widget is not None: + self._dock_widget.toggleView(False) + self.prompt_restart() + + def _find_containing_dock(self): + """Walk up the parent chain to find the wrapping CDockWidget, if any.""" + widget = self.parentWidget() + while widget is not None: + if CDockWidget is not None and isinstance(widget, CDockWidget): + return widget + widget = widget.parentWidget() + return None + + def prompt_restart(self): + """Prompt the user for confirmation, then restart the BEC server if confirmed.""" + self.dialog = RestartConfirmationDialog(parent=self) + if self.dialog.exec_() == QDialog.Accepted: + self._start_restart() + else: + self._close_containing_dock() + + def _start_restart(self): + """Kick off the restart on a background thread and show progress feedback.""" + self.dialog.show_restarting() + self.dialog.finished.connect(self._close_containing_dock) + + self._worker = _RestartWorker(self.client, parent=self) + self._worker.finished_ok.connect(self._on_restart_finished) + self._worker.failed.connect(self._on_restart_failed) + self._worker.finished.connect(self._worker.deleteLater) + self._worker.start() + + def _on_restart_finished(self): + self.dialog.show_done() + + def _on_restart_failed(self, error_msg: str): + self.dialog.show_error(error_msg) + + def _close_containing_dock(self): + """Actually close (and typically delete) the dock panel this widget lives in. + + Reuses the reference found in `_start()` rather than re-walking the + parent chain, since toggleView(False) doesn't reparent anything. + """ + if self._dock_widget is not None: + self._dock_widget.closeDockWidget() + return + + # Fallback: no dock wrapper found (e.g. standalone use) + self.close() + + +class _RestartWorker(QThread): + """Runs the blocking server-restart call off the GUI thread.""" + + finished_ok = Signal() + failed = Signal(str) + + def __init__(self, client, parent=None): + super().__init__(parent) + self._client = client + + def run(self): + try: + self._client._request_server_restart() + except Exception as exc: # pylint: disable=broad-except + logger.exception("Failed to restart BEC server") + self.failed.emit(str(exc)) + else: + self.finished_ok.emit() + + +class RestartConfirmationDialog(QDialog): + """Dialog asking the user to confirm a restart of the BEC server, then shows progress + and waits for the user to acknowledge completion before closing.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Restart BEC Server") + self.setModal(True) + + self.layout = QVBoxLayout(self) + + self.message = QLabel( + "Are you sure you want to restart the BEC server?\n" + "This will interrupt any running scans!" + ) + self.layout.addWidget(self.message) + + self.button_box = QDialogButtonBox() + self.cancel_button = self.button_box.addButton("Cancel", QDialogButtonBox.RejectRole) + self.restart_button = self.button_box.addButton("Restart now", QDialogButtonBox.AcceptRole) + + self.button_box.accepted.connect(self.accept) + self.button_box.rejected.connect(self.reject) + + self.layout.addWidget(self.button_box) + + # Log panel is created lazily, only once the restart actually begins, so + # opening/cancelling the confirmation dialog never touches the log stream. + self._log_panel: LogPanel | None = None + + # Regardless of how the dialog ends (accept/reject/close), tear the log + # panel down. finished() fires reliably in all of those cases; it's a + # no-op if the log panel was never created. + self.finished.connect(self._teardown_log_panel) + + def show_restarting(self): + """Switch the dialog into a non-blocking 'in progress' state, with a live + view of the server logs so the user can see what's happening. + """ + self.message.setText("BEC server is restarting, please wait...") + self.button_box.hide() + self._setup_log_panel() + self.resize(750, 450) + self.show() + + def _setup_log_panel(self): + """Embed the standard LogPanel widget""" + if self._log_panel is not None: + return + self._log_panel = LogPanel(parent=self, show_toolbar=False) + self.layout.removeWidget(self.button_box) + self.layout.addWidget(self._log_panel) + self.layout.addWidget(self.button_box) + + def _teardown_log_panel(self, *_): + """Clean up the log panel so it stops receiving updates once this dialog + is gone. LogPanel.cleanup() detaches it from the shared log queue and + handles its own BECWidget/RPC teardown.""" + if self._log_panel is None: + return + self._log_panel.cleanup() + self._log_panel = None + + def show_done(self): + """Show a success state and require the user to explicitly close the dialog.""" + self.message.setText("Server restarted successfully.") + self._show_close_button("Close") + + def show_error(self, error_msg: str): + """Show a failure state and require the user to explicitly close the dialog.""" + self.message.setText(f"Restart failed:\n{error_msg}") + self._show_close_button("Close") + + def _show_close_button(self, label: str): + """Replace the button row with a single acknowledgement button. + + Clicking it calls accept(), which emits the dialog's `finished` signal - + that's what RestartServer listens for to know it's safe to close the dock. + """ + self.button_box.clear() + self.close_button = self.button_box.addButton(label, QDialogButtonBox.AcceptRole) + self.button_box.accepted.connect(self.accept) + self.button_box.show() + + +if __name__ == "__main__": + app = QApplication(sys.argv) + apply_theme("light") + dispatcher = BECDispatcher(gui_id="restart_server") + win = RestartServer() + win.show() + sys.exit(app.exec_()) diff --git a/debye_bec/bec_widgets/widgets/restart_server/restart_server.pyproject b/debye_bec/bec_widgets/widgets/restart_server/restart_server.pyproject new file mode 100644 index 0000000..a2ce968 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/restart_server/restart_server.pyproject @@ -0,0 +1 @@ +{'files': ['restart_server.py']} \ No newline at end of file diff --git a/debye_bec/bec_widgets/widgets/restart_server/restart_server_plugin.py b/debye_bec/bec_widgets/widgets/restart_server/restart_server_plugin.py new file mode 100644 index 0000000..230247b --- /dev/null +++ b/debye_bec/bec_widgets/widgets/restart_server/restart_server_plugin.py @@ -0,0 +1,57 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from bec_widgets.utils.bec_designer import designer_material_icon +from qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtWidgets import QWidget + +from .restart_server import RestartServer + +DOM_XML = """ + + + + +""" + + +class RestartServerPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + if parent is None: + return QWidget() + t = RestartServer(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "" + + def icon(self): + return designer_material_icon(RestartServer.ICON_NAME) + + def includeFile(self): + return "restart_server" + + def initialize(self, form_editor): + self._form_editor = form_editor + + def isContainer(self): + return False + + def isInitialized(self): + return self._form_editor is not None + + def name(self): + return "RestartServer" + + def toolTip(self): + return "RestartServer" + + def whatsThis(self): + return self.toolTip() diff --git a/debye_bec/file_writer/storage_copy.py b/debye_bec/file_writer/storage_copy.py new file mode 100644 index 0000000..fa76a5e --- /dev/null +++ b/debye_bec/file_writer/storage_copy.py @@ -0,0 +1,76 @@ +""" +Beamline storage-copy hook for debye_bec. + +Use this module to handle ``bec.beamline_storage_copy(...)`` requests with +beamline-specific file transfer logic. + +The plugin_storage_copy function is commented out by default. Uncomment and implement it to handle +beamline-specific storage copy operations. The function should accept the source file path, +a scope identifier, the active BEC account, and an optional subdirectory for the destination. It should +perform the necessary checks and copy the file to the appropriate location based on the provided scope. +The subdirectory, if provided, has already been sanitized to ensure it is safe for use in file paths. +""" + +# import os +# import shutil + +# from bec_lib.logger import bec_logger + +# logger = bec_logger.logger + + +# def plugin_storage_copy( +# source_file: str, scope: str, active_account: str, subdir: str | None = None +# ) -> None: +# """ +# Run a beamline-defined storage copy operation. + +# Args: +# source_file: Source file that should be copied. +# scope: Beamline-defined copy scope identifier. +# active_account: Active BEC account, if one is available. +# subdir: Optional sanitized relative destination subdirectory. +# """ +# supported_file_extensions = [ +# ".h5", +# ".txt", +# ".csv", +# ".log", +# ".json", +# ".png", +# ".jpg", +# ".jpeg", +# ".tiff", +# ".yaml", +# ".yml", +# ] +# if not any(source_file.endswith(ext) for ext in supported_file_extensions): +# logger.error( +# f"Unsupported file type for '{source_file}'. Supported extensions: " +# f"{supported_file_extensions}" +# ) +# return + +# # Example storage mapping. Replace this with beamline-specific scopes and paths. +# storage_locations = {"alignment": f"/sls/x99sa/data/p12345/raw/bec/alignment/{active_account}/"} +# if scope not in storage_locations: +# logger.error( +# f"Received unknown scope '{scope}'. Available scopes: {list(storage_locations.keys())}" +# ) +# return + +# if not os.path.isfile(source_file): +# logger.error(f"File '{source_file}' does not exist or is not reachable.") +# return + +# destination_dir = storage_locations[scope] +# if subdir: +# destination_dir = os.path.join(destination_dir, subdir) +# os.makedirs(destination_dir, exist_ok=True) +# destination_file = os.path.join(destination_dir, os.path.basename(source_file)) + +# try: +# shutil.copy(source_file, destination_file) +# logger.info(f"Copied '{source_file}' to '{destination_file}'") +# except Exception as exc: +# logger.error(f"Failed to copy '{source_file}' to '{destination_file}': {exc}") diff --git a/pyproject.toml b/pyproject.toml index 792d73b..59b6017 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,9 @@ plugin_ds_startup = "debye_bec.deployments.device_server.startup:run" [project.entry-points."bec.file_writer"] plugin_file_writer = "debye_bec.file_writer" +[project.entry-points."bec.file_writer.storage_copy"] +plugin_storage_copy = "debye_bec.file_writer.storage_copy:plugin_storage_copy" + [project.entry-points."bec.scans"] plugin_scans = "debye_bec.scans"