Compare commits

..

3 Commits

Author SHA1 Message Date
wakonig_k c40ce53955 fix: abort button should use request id 2026-06-15 22:07:09 +02:00
semantic-release 2b22a7065c 3.16.0
Automatically generated by python-semantic-release
2026-06-15 09:25:01 +00:00
wakonig_k 9146c5194e feat(screenshot): add option to upload to SciLog 2026-06-15 11:24:10 +02:00
7 changed files with 154 additions and 25 deletions
+8
View File
@@ -1,6 +1,14 @@
# CHANGELOG
## v3.16.0 (2026-06-15)
### Features
- **screenshot**: Add option to upload to SciLog
([`9146c51`](https://github.com/bec-project/bec_widgets/commit/9146c5194e0871b6f99a1b985a6d6b2e3fdd409c))
## v3.15.1 (2026-06-13)
### Bug Fixes
+45
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
import os
import tempfile
from datetime import datetime
from typing import TYPE_CHECKING
@@ -304,6 +306,49 @@ class BECWidget(BECConnector):
buf.close()
return ba
@SafeSlot(popup_error=True)
@rpc_timeout(None)
def screenshot_to_scilog(self) -> None:
"""
Take a screenshot of the widget and send it to SciLog through BEC messaging services.
"""
if not isinstance(self, QWidget):
raise RuntimeError("Cannot take screenshot of non-QWidget instance")
messaging = getattr(self.client, "messaging", None)
if messaging is None:
raise RuntimeError("BEC messaging services are not available on the current client.")
scilog = messaging.scilog
if not getattr(scilog, "_enabled", False):
# We currently don't expose a public method to check if SciLog is enabled,
# so we play defensive and check for an internal _enabled attribute that
# should be True when SciLog is enabled.
raise RuntimeError(
"SciLog is not enabled for the current client, cannot send screenshot."
)
pixmap: QPixmap = self.grab()
if pixmap.isNull():
raise RuntimeError("Failed to capture screenshot.")
tmp_name: str | None = None
try:
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp_file:
tmp_name = tmp_file.name
if not pixmap.save(tmp_name, "PNG"):
raise RuntimeError("Failed to save screenshot to a temporary file.")
msg = messaging.scilog.new()
msg.add_attachment(tmp_name)
msg.send()
logger.info("Screenshot sent to SciLog")
finally:
if tmp_name and os.path.exists(tmp_name):
os.unlink(tmp_name)
def attach(self):
dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget)
if dock is None:
@@ -32,6 +32,7 @@ from bec_widgets.utils.rpc_widget_handler import widget_handler
from bec_widgets.utils.toolbars.actions import (
ExpandableMenuAction,
MaterialIconAction,
SwitchableToolBarAction,
WidgetAction,
)
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
@@ -470,10 +471,34 @@ class BECDockArea(DockAreaWidget):
self._profile_management_enabled
)
self.toolbar.components.add_safe(
"screenshot",
"screenshot_save",
MaterialIconAction(icon_name="photo_camera", tooltip="Take Screenshot", parent=self),
)
self.toolbar.components.get_action("screenshot").action.setVisible(
self.toolbar.components.add_safe(
"screenshot_to_scilog",
MaterialIconAction(
icon_name="add_a_photo", tooltip="Send Screenshot to SciLog", parent=self
),
)
self.toolbar.components.add_safe(
"screenshot",
SwitchableToolBarAction(
actions={
"save": self.toolbar.components.get_action_reference("screenshot_save")(),
"scilog": self.toolbar.components.get_action_reference(
"screenshot_to_scilog"
)(),
},
initial_action="save",
tooltip="Screenshot Actions",
checkable=False,
parent=self.toolbar,
),
)
self.toolbar.components.get_action("screenshot_save").action.setVisible(
self._profile_management_enabled
)
self.toolbar.components.get_action("screenshot_to_scilog").action.setVisible(
self._profile_management_enabled
)
dark_mode_action = WidgetAction(
@@ -542,7 +567,12 @@ class BECDockArea(DockAreaWidget):
_connect_flat_actions(self._ACTION_MAPPINGS["menu_utils"])
self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all)
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
self.toolbar.components.get_action("screenshot_save").action.triggered.connect(
self.screenshot
)
self.toolbar.components.get_action("screenshot_to_scilog").action.triggered.connect(
self.screenshot_to_scilog
)
def _new_plugin_widget(self, widget_type: str, toolbar_action: MaterialIconAction) -> None:
# Created as helper method for simple tests
@@ -12,7 +12,7 @@ logger = bec_logger.logger
class AbortButton(BECWidget, QWidget):
"""A button that abort the scan."""
"""A button that abort the request."""
PLUGIN = True
ICON_NAME = "cancel"
@@ -25,11 +25,12 @@ class AbortButton(BECWidget, QWidget):
config=None,
gui_id=None,
toolbar=False,
scan_id=None,
request_id=None,
**kwargs,
):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.get_bec_shortcuts()
self.request_id = request_id
self.layout = QHBoxLayout(self)
self.layout.setSpacing(0)
@@ -39,7 +40,7 @@ class AbortButton(BECWidget, QWidget):
if toolbar:
icon = material_icon("cancel", color="#666666", filled=True)
self.button = QToolButton(icon=icon)
self.button.setToolTip("Abort the scan")
self.button.setToolTip("Abort the request")
else:
self.button = QPushButton()
self.button.setText("Abort")
@@ -47,8 +48,6 @@ class AbortButton(BECWidget, QWidget):
self.layout.addWidget(self.button)
self.scan_id = scan_id
@SafeSlot()
def abort_scan(
self,
@@ -57,10 +56,10 @@ class AbortButton(BECWidget, QWidget):
Abort the scan.
Args:
scan_id(str|None): The scan id to abort. If None, the current scan will be aborted.
request_id(str|None): The request id to abort. If None, the current scan will be aborted.
"""
if self.scan_id is not None:
logger.info(f"Aborting scan with scan_id: {self.scan_id}")
self.queue.request_scan_abortion(scan_id=self.scan_id)
if self.request_id is not None:
logger.info(f"Aborting scan with request_id: {self.request_id}")
self.queue.request_scan_abortion(request_id=self.request_id)
else:
self.queue.request_scan_abortion()
@@ -176,6 +176,7 @@ class BECQueue(BECWidget, CompactPopupWidget):
scan_names = []
scan_ids = []
user_metadatas = []
request_ids = []
status = item.status
for request_block in blocks:
scan_type = request_block.msg.scan_type
@@ -193,6 +194,8 @@ class BECQueue(BECWidget, CompactPopupWidget):
scan_id = request_block.scan_id
if scan_id:
scan_ids.append(scan_id)
if request_block.RID:
request_ids.append(request_block.RID)
if scan_types:
scan_types = ", ".join(scan_types)
if scan_numbers:
@@ -208,7 +211,15 @@ class BECQueue(BECWidget, CompactPopupWidget):
tooltip = json.dumps(user_metadatas, indent=2)
if scan_ids:
scan_ids = ", ".join(scan_ids)
self.set_row(index, scan_numbers, scan_names, scan_types, status, scan_ids, tooltip)
self.set_row(
index,
scan_numbers,
scan_names,
scan_types,
status,
tooltip,
abort_request_id=request_ids[0] if request_ids else None,
)
busy = (
False
if all(item.status in ("STOPPED", "COMPLETED", "IDLE") for item in queue_info)
@@ -249,8 +260,8 @@ class BECQueue(BECWidget, CompactPopupWidget):
scan_name: str,
scan_type: str,
status: str,
scan_id: str,
tooltip: str = "",
abort_request_id: str = "",
):
"""
Set the row of the table.
@@ -261,10 +272,10 @@ class BECQueue(BECWidget, CompactPopupWidget):
scan_name (str): The scan name.
scan_type (str): The scan type.
status (str): The status.
scan_id (str): The scan id.
tooltip (str): Optional tooltip to display (pretty-printed user metadata).
abort_request_id (str): request id to abort.
"""
abort_button = self._create_abort_button(scan_id)
abort_button = self._create_abort_button(abort_request_id)
abort_button.button.clicked.connect(self.delete_selected_row)
self.table.setItem(index, 0, self.format_item(scan_number, tooltip=tooltip))
@@ -273,17 +284,17 @@ class BECQueue(BECWidget, CompactPopupWidget):
self.table.setItem(index, 3, self.format_item(status, status=True, tooltip=tooltip))
self.table.setCellWidget(index, 4, abort_button)
def _create_abort_button(self, scan_id: str) -> AbortButton:
def _create_abort_button(self, request_id: str) -> AbortButton:
"""
Create an abort button with styling for BEC Queue widget for certain scan_id.
Create an abort button with styling for BEC Queue widget for certain request_id.
Args:
scan_id(str): The scan id to abort.
request_id(str): The request id to abort.
Returns:
AbortButton: The abort button.
"""
abort_button = AbortButton(parent=self, scan_id=scan_id)
abort_button = AbortButton(parent=self, request_id=request_id)
abort_button.button.setText("")
abort_button.button.setIcon(
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "bec_widgets"
version = "3.15.1"
version = "3.16.0"
description = "BEC Widgets"
requires-python = ">=3.11"
classifiers = [
@@ -77,6 +77,7 @@ qtermwidget = ["pyside6_qtermwidget"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
+39 -4
View File
@@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch
import pytest
from qtpy.QtCore import QSettings, Qt, QTimer
from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import QDialog, QMessageBox, QWidget
from qtpy.QtWidgets import QDialog, QMessageBox, QToolButton, QWidget
import bec_widgets.widgets.containers.dock_area.basic_dock_area as basic_dock_module
import bec_widgets.widgets.containers.dock_area.dock_area as dock_area_module
@@ -987,9 +987,9 @@ class TestToolbarFunctionality:
mock_screenshot = mock.MagicMock()
mock_grab.return_value = mock_screenshot
# Trigger the screenshot action
action = advanced_dock_area.toolbar.components.get_action("screenshot").action
action.trigger()
# Trigger the screenshot main button
action = advanced_dock_area.toolbar.components.get_action("screenshot")
action.main_button.click()
# Verify the dialog was called
mock_dialog.assert_called_once()
@@ -1000,6 +1000,41 @@ class TestToolbarFunctionality:
# Verify save was called with the filename
mock_screenshot.save.assert_called_once_with(str(screenshot_path))
def test_screenshot_button_has_scilog_dropdown(self, advanced_dock_area):
"""Test screenshot toolbar button exposes a SciLog dropdown option."""
action = advanced_dock_area.toolbar.components.get_action("screenshot")
button = action.main_button
assert isinstance(button, QToolButton)
assert button.popupMode() == QToolButton.ToolButtonPopupMode.MenuButtonPopup
assert button.menu() is not None
assert [menu_action.text() for menu_action in button.menu().actions()] == [
"Take Screenshot",
"Send Screenshot to SciLog",
]
def test_screenshot_to_scilog_action(self, advanced_dock_area):
"""Test sending a screenshot through the BEC SciLog messaging service."""
mock_message = mock.MagicMock()
mock_message.add_attachment.return_value = mock_message
advanced_dock_area.client.messaging.scilog.new = mock.MagicMock(return_value=mock_message)
advanced_dock_area.client.messaging.scilog._enabled = True
with mock.patch.object(advanced_dock_area, "grab") as mock_grab:
mock_screenshot = mock.MagicMock()
mock_screenshot.isNull.return_value = False
mock_screenshot.save.return_value = True
mock_grab.return_value = mock_screenshot
action = advanced_dock_area.toolbar.components.get_action("screenshot")
assert action.main_button is not None
scilog_menu_action = action.main_button.menu().actions()[1]
scilog_menu_action.trigger()
advanced_dock_area.client.messaging.scilog.new.assert_called_once_with()
mock_message.add_attachment.assert_called_once()
mock_message.send.assert_called_once_with()
def test_plugin_toolbar_actions_empty_when_no_plugins(self, clear_plugin_toolbar_actions_cache):
"""Test that no plugin toolbar actions are produced when no plugin widgets exist."""
with patch(