Merge branch 'main' into feat/add-proposal-metadata-to-file
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
+14
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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("<b>BEC server is restarting, please wait...</b>")
|
||||
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("<b>Server restarted successfully.</b>")
|
||||
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"<b>Restart failed:</b>\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_())
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['restart_server.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 = """
|
||||
<ui language='c++'>
|
||||
<widget class='RestartServer' name='restart_server'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
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()
|
||||
@@ -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}")
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user