1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-10 18:50:55 +02:00

Compare commits

..

40 Commits

Author SHA1 Message Date
semantic-release
e7ef8a3891 3.3.1
Automatically generated by python-semantic-release
2026-03-20 14:01:06 +00:00
90222f3082 fix(dap_combobox): rewritten as proper combobox 2026-03-20 15:00:12 +01:00
79af15a88b fix(dap_combobox): added safeguard for no DAP models 2026-03-20 15:00:12 +01:00
semantic-release
ad01011a3e 3.3.0
Automatically generated by python-semantic-release
2026-03-20 13:18:49 +00:00
d4ecefd80a fix: Fix black 2026 formatting 2026-03-20 14:17:41 +01:00
d4afcb6832 refactor(fuzzy-search): unify is_match for fuzzy search 2026-03-20 14:17:41 +01:00
2b0f575733 refactor(atlas-http-service): Rename AtlasEndpoints 2026-03-20 14:17:41 +01:00
0c6f3f8352 fix(admin-widget): Cleanup and minor improvements 2026-03-20 14:17:41 +01:00
48c9c83bb0 fix(admin_view): minor changes
fixed the styling and avoid shadowing styles
minor rewording
2026-03-20 14:17:41 +01:00
ab223d5fdc refactor: fix formatting, running black 2026.1 2026-03-20 14:17:41 +01:00
137e572a94 fix(admin-view): generate RPC interface for AdminView 2026-03-20 14:17:41 +01:00
b14b046882 fix(main-app): fix id for main-app init of AdminView 2026-03-20 14:17:41 +01:00
a7a9458180 refactor: address review comments 2026-03-20 14:17:41 +01:00
23c146b3e6 fix(bec-atlas-admin-view): Fix connect_slot for dispatcher 2026-03-20 14:17:41 +01:00
df44d9b50e test(bec-atlas-admin-view): complement tests for BECAtlasAdminView, ExperimentSelection, BECAtlasHTTPService 2026-03-20 14:17:41 +01:00
de941d1bc5 fix(actions): allow minimum icon size for actions in toolbar 2026-03-20 14:17:41 +01:00
34e80ee8f9 test(bec-atlas-http-service): add tests for http service 2026-03-20 14:17:41 +01:00
d1a1d85abd fix(login-dialog): remove login_dialog 2026-03-20 14:17:41 +01:00
8e53ae2d39 fix(RPC): fix rpc access 2026-03-20 14:17:41 +01:00
889e9c0994 fix(pyproject): add PyJWT as dependency 2026-03-20 14:17:41 +01:00
f565deb71d fix(main-app): skip on_enter/exit hooks if darkmodebutton clicked 2026-03-20 14:17:41 +01:00
895b318990 refactor: cleanup widgets 2026-03-20 14:17:41 +01:00
3a17a249ed refactor(admin-view): Refactor experiment selection, http service, admin view, and add main view 2026-03-20 14:17:41 +01:00
598c453a18 feat(experiment-selection): add experiment selection widget 2026-03-20 14:17:41 +01:00
63059a4ef8 feat(admin-view): add admin view to views 2026-03-20 14:17:41 +01:00
ec58fbd6d8 feat(bec-atlas-admin-view): Add initial admin view 2026-03-20 14:17:41 +01:00
17708730fc feat(bec-atlas-admin-view): add http service through QNetworkAccessManager 2026-03-20 14:17:41 +01:00
1384a329ab feat(bec-atlas-admin-view): Add login dilaog 2026-03-20 14:17:41 +01:00
semantic-release
da1dc85b44 3.2.4
Automatically generated by python-semantic-release
2026-03-19 17:23:19 +00:00
28be696f7c fix(main_app): setApplicationName("BEC") 2026-03-19 18:22:29 +01:00
semantic-release
008c3a223a 3.2.3
Automatically generated by python-semantic-release
2026-03-16 15:07:09 +00:00
b9145d762c fix: check adding parent for filesystemmodel 2026-03-16 16:06:22 +01:00
37a5dc2e9e fix: refactor client mock with global fakeredis 2026-03-16 16:06:22 +01:00
1351fcd47b ci: fix path for uploading logs on failure 2026-03-16 15:49:22 +01:00
semantic-release
14a6b04b11 3.2.2
Automatically generated by python-semantic-release
2026-03-16 14:28:24 +00:00
4c9d7fddce fix(image): disconnecting of 2d monitor 2026-03-16 15:26:40 +01:00
semantic-release
39ecb89196 3.2.1
Automatically generated by python-semantic-release
2026-03-16 14:08:42 +00:00
974f25997d fix(e2e): bec shell excluded from e2e testing 2026-03-16 15:07:51 +01:00
e061fa31a9 fix(e2e): bec dock rpc fixed synchronization 2026-03-16 15:07:51 +01:00
718f99527c fix(e2e): timeout for maybe_remove_dock_area 2026-03-16 15:07:51 +01:00
39 changed files with 3568 additions and 647 deletions

View File

@@ -1,19 +1,19 @@
name: Full CI
on:
on:
push:
pull_request:
workflow_dispatch:
inputs:
BEC_WIDGETS_BRANCH:
description: "Branch of BEC Widgets to install"
description: 'Branch of BEC Widgets to install'
required: false
type: string
BEC_CORE_BRANCH:
description: "Branch of BEC Core to install"
description: 'Branch of BEC Core to install'
required: false
type: string
OPHYD_DEVICES_BRANCH:
description: "Branch of Ophyd Devices to install"
description: 'Branch of Ophyd Devices to install'
required: false
type: string
@@ -25,58 +25,60 @@ permissions:
pull-requests: write
jobs:
# check_pr_status:
# uses: ./.github/workflows/check_pr.yml
check_pr_status:
uses: ./.github/workflows/check_pr.yml
# formatter:
# needs: check_pr_status
# if: needs.check_pr_status.outputs.branch-pr == ''
# uses: ./.github/workflows/formatter.yml
formatter:
needs: check_pr_status
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/formatter.yml
# unit-test:
# needs: [check_pr_status, formatter]
# if: needs.check_pr_status.outputs.branch-pr == ''
# uses: ./.github/workflows/pytest.yml
# with:
# BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref }}
# BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
# OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
# secrets:
# CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
unit-test:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/pytest.yml
with:
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref }}
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
# unit-test-matrix:
# needs: [check_pr_status, formatter]
# if: needs.check_pr_status.outputs.branch-pr == ''
# uses: ./.github/workflows/pytest-matrix.yml
# with:
# BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha}}
# BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
# OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
unit-test-matrix:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/pytest-matrix.yml
with:
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha}}
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
# generate-cli-test:
# needs: [check_pr_status, formatter]
# if: needs.check_pr_status.outputs.branch-pr == ''
# uses: ./.github/workflows/generate-cli-check.yml
generate-cli-test:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/generate-cli-check.yml
end2end-test:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/end2end-conda.yml
# child-repos:
# needs: [check_pr_status, formatter]
# if: needs.check_pr_status.outputs.branch-pr == ''
# uses: ./.github/workflows/child_repos.yml
# with:
# BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
# OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main'}}
# BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
child-repos:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/child_repos.yml
with:
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main'}}
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
plugin_repos:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
uses: bec-project/bec/.github/workflows/plugin_repos.yml@main
with:
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
# plugin_repos:
# needs: [check_pr_status, formatter]
# if: needs.check_pr_status.outputs.branch-pr == ''
# uses: bec-project/bec/.github/workflows/plugin_repos.yml@main
# with:
# BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
# BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
# secrets:
# GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}
secrets:
GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}

View File

@@ -48,9 +48,7 @@ jobs:
source ./bin/install_bec_dev.sh -t
cd ../
pip install -e ./ophyd_devices -e .[dev,pyside6] -e ./bec_testing_plugin
pip install pytest-repeat
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end/user_interaction/test_user_interaction_e2e.py
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
- name: Upload logs if job fails
if: failure()

View File

@@ -1,6 +1,147 @@
# CHANGELOG
## v3.3.1 (2026-03-20)
### Bug Fixes
- **dap_combobox**: Added safeguard for no DAP models
([`79af15a`](https://github.com/bec-project/bec_widgets/commit/79af15a88b993cd5b6bf730796f995f20cf6f188))
- **dap_combobox**: Rewritten as proper combobox
([`90222f3`](https://github.com/bec-project/bec_widgets/commit/90222f30821f822eb24b0179401d4e43050e0156))
## v3.3.0 (2026-03-20)
### Bug Fixes
- Fix black 2026 formatting
([`d4ecefd`](https://github.com/bec-project/bec_widgets/commit/d4ecefd80a6ab944b4da51c1ee35e5dea67770f2))
- **actions**: Allow minimum icon size for actions in toolbar
([`de941d1`](https://github.com/bec-project/bec_widgets/commit/de941d1bc565e444f84696b1de046d50b62f3c1b))
- **admin-view**: Generate RPC interface for AdminView
([`137e572`](https://github.com/bec-project/bec_widgets/commit/137e572a942281a9c7478a6be83f815848917e26))
- **admin-widget**: Cleanup and minor improvements
([`0c6f3f8`](https://github.com/bec-project/bec_widgets/commit/0c6f3f8352e7318a4f0579c83066b5b433fd1144))
- **admin_view**: Minor changes
([`48c9c83`](https://github.com/bec-project/bec_widgets/commit/48c9c83bb0c9432905d347b2d2cf46c05e58c098))
- **bec-atlas-admin-view**: Fix connect_slot for dispatcher
([`23c146b`](https://github.com/bec-project/bec_widgets/commit/23c146b3e6bbbabfb35f1892bc8653a65652ae6a))
- **login-dialog**: Remove login_dialog
([`d1a1d85`](https://github.com/bec-project/bec_widgets/commit/d1a1d85abd3331ebab696580c692c69b71482f37))
- **main-app**: Fix id for main-app init of AdminView
([`b14b046`](https://github.com/bec-project/bec_widgets/commit/b14b04688284eb875ea4469765786834e74fceb3))
- **main-app**: Skip on_enter/exit hooks if darkmodebutton clicked
([`f565deb`](https://github.com/bec-project/bec_widgets/commit/f565deb71db8fa5206fa2b4eea436e5055030bbc))
- **pyproject**: Add PyJWT as dependency
([`889e9c0`](https://github.com/bec-project/bec_widgets/commit/889e9c0994a960b93c93143b6dc5845dc96f9f96))
- **RPC**: Fix rpc access
([`8e53ae2`](https://github.com/bec-project/bec_widgets/commit/8e53ae2d3938e9c0a4c11082300156994447faaf))
### Features
- **admin-view**: Add admin view to views
([`63059a4`](https://github.com/bec-project/bec_widgets/commit/63059a4ef897a919f296c68ada066e0b228f8248))
- **bec-atlas-admin-view**: Add http service through QNetworkAccessManager
([`1770873`](https://github.com/bec-project/bec_widgets/commit/17708730fcff41713638c17d0cc1f5d9d0b75122))
- **bec-atlas-admin-view**: Add initial admin view
([`ec58fbd`](https://github.com/bec-project/bec_widgets/commit/ec58fbd6d859058f518b88ba15670a3a715c3cc3))
- **bec-atlas-admin-view**: Add login dilaog
([`1384a32`](https://github.com/bec-project/bec_widgets/commit/1384a329abf873b5496e540a542088c7f13b7270))
- **experiment-selection**: Add experiment selection widget
([`598c453`](https://github.com/bec-project/bec_widgets/commit/598c453a1876cebc2482d55bf6c2728ec247def0))
### Refactoring
- Address review comments
([`a7a9458`](https://github.com/bec-project/bec_widgets/commit/a7a9458180c18bf2bba652c2ff8a68875af36a22))
- Cleanup widgets
([`895b318`](https://github.com/bec-project/bec_widgets/commit/895b3189904778c269200365b264a32ff15dda21))
- Fix formatting, running black 2026.1
([`ab223d5`](https://github.com/bec-project/bec_widgets/commit/ab223d5fdc00b1a7bc9fd61abce5fabe4409654b))
- **admin-view**: Refactor experiment selection, http service, admin view, and add main view
([`3a17a24`](https://github.com/bec-project/bec_widgets/commit/3a17a249ed179fb8a11591f948c7b6338e10a60d))
- **atlas-http-service**: Rename AtlasEndpoints
([`2b0f575`](https://github.com/bec-project/bec_widgets/commit/2b0f575733412a96e54dff2dca15082d64caf7ee))
- **fuzzy-search**: Unify is_match for fuzzy search
([`d4afcb6`](https://github.com/bec-project/bec_widgets/commit/d4afcb68324f63ac8aea7cc3b2c82e79d2e643ca))
### Testing
- **bec-atlas-admin-view**: Complement tests for BECAtlasAdminView, ExperimentSelection,
BECAtlasHTTPService
([`df44d9b`](https://github.com/bec-project/bec_widgets/commit/df44d9b50eb289a7851579c64a2a8c0e2363b06a))
- **bec-atlas-http-service**: Add tests for http service
([`34e80ee`](https://github.com/bec-project/bec_widgets/commit/34e80ee8f9a2b2373c97ae7cde90690ab6fb37ce))
## v3.2.4 (2026-03-19)
### Bug Fixes
- **main_app**: Setapplicationname("bec")
([`28be696`](https://github.com/bec-project/bec_widgets/commit/28be696f7c7d9762c742c6d5fb5b03867d5e92ea))
## v3.2.3 (2026-03-16)
### Bug Fixes
- Check adding parent for filesystemmodel
([`b9145d7`](https://github.com/bec-project/bec_widgets/commit/b9145d762cdf946f184834928a6404f21b4802a9))
- Refactor client mock with global fakeredis
([`37a5dc2`](https://github.com/bec-project/bec_widgets/commit/37a5dc2e9eeb447d174f4d7087051672f308c84c))
### Continuous Integration
- Fix path for uploading logs on failure
([`1351fcd`](https://github.com/bec-project/bec_widgets/commit/1351fcd47b909c1a33cb389c096041eb1449e3d3))
## v3.2.2 (2026-03-16)
### Bug Fixes
- **image**: Disconnecting of 2d monitor
([`4c9d7fd`](https://github.com/bec-project/bec_widgets/commit/4c9d7fddce7aa5b7f13a00ac332bd54b301e3c28))
## v3.2.1 (2026-03-16)
### Bug Fixes
- **e2e**: Bec dock rpc fixed synchronization
([`e061fa3`](https://github.com/bec-project/bec_widgets/commit/e061fa31a9a5e5c00e44337d7cc52c51d8e259b5))
- **e2e**: Bec shell excluded from e2e testing
([`974f259`](https://github.com/bec-project/bec_widgets/commit/974f25997d68d13ff1063026f9e5c4c8dd4d49f3))
- **e2e**: Timeout for maybe_remove_dock_area
([`718f995`](https://github.com/bec-project/bec_widgets/commit/718f99527c3bebb96845d3305aba69434eb83f77))
## v3.2.0 (2026-03-11)
### Features

View File

@@ -5,6 +5,7 @@ from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
from bec_widgets.applications.navigation_centre.side_bar import SideBar
from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem
from bec_widgets.applications.views.admin_view.admin_view import AdminView
from bec_widgets.applications.views.developer_view.developer_view import DeveloperView
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
from bec_widgets.applications.views.dock_area_view.dock_area_view import DockAreaView
@@ -63,6 +64,8 @@ class BECMainApp(BECMainWindow):
self.dock_area = DockAreaView(self)
self.device_manager = DeviceManagerView(self)
# self.developer_view = DeveloperView(self) #TODO temporary disable until the bugs with BECShell are resolved
self.admin_view = AdminView(self)
self.add_view(icon="widgets", title="Dock Area", widget=self.dock_area, mini_text="Docks")
self.add_view(
icon="display_settings",
@@ -78,6 +81,13 @@ class BECMainApp(BECMainWindow):
# mini_text="IDE",
# exclusive=True,
# )
self.add_view(
icon="admin_panel_settings",
title="Admin View",
widget=self.admin_view,
mini_text="Admin",
from_top=False,
)
if self._show_examples:
self.add_section("Examples", "examples")
@@ -181,6 +191,12 @@ class BECMainApp(BECMainWindow):
# Internal: route sidebar selection to the stack
def _on_view_selected(self, vid: str) -> None:
# Special handling for views that can not be switched to (e.g. dark mode toggle)
# Not registered as proper view with a stack index, so we ignore any logic below
# as it will anyways not result in a stack switch.
idx = self._view_index.get(vid)
if idx is None or not (0 <= idx < self.stack.count()):
return
# Determine current view
current_index = self.stack.currentIndex()
current_view = (
@@ -378,6 +394,7 @@ def main(): # pragma: no cover
args, qt_args = parser.parse_known_args(sys.argv[1:])
app = QApplication([sys.argv[0], *qt_args])
app.setApplicationName("BEC")
apply_theme("dark")
w = BECMainApp(show_examples=args.examples)

View File

@@ -0,0 +1,35 @@
"""Module for Admin View."""
from qtpy.QtWidgets import QWidget
from bec_widgets.applications.views.view import ViewBase
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_admin_view import BECAtlasAdminView
class AdminView(ViewBase):
"""
A view for administrators to change the current active experiment, manage messaging
services, and more tasks reserved for users with admin privileges.
"""
def __init__(
self,
parent: QWidget | None = None,
content: QWidget | None = None,
*,
view_id: str | None = None,
title: str | None = None,
**kwargs,
):
super().__init__(parent=parent, content=content, view_id=view_id, title=title)
self.admin_widget = BECAtlasAdminView(parent=self)
self.set_content(self.admin_widget)
@SafeSlot()
def on_exit(self) -> None:
"""Called before the view is hidden.
Default implementation does nothing. Override in subclasses.
"""
self.admin_widget.logout()

View File

@@ -89,6 +89,16 @@ except ImportError as e:
logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(e))}")
class AdminView(RPCBase):
"""A view for administrators to change the current active experiment, manage messaging"""
@rpc_call
def activate(self) -> "None":
"""
Switch the parent application to this view.
"""
class AutoUpdates(RPCBase):
@property
@rpc_call
@@ -975,7 +985,7 @@ class Curve(RPCBase):
class DapComboBox(RPCBase):
"""The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC."""
"""Editable combobox listing the available DAP models."""
@rpc_call
def select_y_axis(self, y_axis: str):
@@ -1001,7 +1011,7 @@ class DapComboBox(RPCBase):
Slot to update the fit model.
Args:
default_device(str): Default device name.
fit_name(str): Fit model name.
"""

View File

@@ -1,6 +1,7 @@
# pylint: skip-file
from unittest.mock import MagicMock
from bec_lib.config_helper import ConfigHelper
from bec_lib.device import Device as BECDevice
from bec_lib.device import Positioner as BECPositioner
from bec_lib.device import ReadoutPriority
@@ -219,7 +220,9 @@ class Device(FakeDevice):
class DMMock:
def __init__(self):
def __init__(self, *args, **kwargs):
self._service = args[0]
self.config_helper = ConfigHelper(self._service.connector, self._service._service_name)
self.devices = DeviceContainer()
self.enabled_devices = [device for device in self.devices if device.enabled]
@@ -273,6 +276,10 @@ class DMMock:
configs.append(device._config)
return configs
def initialize(*_): ...
def shutdown(self): ...
DEVICES = [
FakePositioner("samx", limits=[-10, 10], read_value=2.0),

View File

@@ -123,17 +123,16 @@ class BECDispatcher:
self._registered_slots: DefaultDict[Hashable, QtThreadSafeCallback] = (
collections.defaultdict()
)
self.client = client
if self.client is None:
if config is not None:
if not isinstance(config, ServiceConfig):
# config is supposed to be a path
config = ServiceConfig(config)
if client is None:
if config is not None and not isinstance(config, ServiceConfig):
# config is supposed to be a path
config = ServiceConfig(config)
self.client = BECClient(
config=config, connector_cls=QtRedisConnector, name="BECWidgets"
)
else:
self.client = client
if self.client.started:
# have to reinitialize client to use proper connector
logger.info("Shutting down BECClient to switch to QtRedisConnector")

View File

@@ -0,0 +1,35 @@
"""Module providing fuzzy search utilities for the BEC widgets."""
from __future__ import annotations
from typing import Any
from thefuzz import fuzz
FUZZY_SEARCH_THRESHOLD = 80
def is_match(
text: str, row_data: dict[str, Any], relevant_keys: list[str], enable_fuzzy: bool
) -> bool:
"""
Check if the text matches any of the relevant keys in the row data.
Args:
text (str): The text to search for.
row_data (dict[str, Any]): The row data to search in.
relevant_keys (list[str]): The keys to consider for searching.
enable_fuzzy (bool): Whether to use fuzzy matching.
Returns:
bool: True if a match is found, False otherwise.
"""
for key in relevant_keys:
data = str(row_data.get(key, "") or "")
if enable_fuzzy:
match_ratio = fuzz.partial_ratio(text.lower(), data.lower())
if match_ratio >= FUZZY_SEARCH_THRESHOLD:
return True
else:
if text.lower() in data.lower():
return True
return False

View File

@@ -118,9 +118,6 @@ class RPCServer:
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
try:
logger.info(
f"Processing RPC instruction: {msg['action']} with request_id: {request_id}. Parameters: {msg.get('parameter')}"
)
method = msg["action"]
args = msg["parameter"].get("args", [])
kwargs = msg["parameter"].get("kwargs", {})
@@ -260,10 +257,6 @@ class RPCServer:
else:
name = WidgetContainerUtils.generate_unique_name("dock_area", existing_dock_areas)
logger.info(
f"Launching new dock area with name: {name} and startup_profile: {startup_profile}"
)
result_widget = bw_launch.dock_area(object_name=name, startup_profile=startup_profile)
result_widget.window().setWindowTitle(f"BEC - {name}")
@@ -303,9 +296,6 @@ class RPCServer:
else:
res = self.serialize_object(res)
except RegistryNotReadyError:
logger.info(
f"Object not registered yet for RPC request {request_id}, retrying serialization after {retry_delay} ms"
)
try:
self._rpc_singleshot_repeats[request_id] += retry_delay
QTimer.singleShot(

View File

@@ -35,16 +35,19 @@ logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
def create_action_with_text(toolbar_action, toolbar: QToolBar):
def create_action_with_text(toolbar_action, toolbar: QToolBar, min_size: QSize | None = None):
"""
Helper function to create a toolbar button with text beside or under the icon.
Args:
toolbar_action(ToolBarAction): The toolbar action to create the button for.
toolbar(ModularToolBar): The toolbar to add the button to.
min_size(QSize, optional): The minimum size for the button. Defaults to None.
"""
btn = QToolButton(parent=toolbar)
if min_size is not None:
btn.setMinimumSize(min_size)
if getattr(toolbar_action, "label_text", None):
toolbar_action.action.setText(toolbar_action.label_text)
if getattr(toolbar_action, "tooltip", None):

View File

@@ -63,7 +63,7 @@ class ScriptTreeWidget(QWidget):
layout.setSpacing(0)
# Create tree view
self.tree = QTreeView()
self.tree = QTreeView(parent=self)
self.tree.setHeaderHidden(True)
self.tree.setRootIsDecorated(True)
@@ -71,12 +71,12 @@ class ScriptTreeWidget(QWidget):
self.tree.setMouseTracking(True)
# Create file system model
self.model = QFileSystemModel()
self.model = QFileSystemModel(parent=self)
self.model.setNameFilters(["*.py"])
self.model.setNameFilterDisables(False)
# Create proxy model to filter out underscore directories
self.proxy_model = QSortFilterProxyModel()
self.proxy_model = QSortFilterProxyModel(parent=self)
self.proxy_model.setFilterRegularExpression(QRegularExpression("^[^_].*"))
self.proxy_model.setSourceModel(self.model)
self.tree.setModel(self.proxy_model)

View File

@@ -5,9 +5,8 @@ in DeviceTableRow entries.
from __future__ import annotations
import traceback
from copy import deepcopy
from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, Tuple
from typing import TYPE_CHECKING, Any, Callable, Iterable, Tuple
from bec_lib.atlas_models import Device as DeviceModel
from bec_lib.callback_handler import EventType
@@ -19,6 +18,7 @@ from thefuzz import fuzz
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.fuzzy_search import is_match
from bec_widgets.widgets.control.device_manager.components.device_table.device_table_row import (
DeviceTableRow,
)
@@ -37,34 +37,6 @@ _DeviceCfgIter = Iterable[dict[str, Any]]
# DeviceValidationResult: device_config, config_status, connection_status, error_message
_ValidationResultIter = Iterable[Tuple[dict[str, Any], ConfigStatus, ConnectionStatus, str]]
FUZZY_SEARCH_THRESHOLD = 80
def is_match(
text: str, row_data: dict[str, Any], relevant_keys: list[str], enable_fuzzy: bool
) -> bool:
"""
Check if the text matches any of the relevant keys in the row data.
Args:
text (str): The text to search for.
row_data (dict[str, Any]): The row data to search in.
relevant_keys (list[str]): The keys to consider for searching.
enable_fuzzy (bool): Whether to use fuzzy matching.
Returns:
bool: True if a match is found, False otherwise.
"""
for key in relevant_keys:
data = str(row_data.get(key, "") or "")
if enable_fuzzy:
match_ratio = fuzz.partial_ratio(text.lower(), data.lower())
if match_ratio >= FUZZY_SEARCH_THRESHOLD:
return True
else:
if text.lower() in data.lower():
return True
return False
class TableSortOnHold:
"""Context manager for putting table sorting on hold. Works with nested calls."""

View File

@@ -2,22 +2,19 @@
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtWidgets import QComboBox, QVBoxLayout, QWidget
from qtpy.QtWidgets import QComboBox
from bec_widgets.utils.bec_widget import BECWidget
logger = bec_logger.logger
class DapComboBox(BECWidget, QWidget):
class DapComboBox(BECWidget, QComboBox):
"""
The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC.
Editable combobox listing the available DAP models.
Args:
parent: Parent widget.
client: BEC client object.
gui_id: GUI ID.
default: Default device name.
The widget behaves as a plain QComboBox and keeps ``fit_model_combobox`` as an alias to itself
for backwards compatibility with older call sites.
"""
ICON_NAME = "data_exploration"
@@ -45,19 +42,20 @@ class DapComboBox(BECWidget, QWidget):
**kwargs,
):
super().__init__(parent=parent, client=client, gui_id=gui_id, **kwargs)
self.layout = QVBoxLayout(self)
self.fit_model_combobox = QComboBox(self)
self.layout.addWidget(self.fit_model_combobox)
self.layout.setContentsMargins(0, 0, 0, 0)
self._available_models = None
self.fit_model_combobox = self # Just for backwards compatibility with older call sites, the widget itself is the combobox
self._available_models: list[str] = []
self._x_axis = None
self._y_axis = None
self.populate_fit_model_combobox()
self.fit_model_combobox.currentTextChanged.connect(self._update_current_fit)
# Set default fit model
self.select_default_fit(default_fit)
self._is_valid_input = False
def select_default_fit(self, default_fit: str | None):
self.setEditable(True)
self.populate_fit_model_combobox()
self.currentTextChanged.connect(self._on_text_changed)
self.select_default_fit(default_fit)
self.check_validity(self.currentText())
def select_default_fit(self, default_fit: str | None = "GaussianModel"):
"""Set the default fit model.
Args:
@@ -65,8 +63,8 @@ class DapComboBox(BECWidget, QWidget):
"""
if self._validate_dap_model(default_fit):
self.select_fit_model(default_fit)
else:
self.select_fit_model("GaussianModel")
elif self.available_models:
self.select_fit_model(self.available_models[0])
@property
def available_models(self):
@@ -114,12 +112,40 @@ class DapComboBox(BECWidget, QWidget):
self._y_axis = y_axis
self.y_axis_updated.emit(y_axis)
def _update_current_fit(self, fit_name: str):
"""Update the current fit."""
@Slot(str)
def _on_text_changed(self, fit_name: str):
"""
Validate and emit updates for the current text.
Args:
fit_name(str): The current text in the combobox, representing the selected fit model.
"""
self.check_validity(fit_name)
if not self._is_valid_input:
return
self.fit_model_updated.emit(fit_name)
if self.x_axis is not None and self.y_axis is not None:
self.new_dap_config.emit(self._x_axis, self._y_axis, fit_name)
@Slot(str)
def check_validity(self, fit_name: str):
"""
Highlight invalid manual entries similarly to DeviceComboBox.
Args:
fit_name(str): The current text in the combobox, representing the selected fit model.
"""
if self._validate_dap_model(fit_name):
self._is_valid_input = True
self.setStyleSheet("border: 1px solid transparent;")
else:
self._is_valid_input = False
if self.isEnabled():
self.setStyleSheet("border: 1px solid red;")
else:
self.setStyleSheet("border: 1px solid transparent;")
@Slot(str)
def select_x_axis(self, x_axis: str):
"""Slot to update the x axis.
@@ -128,7 +154,7 @@ class DapComboBox(BECWidget, QWidget):
x_axis(str): X axis.
"""
self.x_axis = x_axis
self._update_current_fit(self.fit_model_combobox.currentText())
self._on_text_changed(self.currentText())
@Slot(str)
def select_y_axis(self, y_axis: str):
@@ -138,25 +164,26 @@ class DapComboBox(BECWidget, QWidget):
y_axis(str): Y axis.
"""
self.y_axis = y_axis
self._update_current_fit(self.fit_model_combobox.currentText())
self._on_text_changed(self.currentText())
@Slot(str)
def select_fit_model(self, fit_name: str | None):
"""Slot to update the fit model.
Args:
default_device(str): Default device name.
fit_name(str): Fit model name.
"""
if not self._validate_dap_model(fit_name):
raise ValueError(f"Fit {fit_name} is not valid.")
self.fit_model_combobox.setCurrentText(fit_name)
self.setCurrentText(fit_name)
def populate_fit_model_combobox(self):
"""Populate the fit_model_combobox with the devices."""
# pylint: disable=protected-access
self.available_models = [model for model in self.client.dap._available_dap_plugins.keys()]
self.fit_model_combobox.clear()
self.fit_model_combobox.addItems(self.available_models)
available_plugins = getattr(getattr(self.client, "dap", None), "_available_dap_plugins", {})
self.available_models = [model for model in available_plugins.keys()]
self.clear()
self.addItems(self.available_models)
def _validate_dap_model(self, model: str | None) -> bool:
"""Validate the DAP model.
@@ -166,23 +193,23 @@ class DapComboBox(BECWidget, QWidget):
"""
if model is None:
return False
if model not in self.available_models:
return False
return True
return model in self.available_models
@property
def is_valid_input(self) -> bool:
"""Whether the current text matches an available DAP model."""
return self._is_valid_input
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
import sys
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import apply_theme
app = QApplication([])
app = QApplication(sys.argv)
apply_theme("dark")
widget = QWidget()
widget.setFixedSize(200, 200)
layout = QVBoxLayout()
widget.setLayout(layout)
layout.addWidget(DapComboBox())
widget.show()
app.exec_()
dialog = DapComboBox()
dialog.show()
sys.exit(app.exec_())

View File

@@ -270,6 +270,16 @@ class Image(ImageBase):
return
old_device = self._config.device
old_signal = self._config.signal
old_config = self.subscriptions["main"]
if old_device and old_signal and old_device != value:
self._disconnect_monitor_subscription(
device=old_device,
signal=old_signal,
source=old_config.source,
async_update=self.async_update,
async_signal_name=old_config.async_signal_name,
)
self._config.device = value
# If we have a signal, reconnect with the new device
@@ -325,6 +335,16 @@ class Image(ImageBase):
self._set_connection_status("disconnected")
return
old_signal = self._config.signal
old_config = self.subscriptions["main"]
if self._config.device and old_signal and old_signal != value:
self._disconnect_monitor_subscription(
device=self._config.device,
signal=old_signal,
source=old_config.source,
async_update=self.async_update,
async_signal_name=old_config.async_signal_name,
)
self._config.signal = value
# If we have a device, try to connect
@@ -447,6 +467,61 @@ class Image(ImageBase):
)
self._autorange_on_next_update = True
def _disconnect_monitor_subscription(
self,
*,
device: str,
signal: str,
source: Literal["device_monitor_1d", "device_monitor_2d"] | None,
async_update: bool,
async_signal_name: str | None,
) -> None:
if not device or not signal:
return
if async_update:
async_signal_name = async_signal_name or signal
ids_to_check = [self.scan_id, self.old_scan_id]
if source == "device_monitor_1d":
for scan_id in ids_to_check:
if scan_id is None:
continue
self.bec_dispatcher.disconnect_slot(
self.on_image_update_1d,
MessageEndpoints.device_async_signal(scan_id, device, async_signal_name),
)
logger.info(
f"Disconnecting 1d update ScanID:{scan_id}, Device Name:{device},Device Entry:{async_signal_name}"
)
elif source == "device_monitor_2d":
for scan_id in ids_to_check:
if scan_id is None:
continue
self.bec_dispatcher.disconnect_slot(
self.on_image_update_2d,
MessageEndpoints.device_async_signal(scan_id, device, async_signal_name),
)
logger.info(
f"Disconnecting 2d update ScanID:{scan_id}, Device Name:{device},Device Entry:{async_signal_name}"
)
return
if source == "device_monitor_1d":
self.bec_dispatcher.disconnect_slot(
self.on_image_update_1d, MessageEndpoints.device_preview(device, signal)
)
logger.info(
f"Disconnecting preview 1d update Device Name:{device}, Device Entry:{signal}"
)
elif source == "device_monitor_2d":
self.bec_dispatcher.disconnect_slot(
self.on_image_update_2d, MessageEndpoints.device_preview(device, signal)
)
logger.info(
f"Disconnecting preview 2d update Device Name:{device}, Device Entry:{signal}"
)
def _disconnect_current_monitor(self):
"""
Internal method to disconnect the current monitor subscriptions.
@@ -455,55 +530,13 @@ class Image(ImageBase):
return
config = self.subscriptions["main"]
if self.async_update:
async_signal_name = config.async_signal_name or self._config.signal
ids_to_check = [self.scan_id, self.old_scan_id]
if config.source == "device_monitor_1d":
for scan_id in ids_to_check:
if scan_id is None:
continue
self.bec_dispatcher.disconnect_slot(
self.on_image_update_1d,
MessageEndpoints.device_async_signal(
scan_id, self._config.device, async_signal_name
),
)
logger.info(
f"Disconnecting 1d update ScanID:{scan_id}, Device Name:{self._config.device},Device Entry:{async_signal_name}"
)
elif config.source == "device_monitor_2d":
for scan_id in ids_to_check:
if scan_id is None:
continue
self.bec_dispatcher.disconnect_slot(
self.on_image_update_2d,
MessageEndpoints.device_async_signal(
scan_id, self._config.device, async_signal_name
),
)
logger.info(
f"Disconnecting 2d update ScanID:{scan_id}, Device Name:{self._config.device},Device Entry:{async_signal_name}"
)
else:
if config.source == "device_monitor_1d":
self.bec_dispatcher.disconnect_slot(
self.on_image_update_1d,
MessageEndpoints.device_preview(self._config.device, self._config.signal),
)
logger.info(
f"Disconnecting preview 1d update Device Name:{self._config.device}, Device Entry:{self._config.signal}"
)
elif config.source == "device_monitor_2d":
self.bec_dispatcher.disconnect_slot(
self.on_image_update_2d,
MessageEndpoints.device_preview(self._config.device, self._config.signal),
)
logger.info(
f"Disconnecting preview 2d update Device Name:{self._config.device}, Device Entry:{self._config.signal}"
)
self._disconnect_monitor_subscription(
device=self._config.device,
signal=self._config.signal,
source=config.source,
async_update=self.async_update,
async_signal_name=config.async_signal_name,
)
# Reset async state
self.async_update = False
@@ -860,45 +893,19 @@ class Image(ImageBase):
logger.warning("Cannot disconnect monitor without both device and signal")
return
if self.async_update:
async_signal_name = config.async_signal_name or target_entry
ids_to_check = [self.scan_id, self.old_scan_id]
if config.source == "device_monitor_1d":
for scan_id in ids_to_check:
if scan_id is None:
continue
self.bec_dispatcher.disconnect_slot(
self.on_image_update_1d,
MessageEndpoints.device_async_signal(
scan_id, target_device, async_signal_name
),
)
elif config.source == "device_monitor_2d":
for scan_id in ids_to_check:
if scan_id is None:
continue
self.bec_dispatcher.disconnect_slot(
self.on_image_update_2d,
MessageEndpoints.device_async_signal(
scan_id, target_device, async_signal_name
),
)
else:
if config.source == "device_monitor_1d":
self.bec_dispatcher.disconnect_slot(
self.on_image_update_1d,
MessageEndpoints.device_preview(target_device, target_entry),
)
elif config.source == "device_monitor_2d":
self.bec_dispatcher.disconnect_slot(
self.on_image_update_2d,
MessageEndpoints.device_preview(target_device, target_entry),
)
else:
logger.warning(
f"Cannot disconnect monitor {target_device}.{target_entry} with source {self.subscriptions['main'].source}"
)
return
if config.source not in {"device_monitor_1d", "device_monitor_2d"}:
logger.warning(
f"Cannot disconnect monitor {target_device}.{target_entry} with source {self.subscriptions['main'].source}"
)
return
self._disconnect_monitor_subscription(
device=target_device,
signal=target_entry,
source=config.source,
async_update=self.async_update,
async_signal_name=config.async_signal_name,
)
self.subscriptions["main"].async_signal_name = None
self.async_update = False

View File

@@ -0,0 +1,600 @@
"""Admin View panel for setting up account and messaging services in BEC."""
from __future__ import annotations
import time
from typing import TYPE_CHECKING
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.messages import DeploymentInfoMessage, ExperimentInfoMessage
from qtpy.QtCore import QSize, Qt, QTimer, Signal
from qtpy.QtWidgets import (
QFrame,
QGroupBox,
QHBoxLayout,
QLabel,
QSizePolicy,
QStackedLayout,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_login import BECLogin
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import (
MaterialIconAction,
WidgetAction,
create_action_with_text,
)
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_http_service import (
AtlasEndpoints,
AuthenticatedUserInfo,
BECAtlasHTTPService,
HTTPResponse,
)
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_mat_card import (
ExperimentMatCard,
)
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_selection import (
ExperimentSelection,
)
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtWidgets import QToolBar
logger = bec_logger.logger
class OverviewWidget(QGroupBox):
"""Overview Widget for the BEC Atlas Admin view"""
login_requested = Signal(str, str)
change_experiment_requested = Signal()
def __init__(self, parent=None):
super().__init__(parent=parent)
self.setContentsMargins(12, 0, 12, 6)
self._authenticated = False
# Root layout
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(0, 0, 0, 0)
self.root_layout.setSpacing(0)
# Stacked Layout to switch between login form and overview content
self.stacked_layout = QStackedLayout()
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
self.stacked_layout.setSpacing(0)
self.stacked_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
self.root_layout.addLayout(self.stacked_layout)
self._init_login_view()
self._init_experiment_overview()
self.stacked_layout.setCurrentWidget(self._login_widget)
self._experiment_overview_widget.setVisible(False)
def set_experiment_info(self, experiment_info: ExperimentInfoMessage):
"""Set the experiment information for the overview widget."""
self._experiment_overview_widget.set_experiment_info(experiment_info)
@SafeSlot(bool)
def set_authenticated(self, authenticated: bool):
"""Set the authentication state of the overview widget."""
self._authenticated = authenticated
if authenticated:
self.stacked_layout.setCurrentWidget(self._experiment_overview_widget)
self._experiment_overview_widget.setVisible(True)
else:
self.stacked_layout.setCurrentWidget(self._login_widget)
self._experiment_overview_widget.setVisible(False)
def _init_login_view(self):
"""Initialize the login view."""
self._login_widget = QWidget()
layout = QHBoxLayout(self._login_widget)
self._login_widget.setAutoFillBackground(True)
self._login_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
layout.setSpacing(16)
content = QFrame()
content_layout = QVBoxLayout(content)
content.setFrameShape(QFrame.Shape.StyledPanel)
content.setFrameShadow(QFrame.Shadow.Raised)
content.setStyleSheet("""
QFrame
{
border: 1px solid #cccccc;
}
QLabel
{
border: none;
}
""")
content_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
content.setFixedSize(400, 280)
self._login = BECLogin(parent=self)
self._login.credentials_entered.connect(self.login_requested.emit)
content_layout.addWidget(self._login)
layout.addWidget(content)
self.stacked_layout.addWidget(self._login_widget)
def _init_experiment_overview(self):
"""Initialize the experiment overview content."""
self._experiment_overview_widget = ExperimentMatCard(
show_activate_button=True,
parent=self,
title="Current Experiment",
button_text="Change Experiment",
)
self._experiment_overview_widget.experiment_selected.connect(self._on_experiment_selected)
layout = QVBoxLayout(self._experiment_overview_widget)
self._experiment_overview_widget.setAutoFillBackground(True)
self._experiment_overview_widget.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
layout.setSpacing(16)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.stacked_layout.addWidget(self._experiment_overview_widget)
@SafeSlot(dict)
def _on_experiment_selected(self, _):
"""Handle the change experiment button click."""
self.change_experiment_requested.emit()
class CustomLogoutAction(MaterialIconAction):
"""Custom logout action that can be enabled/disabled based on authentication state."""
def __init__(self, parent=None):
super().__init__(
icon_name="logout",
tooltip="Logout",
label_text="Logout",
text_position="under",
parent=parent,
filled=True,
)
self.action.setEnabled(False) # Initially disabled until authenticated
self._tick_timer = QTimer(parent)
self._tick_timer.setInterval(1000)
self._tick_timer.timeout.connect(self._on_tick)
self._login_remaining_s = 0
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""
Adds the action to the toolbar.
Args:
toolbar(QToolBar): The toolbar to add the action to.
target(QWidget): The target widget for the action.
"""
create_action_with_text(toolbar_action=self, toolbar=toolbar, min_size=QSize(70, 40))
def set_authenticated(self, auth_info: AuthenticatedUserInfo | None):
"""Enable or disable the logout action based on authentication state."""
if not auth_info:
self._tick_timer.stop()
self._login_remaining_s = 0
self.action.setEnabled(False)
self.update_label() # Reset Label text
return # No need to set the timer if we're not authenticated
self._login_remaining_s = max(0, int(auth_info.exp - time.time())) if auth_info else 0
self.action.setEnabled(True)
if self._login_remaining_s > 0:
self._tick_timer.start()
def _on_tick(self) -> None:
"""Handle the timer countdown tick to update the remaining logout time."""
self._login_remaining_s -= 1
if self._login_remaining_s <= 0:
self.set_authenticated(None) # This will disable the action and stop the timer
return
self.update_label() # Optionally update the label to show remaining time
def update_label(self):
"""Update the label text of the logout action."""
if self._login_remaining_s > 0:
label_text = f"{self.label_text}\n({self._login_remaining_s}s)"
else:
label_text = self.label_text
self.action.setText(label_text)
def cleanup(self):
"""Cleanup the timer when the action is destroyed."""
if self._tick_timer.isActive():
self._tick_timer.stop()
class AtlasConnectionInfo(QWidget):
def __init__(self, parent=None):
super().__init__(parent=parent)
layout = QVBoxLayout(self)
layout.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
layout.setContentsMargins(6, 6, 6, 12)
layout.setSpacing(8)
self._bl_info_label = QLabel(self)
self._atlas_url_label = QLabel(self)
layout.addWidget(self._bl_info_label)
layout.addWidget(self._atlas_url_label)
self._atlas_url_text = ""
def set_info(self, realm_id: str, bl_name: str, atlas_url: str):
"""Set the connection information for the BEC Atlas API."""
bl_info = f"{realm_id} @ {bl_name}"
self._bl_info_label.setText(bl_info)
self._atlas_url_label.setText(atlas_url)
self._atlas_url_text = atlas_url
def set_logged_in(self, email: str):
"""Show login status in the atlas info widget."""
self._atlas_url_label.setText(f"{self._atlas_url_text} | {email}")
def clear_login(self):
"""Clear login status from the atlas info widget."""
self._atlas_url_label.setText(self._atlas_url_text)
class BECAtlasAdminView(BECWidget, QWidget):
RPC = False
authenticated = Signal(bool)
def __init__(
self,
parent=None,
atlas_url: str = "https://bec-atlas-dev.psi.ch/api/v1",
client=None,
**kwargs,
):
super().__init__(parent=parent, client=client, **kwargs)
# State variables
self._current_deployment_info: DeploymentInfoMessage | None = None
self._current_deployment_info = None
self._current_session_info = None
self._current_experiment_info = None
self._authenticated = False
self._atlas_url = atlas_url
# Root layout
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(0, 0, 0, 0)
self.root_layout.setSpacing(0)
# Toolbar for navigation between different views in the admin panel
self.toolbar = ModularToolBar(self)
self.init_toolbar()
self.root_layout.insertWidget(0, self.toolbar)
self.toolbar.show_bundles(["view", "atlas_info", "auth"])
# Stacked layout to switch between overview, experiment selection and messaging services
# It is added below the toolbar
self.stacked_layout = QStackedLayout()
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
self.stacked_layout.setSpacing(0)
self.stacked_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
self.root_layout.addLayout(self.stacked_layout)
# BEC Atlas HTTP Service
self.atlas_http_service = BECAtlasHTTPService(
parent=self, base_url=atlas_url, headers={"accept": "application/json"}
)
# Overview widget
self.overview_widget = OverviewWidget(parent=self)
self.stacked_layout.addWidget(self.overview_widget)
# Experiment Selection widget
self.experiment_selection = ExperimentSelection(parent=self)
self.experiment_selection.setVisible(False)
self.stacked_layout.addWidget(self.experiment_selection)
# Connect signals
self.overview_widget.login_requested.connect(self._on_login_requested)
self.overview_widget.change_experiment_requested.connect(
self._on_experiment_selection_selected
)
self.authenticated.connect(self.overview_widget.set_authenticated)
self.experiment_selection.experiment_selected.connect(self._on_experiment_selected)
self.atlas_http_service.http_response.connect(self._on_http_response_received)
self.atlas_http_service.authenticated.connect(self._on_authenticated)
self._connect_dispatcher()
def _connect_dispatcher(self):
self.bec_dispatcher.connect_slot(
slot=self._update_deployment_info,
topics=MessageEndpoints.deployment_info(),
from_start=True,
)
def init_toolbar(self):
"""Initialize the toolbar for the admin view. This allows to switch between different views in the admin panel."""
# Overview
overview = MaterialIconAction(
icon_name="home",
tooltip="Show Overview Panel",
label_text="Overview",
text_position="under",
parent=self,
filled=True,
)
overview.action.triggered.connect(self._on_overview_selected)
self.toolbar.components.add_safe("overview", overview)
# Experiment Selection
experiment_selection = MaterialIconAction(
icon_name="experiment",
tooltip="Show Experiment Selection Panel",
label_text="Experiment Selection",
text_position="under",
parent=self,
filled=True,
)
experiment_selection.action.triggered.connect(self._on_experiment_selection_selected)
experiment_selection.action.setEnabled(False) # Initially disabled until authenticated
self.toolbar.components.add_safe("experiment_selection", experiment_selection)
# Messaging Services
messaging_services = MaterialIconAction(
icon_name="chat",
tooltip="Show Messaging Services Panel",
label_text="Messaging Services",
text_position="under",
parent=self,
filled=True,
)
messaging_services.action.triggered.connect(self._on_messaging_services_selected)
messaging_services.action.setEnabled(False) # Initially disabled until authenticated
self.toolbar.components.add_safe("messaging_services", messaging_services)
# Atlas Info
self._atlas_info_widget = AtlasConnectionInfo(parent=self)
atlas_info = WidgetAction(widget=self._atlas_info_widget, parent=self)
self.toolbar.components.add_safe("atlas_info", atlas_info)
logout_action = CustomLogoutAction(parent=self)
logout_action.action.triggered.connect(self.logout)
logout_action.action.setEnabled(False) # Initially disabled until authenticated
self.toolbar.components.add_safe("logout", logout_action)
# Add view_bundle to toolbar
view_bundle = ToolbarBundle("view", self.toolbar.components)
view_bundle.add_action("overview")
view_bundle.add_action("experiment_selection")
view_bundle.add_action("messaging_services")
self.toolbar.add_bundle(view_bundle)
# Add atlas_info to toolbar
atlas_info_bundle = ToolbarBundle("atlas_info", self.toolbar.components)
atlas_info_bundle.add_action("atlas_info")
self.toolbar.add_bundle(atlas_info_bundle)
# Add auth_bundle to toolbar
auth_bundle = ToolbarBundle("auth", self.toolbar.components)
auth_bundle.add_action("logout")
self.toolbar.add_bundle(auth_bundle)
########################
## Toolbar icon slots
########################
def _on_overview_selected(self):
"""Show the overview panel."""
self.overview_widget.setVisible(True)
self.experiment_selection.setVisible(False)
self.stacked_layout.setCurrentWidget(self.overview_widget)
def _on_experiment_selection_selected(self):
"""Show the experiment selection panel."""
if not self._authenticated:
logger.warning("Attempted to access experiment selection without authentication.")
return
self.overview_widget.setVisible(False)
self.experiment_selection.setVisible(True)
self.stacked_layout.setCurrentWidget(self.experiment_selection)
def _on_messaging_services_selected(self):
"""Show the messaging services panel."""
logger.info("Messaging services panel is not implemented yet.")
return
########################
## Internal slots
########################
@SafeSlot(dict)
def _on_experiment_selected(self, experiment_info: dict) -> None:
"""Handle the experiment selected signal from the experiment selection widget"""
experiment_info = ExperimentInfoMessage.model_validate(experiment_info)
experiment_id = experiment_info.pgroup
deployment_id = self._current_deployment_info.deployment_id
self.set_experiment(experiment_id=experiment_id, deployment_id=deployment_id)
@SafeSlot(str, str, popup_error=True)
def _on_login_requested(self, username: str, password: str):
"""Handle login requested signal from the overview widget."""
# Logout first to clear any existing session and cookies before attempting a new login
if self._authenticated:
logger.info("Existing session detected, logging out before attempting new login.")
self.logout()
# Now login with new credentials
self.login(username, password)
@SafeSlot(dict, dict)
def _update_deployment_info(self, msg: dict, _: dict) -> None:
"""Fetch current deployment info from the server."""
deployment = DeploymentInfoMessage.model_validate(msg)
self._current_deployment_info = deployment
self._current_session_info = deployment.active_session
if self._current_session_info is not None:
self._current_experiment_info = self._current_session_info.experiment
self.overview_widget.set_experiment_info(self._current_experiment_info)
self._atlas_info_widget.set_info(
realm_id=self._current_experiment_info.realm_id or "",
bl_name=self._current_deployment_info.name or "",
atlas_url=self._atlas_url,
)
self.atlas_http_service._set_current_deployment_info(deployment)
def _fetch_available_experiments(self):
"""Fetch the list of available experiments for the authenticated user."""
# What if this is None, should this be an optional user input in the UI?
if self._current_experiment_info is None:
logger.error(
"No current experiment info available, cannot fetch available experiments."
)
return
current_realm_id = self._current_experiment_info.realm_id
if current_realm_id is None:
logger.error(
"Current experiment does not have a realm_id, cannot fetch available experiments."
)
return
self.atlas_http_service.get_experiments_for_realm(current_realm_id)
########################
## HTTP Service response handling
########################
def _on_http_response_received(self, response: dict) -> None:
"""Handle the HTTP response received from the BEC Atlas API."""
response = HTTPResponse(**response)
logger.debug(
f"HTTP Response received: {response.request_url} with status {response.status}"
)
if AtlasEndpoints.REALMS_EXPERIMENTS in response.request_url:
experiments = response.data if isinstance(response.data, list) else []
self.experiment_selection.set_experiment_infos(experiments)
elif AtlasEndpoints.SET_EXPERIMENT in response.request_url:
self._on_overview_selected()
@SafeSlot(dict)
def _on_authenticated(self, auth_info: dict) -> None:
"""Handle authentication state change."""
authenticated = False
# Only if the user has owner access to the deployment, we consider them to be fully authenticated
# This means that although they may authenticate against atlas, they won't be able to see any
# extra information here
if auth_info:
info = AuthenticatedUserInfo.model_validate(auth_info)
if (
self._current_deployment_info
and info.deployment_id == self._current_deployment_info.deployment_id
):
authenticated = True
else:
logger.warning(
f"Authenticated user {info.email} does not have access to the current deployment {self._current_deployment_info.name if self._current_deployment_info else '<no deployment>'}."
)
self._authenticated = authenticated
if authenticated:
self.toolbar.components.get_action("experiment_selection").action.setEnabled(True)
self.toolbar.components.get_action("messaging_services").action.setEnabled(
False
) # TODO activate once messaging is added
self.toolbar.components.get_action("logout").action.setEnabled(True)
self._fetch_available_experiments() # Fetch experiments upon successful authentication
self._atlas_info_widget.set_logged_in(info.email)
self.toolbar.components.get_action("logout").set_authenticated(info)
else:
self.toolbar.components.get_action("experiment_selection").action.setEnabled(False)
self.toolbar.components.get_action("messaging_services").action.setEnabled(False)
self.toolbar.components.get_action("logout").action.setEnabled(False)
# Delete data in experiment selection widget upon logout
self.experiment_selection.set_experiment_infos([])
self._on_overview_selected() # Switch back to overview on logout
self._atlas_info_widget.clear_login() # Clear login status in atlas info widget on logout
self.toolbar.components.get_action("logout").set_authenticated(None)
self.authenticated.emit(authenticated)
################
## API Methods
################
@SafeSlot(str, str, popup_error=True)
def set_experiment(self, experiment_id: str, deployment_id: str) -> None:
"""Set the experiment information for the current experiment."""
self.atlas_http_service.set_experiment(experiment_id, deployment_id)
@SafeSlot(str, str, popup_error=True)
def login(self, username: str, password: str) -> None:
"""Login to the BEC Atlas API with the provided username and password."""
self.atlas_http_service.login(username=username, password=password)
@SafeSlot(popup_error=True)
def logout(self) -> None:
"""Logout from the BEC Atlas API."""
self.atlas_http_service.logout()
def get_user_info(self):
"""Get the current user information from the BEC Atlas API."""
self.atlas_http_service.get_user_info()
###############
## Cleanup
###############
def cleanup(self):
self.atlas_http_service.cleanup()
return super().cleanup()
if __name__ == "__main__":
import sys
from bec_qthemes import apply_theme
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
apply_theme("light")
window = BECAtlasAdminView()
exp_info_dict = {
"_id": "p22622",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_xda_bs", "p22622"],
"realm_id": "TestBeamline",
"proposal": "12345967",
"title": "Test Experiment for Mat Card Widget",
"firstname": "John",
"lastname": "Doe",
"email": "john.doe@psi.ch",
"account": "doe_j",
"pi_firstname": "Jane",
"pi_lastname": "Smith",
"pi_email": "jane.smith@psi.ch",
"pi_account": "smith_j",
"eaccount": "e22622",
"pgroup": "p22622",
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
"proposal_submitted": "15/12/2024",
"proposal_expire": "31/12/2025",
"proposal_status": "Scheduled",
"delta_last_schedule": 30,
"mainproposal": "",
}
from bec_lib.messages import DeploymentInfoMessage, ExperimentInfoMessage, SessionInfoMessage
# proposal_info = ExperimentInfoMessage(**exp_info_dict)
# session_info = SessionInfoMessage(name="Test Session", experiment=proposal_info)
# deployment_info = DeploymentInfoMessage(
# deployment_id="test_deployment_001", active_session=session_info
# )
# window.set_experiment_info(proposal_info)
window.resize(800, 600)
window.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,386 @@
"""
HTTP service widget for interacting with the BEC Atlas API.
This module defines Qt-based classes that wrap ``QNetworkAccessManager`` to perform
authenticated HTTP requests against the BEC Atlas backend, manage login and logout
flows, and track authentication token expiry for the BEC Atlas Admin View.
It also provides Pydantic models that describe HTTP responses and authenticated user
information, as well as an ``AuthenticatedTimer`` helper used to signal when
authentication tokens expire.
"""
import json
import time
from enum import StrEnum
from typing import Literal
import jwt
from bec_lib.logger import bec_logger
from bec_lib.messages import DeploymentInfoMessage
from pydantic import BaseModel
from qtpy.QtCore import QObject, QTimer, QUrl, QUrlQuery, Signal
from qtpy.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
from qtpy.QtWidgets import QMessageBox, QWidget
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
class AtlasEndpoints(StrEnum):
"""Constants for BEC Atlas API endpoints."""
LOGIN = "/user/login"
LOGOUT = "/user/logout"
REALMS_EXPERIMENTS = "/realms/experiments"
SET_EXPERIMENT = "/deployments/experiment"
USER_INFO = "/user/me"
DEPLOYMENT_INFO = "/deployments/id"
class BECAtlasHTTPError(Exception):
"""Custom exception for BEC Atlas HTTP errors."""
class HTTPResponse(BaseModel):
"""Model representing an HTTP response."""
request_url: str
headers: dict
status: int
data: dict | list | str # Check with Klaus if str is deprecated
class AuthenticatedUserInfo(BaseModel):
"""Model representing authenticated user information."""
email: str
exp: float
groups: set[str]
deployment_id: str
class AuthenticatedTimer(QObject):
"""Timer to track authentication expiration and emit a signal when the token expires."""
expired = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self._on_expired)
def start(self, duration_seconds: float):
"""Start the timer with the given duration in seconds."""
self._timer.start(int(duration_seconds * 1000))
def stop(self):
"""Stop the timer."""
self._timer.stop()
@SafeSlot()
def _on_expired(self):
"""Handle the timer expiration by emitting the expired signal."""
logger.info("Authentication token has expired.")
self.expired.emit()
class BECAtlasHTTPService(QWidget):
"""HTTP service using the QNetworkAccessManager to interact with the BEC Atlas API."""
http_response = Signal(dict) # HTTPResponse.model_dump() dict
authenticated = Signal(dict) # AuthenticatedUserInfo.model_dump() dict or {}
authentication_expires = Signal(float)
def __init__(self, parent=None, base_url: str = "", headers: dict | None = None):
super().__init__(parent)
if headers is None:
headers = {"accept": "application/json"}
self._headers = headers
self._base_url = base_url
self.network_manager = QNetworkAccessManager(self)
self.network_manager.finished.connect(self._handle_response)
self._auth_user_info: AuthenticatedUserInfo | None = None
self._auth_timer = self._create_auth_timer()
self._current_deployment_info = None
def _create_auth_timer(self) -> AuthenticatedTimer:
"""Create and connect the authenticated timer to handle token expiration."""
timer = AuthenticatedTimer(self)
timer.expired.connect(self.__clear_login_info)
return timer
@property
def auth_user_info(self) -> AuthenticatedUserInfo | None:
"""Get the authenticated user information, including email and token expiration time."""
return self._auth_user_info
def __set_auth_info(self, login_info: dict[Literal["email", "exp"], str | float]):
"""Set the authenticated user information after a successful login."""
login_info.update({"groups": []}) # Initialize groups as empty until we fetch user info
login_info.update(
{
"deployment_id": (
self._current_deployment_info.deployment_id
if self._current_deployment_info
else ""
)
}
)
self._auth_user_info = AuthenticatedUserInfo(**login_info)
# Start timer to clear auth info once token expires
exp_time = login_info.get("exp", 0)
current_time = time.time() # TODO should we use server time to avoid clock skew issues?
duration = max(0, exp_time - current_time)
self._auth_timer.start(duration)
def __set_auth_groups(self, groups: list[str]):
"""Set the authenticated user's groups after fetching user info."""
if self._auth_user_info is not None:
self._auth_user_info.groups = set(groups)
def __clear_login_info(self, skip_logout: bool = False):
"""Clear the authenticated user information after logout."""
self._auth_user_info = None
if not skip_logout:
self.logout() # Ensure we also logout on the server side and invalidate the session
def closeEvent(self, event):
self.cleanup()
return super().closeEvent(event)
def cleanup(self):
"""Cleanup connection, destroy authenticate cookies."""
logger.info("Cleaning up BECAtlasHTTPService: disconnecting signals and clearing cookies.")
# Disconnect signals to avoid handling responses after cleanup
self.network_manager.finished.disconnect(self._handle_response)
# Logout to invalidate session on server side
self.logout()
# Stop the authentication timer
self._auth_timer.stop()
# Delete all cookies related to the base URL
for cookie in self.network_manager.cookieJar().cookiesForUrl(QUrl(self._base_url)):
self.network_manager.cookieJar().deleteCookie(cookie)
@SafeSlot(QNetworkReply, popup_error=True)
def _handle_response(self, reply: QNetworkReply):
"""
Handle the HTTP response from the server.
Args:
reply (QNetworkReply): The network reply object containing the response.
"""
status = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
raw_bytes = reply.readAll().data()
request_url = reply.url().toString()
headers = dict([(i.data().decode(), j.data().decode()) for i, j in reply.rawHeaderPairs()])
reply.deleteLater()
# Any unsuccessful status code should raise here
if status != 200:
raise BECAtlasHTTPError(
f"HTTP request for {request_url} failed with status code {status} and response: {raw_bytes.decode('utf-8')}"
)
if len(raw_bytes) > 0:
data = json.loads(raw_bytes.decode())
else:
data = {}
if data is None:
data = {}
logger.warning(f"Received empty response for {request_url} with status code {status}.")
if not isinstance(data, (dict, list, str)):
raise BECAtlasHTTPError(
f"Expected response data to be a dict, list, or str for {request_url}, but got {type(data)}. Response content: {data}"
)
if AtlasEndpoints.LOGIN.value in request_url:
# If it's a login response, don't forward the token
# but extract the expiration time and emit it
token = data.get("access_token")
data = jwt.decode(token, options={"verify_signature": False})
self.authentication_expires.emit(data.get("exp", 0))
# Now we set the auth info, and then fetch the user info to get the groups
self.__set_auth_info(data)
self.get_user_info() # Fetch groups, then emit authenticated once groups are set on auth_user
elif AtlasEndpoints.LOGOUT.value in request_url:
self._auth_timer.stop() # Stop the timer if it was running
self.__clear_login_info(skip_logout=True) # Skip calling logout again
self.authenticated.emit({})
elif AtlasEndpoints.USER_INFO.value in request_url:
groups = data.get("groups", [])
email = data.get("email", "")
# Second step of authentication: We also have all groups now
if self.auth_user_info is not None and self.auth_user_info.email == email:
self.__set_auth_groups(groups)
if self._current_deployment_info is not None:
# Now we need to fetch the deployment info to get the owner groups and check access rights,
# Then we can emit the authenticated signal with the full user info including groups if access is
# granted. Otherwise, we emit nothing and show a warning that the user does not have the access
# rights for the current deployment.
self.get_deployment_info(
deployment_id=self._current_deployment_info.deployment_id
)
elif AtlasEndpoints.DEPLOYMENT_INFO.value in request_url:
owner_groups = data.get("owner_groups", [])
if self.auth_user_info is not None and not self.auth_user_info.groups.isdisjoint(
owner_groups
):
self.authenticated.emit(self.auth_user_info.model_dump())
else:
if self.auth_user_info is not None:
warning_text = f"User {self.auth_user_info.email} does not have access to the active deployment {data.get('name', '<unknown>')}."
else:
warning_text = "Authenticated user information is missing. Cannot verify access to the active deployment."
self._show_warning(warning_text)
self.logout() # Logout to clear auth info and stop timer since user does not have access
response = HTTPResponse(request_url=request_url, headers=headers, status=status, data=data)
self.http_response.emit(response.model_dump())
def _show_warning(self, text: str):
"""Show a warning message to the user."""
msg = QMessageBox(self)
msg.setIcon(QMessageBox.Icon.Warning)
msg.setText(text)
msg.setWindowTitle("Authentication Warning")
msg.exec_()
#######################
# GET/POST Request Methods
#######################
def _get_request(self, endpoint: str, query_parameters: dict | None = None):
"""
GET request to the API endpoint.
Args:
endpoint (str): The API endpoint to send the GET request to.
query_parameters (dict | None): Optional query parameters to include in the URL.
"""
url = QUrl(self._base_url + endpoint)
if query_parameters:
query = QUrlQuery()
for key, value in query_parameters.items():
query.addQueryItem(key, value)
url.setQuery(query)
request = QNetworkRequest(url)
for key, value in self._headers.items():
request.setRawHeader(key.encode(), value.encode())
self.network_manager.get(request)
def _post_request(
self, endpoint: str, payload: dict | None = None, query_parameters: dict | None = None
):
"""
POST request to the API endpoint with a JSON payload.
Args:
endpoint (str): The API endpoint to send the POST request to.
payload (dict): The JSON payload to include in the POST request.
query_parameters (dict | None): Optional query parameters to include in the URL.
"""
if payload is None:
payload = {}
url = QUrl(self._base_url + endpoint)
if query_parameters:
query = QUrlQuery()
for key, value in query_parameters.items():
query.addQueryItem(key, value)
url.setQuery(query)
request = QNetworkRequest(url)
# Headers
for key, value in self._headers.items():
request.setRawHeader(key.encode(), value.encode())
request.setHeader(QNetworkRequest.KnownHeaders.ContentTypeHeader, "application/json")
payload_dump = json.dumps(payload).encode()
self.network_manager.post(request, payload_dump)
def _set_current_deployment_info(self, deployment_info: dict | DeploymentInfoMessage):
"""
Set the current deployment information for the service.
Args:
deployment_info (dict | DeploymentInfoMessage): The deployment information to set.
"""
if isinstance(deployment_info, dict):
deployment_info = DeploymentInfoMessage.model_validate(deployment_info)
self._current_deployment_info = deployment_info
################
# API Methods
################
@SafeSlot(str, str, popup_error=True)
def login(self, username: str, password: str):
"""
Login to BEC Atlas with the provided username and password.
Args:
username (str): The username for authentication.
password (str): The password for authentication.
"""
self._post_request(
endpoint=AtlasEndpoints.LOGIN.value,
payload={"username": username, "password": password},
)
@SafeSlot(popup_error=True)
def logout(self):
"""Logout from BEC Atlas."""
self._post_request(endpoint=AtlasEndpoints.LOGOUT.value)
@SafeSlot(str, popup_error=True)
def get_experiments_for_realm(self, realm_id: str):
"""
Get the list of realms from BEC Atlas. Requires authentication.
Args:
realm_id (str): The ID of the realm to retrieve experiments for.
"""
self._get_request(
endpoint=AtlasEndpoints.REALMS_EXPERIMENTS.value,
query_parameters={"realm_id": realm_id},
)
@SafeSlot(str, str)
def set_experiment(self, experiment_id: str, deployment_id: str) -> None:
"""
Set the current experiment information for the service.
Args:
experiment_id (str): The ID of the experiment to set.
deployment_id (str): The ID of the deployment associated with the experiment.
"""
self._post_request(
endpoint=AtlasEndpoints.SET_EXPERIMENT.value,
query_parameters={"experiment_id": experiment_id, "deployment_id": deployment_id},
)
@SafeSlot(popup_error=True)
def get_user_info(self):
"""Get the current user information from BEC Atlas. Requires authentication."""
self._get_request(endpoint=AtlasEndpoints.USER_INFO.value)
@SafeSlot(str, popup_error=True)
def get_deployment_info(self, deployment_id: str):
"""
Get the deployment information for a given deployment ID. Requires authentication.
Args:
deployment_id (str): The ID of the deployment to retrieve information for.
"""
self._get_request(
endpoint=AtlasEndpoints.DEPLOYMENT_INFO.value,
query_parameters={"deployment_id": deployment_id},
)

View File

@@ -0,0 +1,273 @@
"""Mat-card like widget to display experiment details. Optionally, a button on the bottom which the user can click to trigger the selection of the experiment."""
from bec_lib.messages import ExperimentInfoMessage
from qtpy.QtCore import Qt, Signal
from qtpy.QtWidgets import (
QFrame,
QGraphicsDropShadowEffect,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_theme_palette
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.utils import (
format_name,
format_schedule,
)
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
class BorderLessLabel(QLabel):
"""A QLabel that does not show any border, even when stylesheets try to apply one."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setStyleSheet("border: none;")
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred)
class ExperimentMatCard(BECWidget, QWidget):
RPC = False
experiment_selected = Signal(dict)
def __init__(
self,
parent=None,
show_activate_button: bool = True,
button_text: str = "Activate",
title: str = "Next Experiment",
**kwargs,
):
super().__init__(parent=parent, theme_update=True, **kwargs)
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 8, 12, 8)
self.experiment_info = {}
self._abstract_text = ""
# Add card frame with shadow and custom styling
self._card_frame = QFrame(parent=self)
layout = QVBoxLayout(self._card_frame)
self._card_frame.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
palette = get_theme_palette()
self._card_frame.setStyleSheet(f"""
QFrame {{
border: 1px solid {palette.mid().color().name()};
background: {palette.base().color().name()};
}}
""")
shadow = QGraphicsDropShadowEffect(self._card_frame)
shadow.setBlurRadius(18)
shadow.setOffset(0, 4)
shadow.setColor(palette.shadow().color())
self._card_frame.setGraphicsEffect(shadow)
self._group_box = QGroupBox(self._card_frame)
self._group_box.setStyleSheet(
"QGroupBox { border: none; }; QLabel { border: none; padding: 0px; }"
)
self._fill_group_box(
title=title, show_activate_button=show_activate_button, button_text=button_text
)
self.apply_theme("light")
def apply_theme(self, theme: str):
palette = get_theme_palette()
self._card_frame.setStyleSheet(f"""
QFrame {{
border: 1px solid {palette.mid().color().name()};
background: {palette.base().color().name()};
}}
""")
shadow = self._card_frame.graphicsEffect()
if isinstance(shadow, QGraphicsDropShadowEffect):
shadow.setColor(palette.shadow().color())
def _fill_group_box(
self, title: str, show_activate_button: bool, button_text: str = "Activate"
):
group_layout = QVBoxLayout(self._group_box)
group_layout.setContentsMargins(16, 16, 16, 16)
group_layout.setSpacing(12)
title_row = QHBoxLayout()
self._card_title = BorderLessLabel(title, self._group_box)
self._card_title.setStyleSheet("""
border: none;
font-size: 14px;
font-weight: 600;
""")
# Add title row and info button to QH layout, then add it to QV layout
title_row.addWidget(self._card_title)
title_row.addStretch(1)
group_layout.addLayout(title_row)
self._card_grid = QGridLayout()
self._card_grid.setHorizontalSpacing(12)
self._card_grid.setVerticalSpacing(8)
self._card_grid.setColumnStretch(1, 1)
self._card_pgroup = BorderLessLabel("-", self._group_box)
self._card_title_value = BorderLessLabel("-", self._group_box)
self._card_title_value.setWordWrap(True)
self._card_title_value.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
self._card_name = BorderLessLabel("-", self._group_box)
self._card_start = BorderLessLabel("-", self._group_box)
self._card_end = BorderLessLabel("-", self._group_box)
self._card_row_labels = []
def _row_label(text):
label = BorderLessLabel(text, self._group_box)
self._card_row_labels.append(label)
return label
self._card_grid.addWidget(_row_label("Name"), 0, 0)
self._card_grid.addWidget(self._card_name, 0, 1)
self._card_grid.addWidget(_row_label("Title"), 1, 0)
self._card_grid.addWidget(self._card_title_value, 1, 1)
self._card_grid.addWidget(_row_label("P-group"), 2, 0)
self._card_grid.addWidget(self._card_pgroup, 2, 1)
self._card_grid.addWidget(_row_label("Schedule (start)"), 3, 0)
self._card_grid.addWidget(self._card_start, 3, 1)
self._card_grid.addWidget(_row_label("Schedule (end)"), 4, 0)
self._card_grid.addWidget(self._card_end, 4, 1)
# Add to groupbox
group_layout.addLayout(self._card_grid)
# Add abstract field at the bottom of the card.
self._abstract_label = BorderLessLabel("", self._group_box)
self._abstract_label.setWordWrap(True)
self._abstract_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
group_layout.addWidget(self._abstract_label)
# Add activate button at the bottom
self._activate_button = QPushButton(button_text, self._group_box)
self._activate_button.clicked.connect(self._emit_next_experiment)
self._activate_button.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred
)
group_layout.addWidget(self._activate_button, alignment=Qt.AlignmentFlag.AlignHCenter)
self._activate_button.setVisible(show_activate_button)
self._activate_button.setEnabled(False)
self._card_frame.layout().setContentsMargins(12, 12, 12, 12)
self._card_frame.layout().addWidget(self._group_box)
card_row = QHBoxLayout()
card_row.addStretch(0)
card_row.addWidget(self._card_frame)
card_row.addStretch(0)
layout = self.layout()
layout.addStretch(0)
layout.addLayout(card_row)
layout.addStretch(0)
def _emit_next_experiment(self):
self.experiment_selected.emit(self.experiment_info)
def clear_experiment_info(self):
"""
Clear the experiment information displayed on the card and disable the activate button.
"""
self._card_pgroup.setText("-")
self._card_title_value.setText("-")
self._card_name.setText("-")
self._card_start.setText("-")
self._card_end.setText("-")
self._abstract_text = ""
self._abstract_label.setText("")
self.experiment_info = {}
self._activate_button.setEnabled(False)
def set_experiment_info(self, info: ExperimentInfoMessage | dict):
"""
Set the experiment information to display on the card.
Args:
info (ExperimentInfoMessage | dict): The experiment information to display. Can be either a
dictionary or an ExperimentInfoMessage instance.
"""
if isinstance(info, dict):
info = ExperimentInfoMessage(**info)
start, end = format_schedule(info.schedule)
self._card_pgroup.setText(info.pgroup or "-")
self._card_title_value.setText(info.title or "-")
self._card_name.setText(format_name(info))
self._card_start.setText(start or "-")
self._card_end.setText(end or "-")
self._abstract_text = (info.abstract or "").strip()
self._abstract_label.setText(self._abstract_text if self._abstract_text else "")
self.experiment_info = info.model_dump()
self._activate_button.setEnabled(True)
def set_title(self, title: str):
"""
Set the title displayed at the top of the card.
Args:
title (str): The title text to display.
"""
self._card_title.setText(title)
if __name__ == "__main__": # pragma: no cover
import sys
from bec_qthemes import apply_theme
from qtpy.QtWidgets import QApplication
exp_info = {
"_id": "p22622",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_xda_bs", "p22622"],
"realm_id": "TestBeamline",
"proposal": "12345967",
"title": "Test Experiment for Mat Card Widget",
"firstname": "John",
"lastname": "Doe",
"email": "john.doe@psi.ch",
"account": "doe_j",
"pi_firstname": "Jane",
"pi_lastname": "Smith",
"pi_email": "jane.smith@psi.ch",
"pi_account": "smith_j",
"eaccount": "e22622",
"pgroup": "p22622",
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
"proposal_submitted": "15/12/2024",
"proposal_expire": "31/12/2025",
"proposal_status": "Scheduled",
"delta_last_schedule": 30,
"mainproposal": "",
}
app = QApplication(sys.argv)
apply_theme("dark")
w = QWidget()
l = QVBoxLayout(w)
button = DarkModeButton()
widget = ExperimentMatCard()
widget.set_experiment_info(exp_info)
widget.set_title("Scheduled Experiment")
l.addWidget(button)
l.addWidget(widget)
w.resize(w.sizeHint())
w.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,395 @@
"""Experiment Selection View for BEC Atlas Admin Widget"""
from datetime import datetime
from typing import Any
from bec_lib.logger import bec_logger
from qtpy.QtCore import Qt, Signal
from qtpy.QtWidgets import (
QCheckBox,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QSizePolicy,
QTableWidget,
QTableWidgetItem,
QTabWidget,
QVBoxLayout,
QWidget,
)
from thefuzz import fuzz
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.fuzzy_search import is_match
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_mat_card import (
ExperimentMatCard,
)
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.utils import (
format_name,
format_schedule,
)
logger = bec_logger.logger
class ExperimentSelection(QWidget):
experiment_selected = Signal(dict)
def __init__(self, experiment_infos=None, parent=None):
super().__init__(parent=parent)
self._experiment_infos = experiment_infos or []
self._next_experiment = self._select_next_experiment(self._experiment_infos)
self._enable_fuzzy_search: bool = True
self._hidden_rows: set[int] = set()
self._headers: dict[str, str] = {
"pgroup": "pgroup",
"title": "Title",
"name": "Name",
"schedule_start": "Schedule (start)",
"schedule_end": "Schedule (end)",
}
self._table_infos: list[dict[str, Any]] = []
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
# main_layout.setSpacing(12)
main_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.setAutoFillBackground(True)
self._tabs = QTabWidget(self)
main_layout.addWidget(self._tabs, stretch=1)
self._card_tab = ExperimentMatCard(
parent=self, show_activate_button=True, button_text="Activate"
)
self._card_tab.experiment_selected.connect(self._emit_selected_experiment)
if self._next_experiment:
self._card_tab.set_experiment_info(self._next_experiment)
self._table_tab = QWidget(self)
self._tabs.addTab(self._card_tab, "Next Experiment")
self._tabs.addTab(self._table_tab, "Manual Selection")
self._build_table_tab()
self._tabs.currentChanged.connect(self._on_tab_changed)
button_layout = QHBoxLayout()
main_layout.addLayout(button_layout)
self._apply_table_filters()
self.restore_default_view()
def restore_default_view(self):
"""Reset the view to the default state, showing the next experiment card."""
self._tabs.setCurrentWidget(self._card_tab)
def set_experiment_infos(self, experiment_infos: list[dict]):
"""
Update the experiment information displayed in the view. It will in addition determine
the next experiment to be shown in the card view. If no next experiment can be determined,
the card view will be cleared.
Args:
experiment_infos (list[dict]): A list of experiment information dictionaries.
"""
self._experiment_infos = experiment_infos
self._next_experiment = self._select_next_experiment(self._experiment_infos)
if self._next_experiment:
self._card_tab.set_experiment_info(self._next_experiment)
else:
self._card_tab.clear_experiment_info()
self._apply_table_filters()
def _setup_search(self, layout: QVBoxLayout):
"""
Create components related to the search functionality
Args:
layout (QVBoxLayout): The layout to which the search components will be added.
"""
# Create search bar
search_layout = QHBoxLayout()
self.search_label = QLabel("Search:")
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("Filter experiments...")
self.search_input.setClearButtonEnabled(True)
self.search_input.textChanged.connect(self._apply_row_filter)
search_layout.addWidget(self.search_label)
search_layout.addWidget(self.search_input)
# Add exact match toggle
fuzzy_layout = QHBoxLayout()
self.fuzzy_label = QLabel("Exact Match:")
self.fuzzy_is_disabled = QCheckBox()
self.fuzzy_is_disabled.stateChanged.connect(self._state_change_fuzzy_search)
self.fuzzy_is_disabled.setToolTip(
"Enable approximate matching (OFF) and exact matching (ON)"
)
self.fuzzy_label.setToolTip("Enable approximate matching (OFF) and exact matching (ON)")
fuzzy_layout.addWidget(self.fuzzy_label)
fuzzy_layout.addWidget(self.fuzzy_is_disabled)
fuzzy_layout.addStretch()
# Add both search components to the layout
self.search_controls = QHBoxLayout()
self.search_controls.addLayout(search_layout)
self.search_controls.addSpacing(20) # Add some space between the search box and toggle
self.search_controls.addLayout(fuzzy_layout)
# Add filter section for proposals
filter_layout = QHBoxLayout()
filter_layout.setContentsMargins(12, 0, 12, 0)
filter_layout.setSpacing(12)
self._with_proposals = QCheckBox("Show experiments with proposals", self)
self._without_proposals = QCheckBox("Show experiments without proposals", self)
self._with_proposals.setChecked(True)
self._without_proposals.setChecked(True)
self._with_proposals.toggled.connect(self._apply_table_filters)
self._without_proposals.toggled.connect(self._apply_table_filters)
filter_layout.addWidget(self._with_proposals)
filter_layout.addWidget(self._without_proposals)
filter_layout.addStretch(1)
self.search_controls.addLayout(filter_layout)
# Insert the search controls layout at the top of the table
layout.addLayout(self.search_controls)
def _build_table_tab(self):
layout = QVBoxLayout(self._table_tab)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(8)
self._setup_search(layout)
# Add table
hor_layout = QHBoxLayout()
self._table = QTableWidget(self._table_tab)
self._table.setColumnCount(5)
self._table.setHorizontalHeaderLabels(list(self._headers.values()))
vh = self._table.verticalHeader()
vh.setVisible(False)
vh.setDefaultSectionSize(vh.minimumSectionSize())
self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self._table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
self._table.setWordWrap(True)
self._table.setStyleSheet("QTableWidget::item { padding: 4px; }")
header = self._table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
self._table.itemSelectionChanged.connect(self._update_selection_state)
hor_layout.addWidget(self._table, stretch=5)
hor_layout.addSpacing(12) # Add space between table and side card
# Add side card for experiment details
self._side_card = ExperimentMatCard(
parent=self, show_activate_button=True, button_text="Activate"
)
self._side_card.experiment_selected.connect(self._emit_selected_experiment)
hor_layout.addWidget(self._side_card, stretch=2) # Ratio 5:2 between table and card
layout.addLayout(hor_layout)
@SafeSlot()
@SafeSlot(int)
@SafeSlot(bool) # Overload for buttons
def _apply_table_filters(self, *args, **kwargs):
if self._tabs.currentWidget() is not self._table_tab:
return
show_with = self._with_proposals.isChecked()
show_without = self._without_proposals.isChecked()
self._table_infos = []
for info in self._experiment_infos:
has_proposal = bool(info.get("proposal"))
if has_proposal and not show_with:
continue
if not has_proposal and not show_without:
continue
self._table_infos.append(info)
self._populate_table()
self._update_selection_state()
def _populate_table(self):
# Clear table before populating, this keeps headers intact
self._table.setRowCount(0)
# Refill table
self._table.setRowCount(len(self._table_infos))
for row, info in enumerate(self._table_infos):
pgroup = info.get("pgroup", "")
title = info.get("title", "")
name = format_name(info)
start, end = format_schedule(info.get("schedule"))
self._table.setItem(row, 0, QTableWidgetItem(pgroup))
self._table.setItem(row, 1, QTableWidgetItem(title))
self._table.setItem(row, 2, QTableWidgetItem(name))
self._table.setItem(row, 3, QTableWidgetItem(start))
self._table.setItem(row, 4, QTableWidgetItem(end))
width = self._table.viewport().width()
self._table.resizeRowsToContents()
self._table.resize(width, self._table.height())
# self._table.resizeRowsToContents()
@SafeSlot()
def _update_selection_state(self):
if self._tabs.currentWidget() is not self._table_tab:
return
index = self._table.selectionModel().selectedRows()
if len(index) > 0:
index = index[0]
self._side_card.set_experiment_info(self._table_infos[index.row()])
def _emit_selected_experiment(self):
if self._tabs.currentWidget() is self._card_tab and self._next_experiment:
self.experiment_selected.emit(self._next_experiment)
return
selected = self._table.selectionModel().selectedRows()
if not selected:
return
row = selected[0].row()
if 0 <= row < len(self._table_infos):
self.experiment_selected.emit(self._table_infos[row])
logger.info(f"Emitting next experiment signal with info: {self._table_infos[row]}")
def _select_next_experiment(self, experiment_infos: list[dict]) -> dict | None:
candidates = []
for info in experiment_infos:
start, _ = format_schedule(info.get("schedule"), as_datetime=True)
if start is None:
continue
candidates.append((start, info))
if not candidates:
return experiment_infos[0] if experiment_infos else None
now = datetime.now()
future = [entry for entry in candidates if entry[0] >= now]
pool = future or candidates
return min(pool, key=lambda entry: abs(entry[0] - now))[1]
def _on_tab_changed(self, index):
if self._tabs.widget(index) is self._table_tab:
self._table.resizeRowsToContents()
if self._next_experiment:
self._side_card.set_experiment_info(self._next_experiment)
self._apply_table_filters()
def _get_column_data(self, row) -> dict[str, str]:
output = {}
for ii, header in enumerate(self._headers.values()):
item = self._table.item(row, ii)
if item is None:
output[header] = ""
continue
output[header] = item.text()
return output
@SafeSlot(str)
def _apply_row_filter(self, text_input: str):
"""Apply a filter to the table rows based on the filter text."""
if not text_input:
for row in self._hidden_rows:
self._table.setRowHidden(row, False)
self._hidden_rows.clear()
return
for row in range(self._table.rowCount()):
experiment_data = self._get_column_data(row)
if is_match(
text_input, experiment_data, list(self._headers.values()), self._enable_fuzzy_search
):
self._table.setRowHidden(row, False)
self._hidden_rows.discard(row)
else:
self._table.setRowHidden(row, True)
self._hidden_rows.add(row)
@SafeSlot(int)
def _state_change_fuzzy_search(self, enabled: int):
"""Handle state changes for the fuzzy search toggle."""
self._enable_fuzzy_search = not bool(enabled)
# Re-apply filter with updated fuzzy search setting
current_text = self.search_input.text()
self._apply_row_filter(current_text)
if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication
experiment_infos = [
{
"_id": "p22622",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_xda_bs", "p22622"],
"realm_id": "TestBeamline",
"proposal": "12345967",
"title": "Test Experiment for Mat Card Widget",
"firstname": "John",
"lastname": "Doe",
"email": "john.doe@psi.ch",
"account": "doe_j",
"pi_firstname": "Jane",
"pi_lastname": "Smith",
"pi_email": "jane.smith@psi.ch",
"pi_account": "smith_j",
"eaccount": "e22622",
"pgroup": "p22622",
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
"proposal_submitted": "15/12/2024",
"proposal_expire": "31/12/2025",
"proposal_status": "Scheduled",
"delta_last_schedule": 30,
"mainproposal": "",
},
{
"_id": "p22623",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_xda_bs", "p22623"],
"realm_id": "TestBeamline",
"proposal": "",
"title": "Experiment without Proposal",
"firstname": "Alice",
"lastname": "Johnson",
"email": "alice.johnson@psi.ch",
"account": "johnson_a",
"pi_firstname": "Bob",
"pi_lastname": "Brown",
"pi_email": "bob.brown@psi.ch",
"pi_account": "brown_b",
"eaccount": "e22623",
"pgroup": "p22623",
"abstract": "",
"schedule": [],
"proposal_submitted": "",
"proposal_expire": "",
"proposal_status": "",
"delta_last_schedule": None,
"mainproposal": "",
},
]
app = QApplication([])
from bec_qthemes import apply_theme
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
apply_theme("light")
w = QWidget()
l = QVBoxLayout(w)
dark_button = DarkModeButton()
l.addWidget(dark_button)
widget = ExperimentSelection(experiment_infos)
l.addWidget(widget)
w.resize(1280, 920)
w.show()
app.exec()

View File

@@ -0,0 +1,67 @@
"""Utility functions for experiment selection."""
from datetime import datetime
from typing import Literal
from bec_lib.messages import ExperimentInfoMessage
def format_name(info: dict | ExperimentInfoMessage) -> str:
"""Format the name from the experiment info."""
info = ExperimentInfoMessage.model_validate(info) if isinstance(info, dict) else info
firstname = info.firstname
lastname = info.lastname
return " ".join(part for part in [firstname, lastname] if part)
def format_schedule(
schedule: list[dict[Literal["start", "end"], str]] | None, as_datetime: bool = False
) -> tuple[str, str] | tuple[datetime | None, datetime | None]:
"""Format the schedule information to display start and end times."""
if not schedule:
return (None, None) if as_datetime else ("", "")
start, end = _pick_schedule_entry(schedule)
if as_datetime:
return start, end
return format_datetime(start), format_datetime(end)
def _pick_schedule_entry(
schedule: list[dict[Literal["start", "end"], str]],
) -> tuple[datetime | None, datetime | None]:
"""Pick the most relevant schedule entry based on the current time."""
now = datetime.now()
candidates = []
for item in schedule:
if not item:
continue
start_raw = item.get("start")
parsed = _parse_schedule_start(start_raw)
if parsed is None:
continue
candidates.append((parsed, item))
if not candidates:
return None, None
future = [entry for entry in candidates if entry[0] >= now]
pool = future or candidates
chosen_start, chosen_item = min(pool, key=lambda entry: abs(entry[0] - now))
end_raw = chosen_item.get("end")
return chosen_start, _parse_schedule_start(end_raw)
def _parse_schedule_start(value) -> datetime | None:
"""Parse a schedule start string into a datetime object."""
if not value:
return None
try:
return datetime.strptime(value, "%d/%m/%Y %H:%M:%S")
except ValueError:
return None
def format_datetime(value) -> str:
if not value:
return ""
return value.strftime("%Y-%m-%d %H:%M")

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "3.2.0"
version = "3.3.1"
description = "BEC Widgets"
requires-python = ">=3.11"
classifiers = [
@@ -32,6 +32,7 @@ dependencies = [
"copier~=9.7",
"typer~=0.15",
"markdown~=3.9",
"PyJWT~=2.9",
]

View File

@@ -75,6 +75,13 @@ def test_dock_manipulations_e2e(qtbot, connected_client_gui_obj):
w1 = dock_area.new("Waveform")
w2 = dock_area.new("Waveform")
qtbot.waitUntil(
lambda: all(
gui_id in gui._server_registry for gui_id in [w0._gui_id, w1._gui_id, w2._gui_id]
),
timeout=5000,
)
assert hasattr(gui.bec, "Waveform")
assert hasattr(gui.bec, "Waveform_0")
assert hasattr(gui.bec, "Waveform_1")
@@ -126,6 +133,7 @@ def test_rpc_gui_obj(connected_client_gui_obj, qtbot):
xw = gui.new("X")
xw.delete_all()
qtbot.waitUntil(lambda: len(gui.windows) == 2, timeout=3000)
assert xw.__class__.__name__ == "RPCReference"
assert gui._ipython_registry[xw._gui_id].__class__.__name__ == "BECDockArea"
assert len(gui.windows) == 2
@@ -145,14 +153,15 @@ 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'
qtbot.waitUntil(lambda: len(gui.windows) == 1, timeout=3000)
assert len(gui.windows) == 1
# communication should work, main dock area should have same id and be visible
yw = gui.new("Y")
qtbot.waitUntil(lambda: len(gui.windows) == 2, timeout=3000)
yw.delete_all()
qtbot.waitUntil(lambda: len(gui.windows) == 2, timeout=3000)
assert len(gui.windows) == 2
yw.remove()
qtbot.waitUntil(lambda: len(gui.windows) == 1, timeout=3000)
assert len(gui.windows) == 1
assert len(gui.windows) == 1 # only bec is left

View File

@@ -89,8 +89,8 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
# Skip private attributes
if object_name.startswith("_"):
continue
# Skip VSCode widget as Code server is not available in the Docker image
if object_name == "VSCodeEditor":
# Skip BECShell as ttyd is not installed
if object_name == "BECShell":
continue
# Skip WebConsole as ttyd is not installed

View File

@@ -128,167 +128,147 @@ def maybe_remove_dock_area(qtbot, gui: BECGuiClient, random_int_gen: random.Rand
random_int = random_int_gen.randint(0, 100)
if random_int >= 50:
# Needed, reference gets deleted in the gui
name = gui.dock_area.object_name
gui_id = gui.dock_area._gui_id
gui.dock_area.delete_all() # start fresh
gui.delete("dock_area")
wait_for_namespace_change(
qtbot, gui=gui, parent_widget=gui, object_name=name, widget_gui_id=gui_id, exists=False
)
qtbot.waitUntil(lambda: hasattr(gui, "dock_area") is False, timeout=5000)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_bec_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the BECProgressBar widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.BECProgressBar)
widget: client.BECProgressBar
# Check rpc calls
assert widget.label_template == "$value / $maximum - $percentage %"
widget.set_maximum(100)
widget.set_minimum(50)
widget.set_value(75)
assert widget._get_label() == "75 / 100 - 50 %"
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_bec_queue(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the BECQueue widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.BECQueue)
widget: client.BECQueue
# No rpc calls to test so far
# maybe we can add an rpc call to check the queue length
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_bec_status_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the BECStatusBox widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.BECStatusBox)
# Check rpc calls
assert widget.get_server_state() in ["RUNNING", "IDLE", "BUSY", "ERROR"]
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_dap_combo_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the DAPComboBox widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.DapComboBox)
widget: client.DAPComboBox
# Check rpc calls
widget.select_fit_model("PseudoVoigtModel")
widget.select_x_axis("samx")
widget.select_y_axis("bpm4i")
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_device_browser(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the DeviceBrowser widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.DeviceBrowser)
widget: client.DeviceBrowser
# No rpc calls yet to check
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the Image widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.Image)
widget: client.Image
scans = bec.scans
dev = bec.device_manager.devices
# Test rpc calls
img = widget.image(device=dev.eiger.name, signal="preview")
assert img.get_data() is None
# Run a scan and plot the image
s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
s.wait()
def _wait_for_scan_in_history():
# Get scan item from history
scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
return scan_item is not None
qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
# Check that last image is equivalent to data in Redis
last_img = bec.connector.get_last(MessageEndpoints.device_preview("eiger", "preview"))[
"data"
].data
assert np.allclose(img.get_data(), last_img)
# Now add a device with a preview signal
img = widget.image(device="eiger", signal="preview")
s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
s.wait()
qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# TODO re-enable when issue is resolved #560
# @pytest.mark.timeout(PYTEST_TIMEOUT)
# def test_widgets_e2e_bec_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed):
# """Test the BECProgressBar widget."""
# def test_widgets_e2e_log_panel(qtbot, connected_client_gui_obj, random_generator_from_seed):
# """Test the LogPanel widget."""
# gui = connected_client_gui_obj
# bec = gui._client
# # Create dock_area and widget
# widget = create_widget(qtbot, gui, gui.available_widgets.BECProgressBar)
# widget: client.BECProgressBar
# # Check rpc calls
# assert widget.label_template == "$value / $maximum - $percentage %"
# widget.set_maximum(100)
# widget.set_minimum(50)
# widget.set_value(75)
# assert widget._get_label() == "75 / 100 - 50 %"
# # Test removing the widget, or leaving it open for the next test
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# @pytest.mark.timeout(PYTEST_TIMEOUT)
# def test_widgets_e2e_bec_queue(qtbot, connected_client_gui_obj, random_generator_from_seed):
# """Test the BECQueue widget."""
# gui = connected_client_gui_obj
# bec = gui._client
# # Create dock_area and widget
# widget = create_widget(qtbot, gui, gui.available_widgets.BECQueue)
# widget: client.BECQueue
# # No rpc calls to test so far
# # maybe we can add an rpc call to check the queue length
# # Test removing the widget, or leaving it open for the next test
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# @pytest.mark.timeout(PYTEST_TIMEOUT)
# def test_widgets_e2e_bec_status_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
# """Test the BECStatusBox widget."""
# gui = connected_client_gui_obj
# bec = gui._client
# # Create dock_area and widget
# widget = create_widget(qtbot, gui, gui.available_widgets.BECStatusBox)
# # Check rpc calls
# assert widget.get_server_state() in ["RUNNING", "IDLE", "BUSY", "ERROR"]
# # Test removing the widget, or leaving it open for the next test
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# @pytest.mark.timeout(PYTEST_TIMEOUT)
# def test_widgets_e2e_dap_combo_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
# """Test the DAPComboBox widget."""
# gui = connected_client_gui_obj
# bec = gui._client
# # Create dock_area and widget
# widget = create_widget(qtbot, gui, gui.available_widgets.DapComboBox)
# widget: client.DAPComboBox
# # Check rpc calls
# widget.select_fit_model("PseudoVoigtModel")
# widget.select_x_axis("samx")
# widget.select_y_axis("bpm4i")
# # Test removing the widget, or leaving it open for the next test
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# @pytest.mark.timeout(PYTEST_TIMEOUT)
# def test_widgets_e2e_device_browser(qtbot, connected_client_gui_obj, random_generator_from_seed):
# """Test the DeviceBrowser widget."""
# gui = connected_client_gui_obj
# bec = gui._client
# # Create dock_area and widget
# widget = create_widget(qtbot, gui, gui.available_widgets.DeviceBrowser)
# widget: client.DeviceBrowser
# # No rpc calls yet to check
# # Test removing the widget, or leaving it open for the next test
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# @pytest.mark.timeout(PYTEST_TIMEOUT)
# def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_from_seed):
# """Test the Image widget."""
# gui = connected_client_gui_obj
# bec = gui._client
# # Create dock_area and widget
# widget = create_widget(qtbot, gui, gui.available_widgets.Image)
# widget: client.Image
# scans = bec.scans
# dev = bec.device_manager.devices
# # Test rpc calls
# img = widget.image(device=dev.eiger.name, signal="preview")
# assert img.get_data() is None
# # Run a scan and plot the image
# s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
# s.wait()
# def _wait_for_scan_in_history():
# # Get scan item from history
# scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
# return scan_item is not None
# qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
# # Check that last image is equivalent to data in Redis
# last_img = bec.connector.get_last(MessageEndpoints.device_preview("eiger", "preview"))[
# "data"
# ].data
# assert np.allclose(img.get_data(), last_img)
# # Now add a device with a preview signal
# img = widget.image(device="eiger", signal="preview")
# s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
# s.wait()
# qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
# # Test removing the widget, or leaving it open for the next test
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# # TODO re-enable when issue is resolved #560
# # @pytest.mark.timeout(PYTEST_TIMEOUT)
# # def test_widgets_e2e_log_panel(qtbot, connected_client_gui_obj, random_generator_from_seed):
# # """Test the LogPanel widget."""
# # gui = connected_client_gui_obj
# # bec = gui._client
# # # Create dock_area and widget
# # widget = create_widget(qtbot, gui, gui.available_widgets.LogPanel)
# # widget: client.LogPanel
# # # No rpc calls to check so far
# # # Test removing the widget, or leaving it open for the next test
# # maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# @pytest.mark.timeout(PYTEST_TIMEOUT)
# def test_widgets_e2e_minesweeper(qtbot, connected_client_gui_obj, random_generator_from_seed):
# """Test the MineSweeper widget."""
# gui = connected_client_gui_obj
# bec = gui._client
# # Create dock_area and widget
# widget = create_widget(qtbot, gui, gui.available_widgets.Minesweeper)
# widget: client.MineSweeper
# widget = create_widget(qtbot, gui, gui.available_widgets.LogPanel)
# widget: client.LogPanel
# # No rpc calls to check so far
@@ -296,160 +276,175 @@ def maybe_remove_dock_area(qtbot, gui: BECGuiClient, random_int_gen: random.Rand
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# @pytest.mark.timeout(PYTEST_TIMEOUT)
# def test_widgets_e2e_motor_map(qtbot, connected_client_gui_obj, random_generator_from_seed):
# """Test the MotorMap widget."""
# gui = connected_client_gui_obj
# bec = gui._client
# # Create dock_area and widget
# widget = create_widget(qtbot, gui, gui.available_widgets.MotorMap)
# widget: client.MotorMap
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_minesweeper(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the MineSweeper widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.Minesweeper)
widget: client.MineSweeper
# # Test RPC calls
# dev = bec.device_manager.devices
# scans = bec.scans
# # Set motor map to names
# widget.map(dev.samx, dev.samy)
# # Move motor samx to pos
# pos = dev.samx.limits[1] - 1 # -1 from higher limit
# scans.mv(dev.samx, pos, relative=False).wait()
# # Check that data is up to date
# assert np.isclose(widget.get_data()["x"][-1], pos, dev.samx.precision)
# # Move motor samy to pos
# pos = dev.samy.limits[0] + 1 # +1 from lower limit
# scans.mv(dev.samy, pos, relative=False).wait()
# # Check that data is up to date
# assert np.isclose(widget.get_data()["y"][-1], pos, dev.samy.precision)
# No rpc calls to check so far
# # Test removing the widget, or leaving it open for the next test
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# @pytest.mark.timeout(PYTEST_TIMEOUT)
# def test_widgets_e2e_multi_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
# """Test MultiWaveform widget."""
# gui = connected_client_gui_obj
# bec = gui._client
# # Create dock_area and widget
# widget = create_widget(qtbot, gui, gui.available_widgets.MultiWaveform)
# widget: client.MultiWaveform
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_motor_map(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the MotorMap widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.MotorMap)
widget: client.MotorMap
# # Test RPC calls
# dev = bec.device_manager.devices
# scans = bec.scans
# # test plotting
# cm = "cividis"
# widget.plot(dev.waveform, color_palette=cm)
# assert widget.monitor == dev.waveform.name
# assert widget.color_palette == cm
# Test RPC calls
dev = bec.device_manager.devices
scans = bec.scans
# Set motor map to names
widget.map(dev.samx, dev.samy)
# Move motor samx to pos
pos = dev.samx.limits[1] - 1 # -1 from higher limit
scans.mv(dev.samx, pos, relative=False).wait()
# Check that data is up to date
assert np.isclose(widget.get_data()["x"][-1], pos, dev.samx.precision)
# Move motor samy to pos
pos = dev.samy.limits[0] + 1 # +1 from lower limit
scans.mv(dev.samy, pos, relative=False).wait()
# Check that data is up to date
assert np.isclose(widget.get_data()["y"][-1], pos, dev.samy.precision)
# # Scan with BEC
# s = scans.line_scan(dev.samx, -3, 3, steps=5, exp_time=0.01, relative=False)
# s.wait()
# def _wait_for_scan_in_history():
# # Get scan item from history
# scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
# return scan_item is not None
# qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
# # Wait for data in history (should be plotted?)
# # TODO how can we check that the data was plotted, implement get_data()
# # Test removing the widget, or leaving it open for the next test
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# @pytest.mark.timeout(PYTEST_TIMEOUT)
# def test_widgets_e2e_positioner_indicator(
# qtbot, connected_client_gui_obj, random_generator_from_seed
# ):
# """Test the PositionIndicator widget."""
# gui = connected_client_gui_obj
# bec = gui._client
# # Create dock_area and widget
# widget = create_widget(qtbot, gui, gui.available_widgets.PositionIndicator)
# widget: client.PositionIndicator
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_multi_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test MultiWaveform widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.MultiWaveform)
widget: client.MultiWaveform
# # TODO check what these rpc calls are supposed to do! Issue created #461
# widget.set_value(5)
# Test RPC calls
dev = bec.device_manager.devices
scans = bec.scans
# test plotting
cm = "cividis"
widget.plot(dev.waveform, color_palette=cm)
assert widget.monitor == dev.waveform.name
assert widget.color_palette == cm
# # Test removing the widget, or leaving it open for the next test
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# Scan with BEC
s = scans.line_scan(dev.samx, -3, 3, steps=5, exp_time=0.01, relative=False)
s.wait()
def _wait_for_scan_in_history():
# Get scan item from history
scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
return scan_item is not None
qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
# Wait for data in history (should be plotted?)
# TODO how can we check that the data was plotted, implement get_data()
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# @pytest.mark.timeout(PYTEST_TIMEOUT)
# def test_widgets_e2e_positioner_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
# """Test the PositionerBox widget."""
# gui = connected_client_gui_obj
# bec = gui._client
# # Create dock_area and widget
# widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox)
# widget: client.PositionerBox
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_positioner_indicator(
qtbot, connected_client_gui_obj, random_generator_from_seed
):
"""Test the PositionIndicator widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.PositionIndicator)
widget: client.PositionIndicator
# # Test rpc calls
# dev = bec.device_manager.devices
# scans = bec.scans
# # No rpc calls to check so far
# widget.set_positioner(dev.samx)
# widget.set_positioner(dev.samy.name)
# TODO check what these rpc calls are supposed to do! Issue created #461
widget.set_value(5)
# scans.mv(dev.samy, -3, relative=False).wait()
# # Test removing the widget, or leaving it open for the next test
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# @pytest.mark.timeout(PYTEST_TIMEOUT)
# def test_widgets_e2e_positioner_box_2d(qtbot, connected_client_gui_obj, random_generator_from_seed):
# """Test the PositionerBox2D widget."""
# gui = connected_client_gui_obj
# bec = gui._client
# # Create dock_area and widget
# widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox2D)
# widget: client.PositionerBox2D
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_positioner_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the PositionerBox widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox)
widget: client.PositionerBox
# # Test rpc calls
# dev = bec.device_manager.devices
# scans = bec.scans
# # No rpc calls to check so far
# widget.set_positioner_hor(dev.samx)
# widget.set_positioner_ver(dev.samy)
# Test rpc calls
dev = bec.device_manager.devices
scans = bec.scans
# No rpc calls to check so far
widget.set_positioner(dev.samx)
widget.set_positioner(dev.samy.name)
# # Try moving the motors
# scans.mv(dev.samx, 3, relative=False).wait()
# scans.mv(dev.samy, -3, relative=False).wait()
scans.mv(dev.samy, -3, relative=False).wait()
# # Test removing the widget, or leaving it open for the next test
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# @pytest.mark.timeout(PYTEST_TIMEOUT)
# def test_widgets_e2e_positioner_control_line(
# qtbot, connected_client_gui_obj, random_generator_from_seed
# ):
# """Test the positioner control line widget"""
# gui = connected_client_gui_obj
# bec = gui._client
# # Create dock_area and widget
# widget = create_widget(qtbot, gui, gui.available_widgets.PositionerControlLine)
# widget: client.PositionerControlLine
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_positioner_box_2d(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the PositionerBox2D widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox2D)
widget: client.PositionerBox2D
# # Test rpc calls
# dev = bec.device_manager.devices
# scans = bec.scans
# # Set positioner
# widget.set_positioner(dev.samx)
# scans.mv(dev.samx, 3, relative=False).wait()
# widget.set_positioner(dev.samy.name)
# scans.mv(dev.samy, -3, relative=False).wait()
# Test rpc calls
dev = bec.device_manager.devices
scans = bec.scans
# No rpc calls to check so far
widget.set_positioner_hor(dev.samx)
widget.set_positioner_ver(dev.samy)
# # Test removing the widget, or leaving it open for the next test
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# Try moving the motors
scans.mv(dev.samx, 3, relative=False).wait()
scans.mv(dev.samy, -3, relative=False).wait()
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
@pytest.mark.repeat(20)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_positioner_control_line(
qtbot, connected_client_gui_obj, random_generator_from_seed
):
"""Test the positioner control line widget"""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.PositionerControlLine)
widget: client.PositionerControlLine
# Test rpc calls
dev = bec.device_manager.devices
scans = bec.scans
# Set positioner
widget.set_positioner(dev.samx)
scans.mv(dev.samx, 3, relative=False).wait()
widget.set_positioner(dev.samy.name)
scans.mv(dev.samy, -3, relative=False).wait()
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# TODO passes locally, fails on CI for some reason... -> issue #1003
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_ring_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the RingProgressBar widget"""
@@ -478,86 +473,86 @@ def test_widgets_e2e_ring_progress_bar(qtbot, connected_client_gui_obj, random_g
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# @pytest.mark.timeout(PYTEST_TIMEOUT)
# def test_widgets_e2e_scan_control(qtbot, connected_client_gui_obj, random_generator_from_seed):
# """Test the ScanControl widget"""
# gui = connected_client_gui_obj
# bec = gui._client
# # Create dock_area and widget
# widget = create_widget(qtbot, gui, gui.available_widgets.ScanControl)
# widget: client.ScanControl
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_scan_control(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the ScanControl widget"""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.ScanControl)
widget: client.ScanControl
# # No rpc calls to check so far
# No rpc calls to check so far
# # Test removing the widget, or leaving it open for the next test
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# @pytest.mark.timeout(PYTEST_TIMEOUT)
# def test_widgets_e2e_scatter_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
# """Test the ScatterWaveform widget"""
# gui = connected_client_gui_obj
# bec = gui._client
# # Create dock_area and widget
# widget = create_widget(qtbot, gui, gui.available_widgets.ScatterWaveform)
# widget: client.ScatterWaveform
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_scatter_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the ScatterWaveform widget"""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.ScatterWaveform)
widget: client.ScatterWaveform
# # Test rpc calls
# dev = bec.device_manager.devices
# scans = bec.scans
# widget.plot(dev.samx, dev.samy, dev.bpm4i)
# scans.grid_scan(dev.samx, -5, 5, 5, dev.samy, -5, 5, 5, exp_time=0.01, relative=False).wait()
# Test rpc calls
dev = bec.device_manager.devices
scans = bec.scans
widget.plot(dev.samx, dev.samy, dev.bpm4i)
scans.grid_scan(dev.samx, -5, 5, 5, dev.samy, -5, 5, 5, exp_time=0.01, relative=False).wait()
# # Test removing the widget, or leaving it open for the next test
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# @pytest.mark.timeout(PYTEST_TIMEOUT)
# def test_widgets_e2e_text_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
# """Test the TextBox widget"""
# gui = connected_client_gui_obj
# bec = gui._client
# # Create dock_area and widget
# widget = create_widget(qtbot, gui, gui.available_widgets.TextBox)
# widget: client.TextBox
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_text_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the TextBox widget"""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.TextBox)
widget: client.TextBox
# # RPC calls
# widget.set_plain_text("Hello World")
# widget.set_html_text("<b> Hello World HTML </b>")
# RPC calls
widget.set_plain_text("Hello World")
widget.set_html_text("<b> Hello World HTML </b>")
# # Test removing the widget, or leaving it open for the next test
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# @pytest.mark.timeout(PYTEST_TIMEOUT)
# def test_widgets_e2e_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
# """Test the Waveform widget"""
# gui = connected_client_gui_obj
# bec = gui._client
# # Create dock_area and widget
# widget = create_widget(qtbot, gui, gui.available_widgets.Waveform)
# widget: client.Waveform
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_waveform(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the Waveform widget"""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area and widget
widget = create_widget(qtbot, gui, gui.available_widgets.Waveform)
widget: client.Waveform
# # Test rpc calls
# dev = bec.device_manager.devices
# scans = bec.scans
# widget.plot(dev.bpm4i)
# s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
# s.wait()
# Test rpc calls
dev = bec.device_manager.devices
scans = bec.scans
widget.plot(dev.bpm4i)
s = scans.line_scan(dev.samx, -3, 3, steps=50, exp_time=0.01, relative=False)
s.wait()
# def _wait_for_scan_in_history():
# # Get scan item from history
# scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
# return scan_item is not None
def _wait_for_scan_in_history():
# Get scan item from history
scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
return scan_item is not None
# qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
qtbot.waitUntil(_wait_for_scan_in_history, timeout=7000)
# scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
# samx_data = scan_item.devices.samx.samx.read()["value"]
# bpm4i_data = scan_item.devices.bpm4i.bpm4i.read()["value"]
# curve = widget.curves[0]
# assert np.allclose(curve.get_data()[0], samx_data)
# assert np.allclose(curve.get_data()[1], bpm4i_data)
scan_item = bec.history.get_by_scan_id(s.scan.scan_id)
samx_data = scan_item.devices.samx.samx.read()["value"]
bpm4i_data = scan_item.devices.bpm4i.bpm4i.read()["value"]
curve = widget.curves[0]
assert np.allclose(curve.get_data()[0], samx_data)
assert np.allclose(curve.get_data()[1], bpm4i_data)
# # Test removing the widget, or leaving it open for the next test
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
# Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)

View File

@@ -1,47 +1,18 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
from math import inf
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, PropertyMock, patch
import fakeredis
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
def fake_redis_server(host, port, **kwargs):
redis = fakeredis.FakeRedis()
return redis
from bec_widgets.tests.utils import FakePositioner, Positioner
@pytest.fixture(scope="function")
def mocked_client(bec_dispatcher):
connector = RedisConnector("localhost:1", redis_cls=fake_redis_server)
# Create a MagicMock object
client = MagicMock() # TODO change to real BECClient
# Shutdown the original client
bec_dispatcher.client.shutdown()
# Mock the connector attribute
bec_dispatcher.client = client
# Mock the device_manager.devices attribute
client.connector = connector
client.device_manager = DMMock()
client.device_manager.add_devices(DEVICES)
def mock_mv(*args, relative=False):
# Extracting motor and value pairs
for i in range(0, len(args), 2):
motor = args[i]
value = args[i + 1]
motor.move(value, relative=relative)
return MagicMock(wait=MagicMock())
client.scans = MagicMock(mv=mock_mv)
# Ensure isinstance check for Positioner passes
original_isinstance = isinstance
@@ -52,8 +23,8 @@ def mocked_client(bec_dispatcher):
return original_isinstance(obj, class_info)
with patch("builtins.isinstance", new=isinstance_mock):
yield client
connector.shutdown() # TODO change to real BECClient
yield bec_dispatcher.client
bec_dispatcher.client.connector.shutdown()
##################################################
@@ -190,17 +161,16 @@ def mocked_client_with_dap(mocked_client, dap_plugin_message):
name="LmfitService1D", status=1, info={}
),
}
client = mocked_client
client.service_status = dap_services
client.connector.set(
type(mocked_client).service_status = PropertyMock(return_value=dap_services)
mocked_client.connector.set(
topic=MessageEndpoints.dap_available_plugins("dap"), msg=dap_plugin_message
)
# Patch the client's DAP attribute so that the available models include "GaussianModel"
patched_models = {"GaussianModel": {}, "LorentzModel": {}, "SineModel": {}}
client.dap._available_dap_plugins = patched_models
mocked_client.dap._available_dap_plugins = patched_models
yield client
yield mocked_client
class DummyData:
@@ -233,7 +203,6 @@ def create_dummy_scan_item():
"readout_priority": {"monitored": ["bpm4i"], "async": ["async_device"]},
}
}
dummy_scan.status_message = MagicMock()
dummy_scan.status_message.info = {
"readout_priority": {"monitored": ["bpm4i"], "async": ["async_device"]},
"scan_report_devices": ["samx"],

View File

@@ -1,19 +1,28 @@
import json
import time
from unittest import mock
from unittest.mock import patch
import fakeredis
import h5py
import numpy as np
import pytest
from bec_lib import messages
from bec_lib import messages, service_config
from bec_lib.bec_service import messages
from bec_lib.client import BECClient
from bec_lib.messages import _StoredDataInfo
from bec_qthemes import apply_theme
from bec_qthemes._theme import Theme
from ophyd._pyepics_shim import _dispatcher
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
from qtpy.QtCore import QEvent, QEventLoop
from qtpy.QtWidgets import QApplication, QMessageBox
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.tests.utils import DEVICES, DMMock
from bec_widgets.utils import bec_dispatcher as bec_dispatcher_module
from bec_widgets.utils import error_popups
from bec_widgets.utils.bec_dispatcher import QtRedisConnector
# Patch to set default RAISE_ERROR_DEFAULT to True for tests
# This means that by default, error popups will raise exceptions during tests
@@ -38,15 +47,20 @@ def process_all_deferred_deletes(qapp):
def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unused-argument
qapp = QApplication.instance()
process_all_deferred_deletes(qapp)
apply_theme("light")
qapp.processEvents()
if (
not hasattr(qapp, "theme")
or not isinstance(qapp.theme, Theme)
or qapp.theme.theme != "light"
):
apply_theme("light")
qapp.processEvents()
yield
# if the test failed, we don't want to check for open widgets as
# it simply pollutes the output
# stop pyepics dispatcher for leaking tests
from ophyd._pyepics_shim import _dispatcher
_dispatcher.stop()
if request.node.stash._storage.get("failed"):
@@ -71,9 +85,37 @@ def rpc_register():
RPCRegister.reset_singleton()
_REDIS_CONN: QtRedisConnector | None = None
def global_mock_qt_redis_connector(*_, **__):
global _REDIS_CONN
if _REDIS_CONN is None:
_REDIS_CONN = QtRedisConnector(bootstrap="localhost:1", redis_cls=fakeredis.FakeRedis)
return _REDIS_CONN
def mock_client(*_, **__):
with (
patch("bec_lib.client.DeviceManagerBase", DMMock),
patch("bec_lib.client.DAPPlugins"),
patch("bec_lib.client.Scans"),
patch("bec_lib.client.ScanManager"),
patch("bec_lib.bec_service.BECAccess"),
):
client = BECClient(
config=service_config.ServiceConfig(config={"redis": {"host": "localhost", "port": 1}}),
connector_cls=global_mock_qt_redis_connector,
)
client.start()
client.device_manager.add_devices(DEVICES)
return client
@pytest.fixture(autouse=True)
def bec_dispatcher(threads_check): # pylint: disable=unused-argument
bec_dispatcher = bec_dispatcher_module.BECDispatcher()
with mock.patch.object(bec_dispatcher_module, "BECClient", mock_client):
bec_dispatcher = bec_dispatcher_module.BECDispatcher()
yield bec_dispatcher
bec_dispatcher.disconnect_all()
# clean BEC client

View File

@@ -1,5 +1,7 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
from unittest.mock import MagicMock
import pytest
from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton
@@ -10,6 +12,7 @@ from .client_mocks import mocked_client
@pytest.fixture
def abort_button(qtbot, mocked_client):
widget = AbortButton(client=mocked_client)
widget.queue = MagicMock()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget

View File

@@ -0,0 +1,720 @@
import datetime
import time
from types import SimpleNamespace
from unittest import mock
import jwt
import pytest
from bec_lib.messages import (
DeploymentInfoMessage,
ExperimentInfoMessage,
MessagingConfig,
MessagingServiceScopeConfig,
SessionInfoMessage,
)
from qtpy.QtCore import QByteArray, QUrl
from qtpy.QtNetwork import QNetworkRequest
from bec_widgets.utils.fuzzy_search import is_match
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_admin_view import BECAtlasAdminView
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_http_service import (
AtlasEndpoints,
AuthenticatedUserInfo,
BECAtlasHTTPError,
BECAtlasHTTPService,
HTTPResponse,
)
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_mat_card import (
ExperimentMatCard,
)
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_selection import (
ExperimentSelection,
)
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.utils import (
format_datetime,
format_name,
format_schedule,
)
class _FakeQByteArray:
def __init__(self, payload: bytes):
self._payload = payload
def data(self) -> bytes:
return self._payload
class _FakeReply:
def __init__(
self,
*,
request_url: str,
status: int = 200,
payload: bytes = b"{}",
headers: list[tuple[bytes, bytes]] | None = None,
):
self._request_url = request_url
self._status = status
self._payload = payload
self._headers = (
headers
if headers is not None
else [(QByteArray(b"content-type"), QByteArray(b"application/json"))]
)
self.deleted = False
def attribute(self, attr):
assert attr == QNetworkRequest.Attribute.HttpStatusCodeAttribute
return self._status
def readAll(self):
return _FakeQByteArray(self._payload)
def url(self):
return QUrl(self._request_url)
def rawHeaderPairs(self):
return self._headers
def deleteLater(self):
self.deleted = True
class TestBECAtlasHTTPService:
@pytest.fixture
def http_service(self, qtbot):
"""Fixture to create a BECAtlasHTTPService instance."""
service = BECAtlasHTTPService(base_url="http://localhost:8000")
qtbot.addWidget(service)
qtbot.waitExposed(service)
return service
def test_initialization(self, http_service):
"""Test that the BECAtlasHTTPService initializes correctly."""
assert http_service._base_url == "http://localhost:8000"
assert http_service._auth_timer._timer.isActive() == False
assert http_service._headers == {"accept": "application/json"}
def test_get_request_uses_network_manager_get(self, http_service):
"""Test that _get_request uses the network manager's get method with correct parameters."""
with mock.patch.object(http_service.network_manager, "get") as mock_get:
http_service._get_request(
endpoint=AtlasEndpoints.REALMS_EXPERIMENTS.value,
query_parameters={"realm_id": "realm-1"},
)
mock_get.assert_called_once()
request = mock_get.call_args.args[0]
assert request.url().toString() == (
"http://localhost:8000/realms/experiments?realm_id=realm-1"
)
assert request.rawHeader("accept") == QByteArray(b"application/json")
def test_post_request_uses_network_manager_post(self, http_service):
"""Test that _post_request uses the network manager's post method with correct parameters."""
with mock.patch.object(http_service.network_manager, "post") as mock_post:
http_service._post_request(
endpoint=AtlasEndpoints.LOGIN.value, payload={"username": "alice", "password": "pw"}
)
mock_post.assert_called_once()
request, payload = mock_post.call_args.args
assert request.url().toString() == "http://localhost:8000/user/login"
assert request.rawHeader("accept") == QByteArray(b"application/json")
assert payload == b'{"username": "alice", "password": "pw"}'
def test_public_api(self, http_service):
"""Test BEC ATLAS public API methods from the http service."""
with mock.patch.object(http_service, "_get_request") as mock_get:
# User info
http_service.get_user_info()
mock_get.assert_called_once_with(endpoint=AtlasEndpoints.USER_INFO.value)
mock_get.reset_mock()
# Deployment info
http_service.get_deployment_info("dep-1")
mock_get.assert_called_once_with(
endpoint=AtlasEndpoints.DEPLOYMENT_INFO.value,
query_parameters={"deployment_id": "dep-1"},
)
mock_get.reset_mock()
# Realms experiments
http_service.get_experiments_for_realm("realm-1")
mock_get.assert_called_once_with(
endpoint=AtlasEndpoints.REALMS_EXPERIMENTS.value,
query_parameters={"realm_id": "realm-1"},
)
with mock.patch.object(http_service, "_post_request") as mock_post:
# Logout
http_service.logout()
mock_post.assert_called_once_with(endpoint=AtlasEndpoints.LOGOUT.value)
mock_post.reset_mock()
# Login
http_service.login("alice", "pw")
mock_post.assert_called_once_with(
endpoint=AtlasEndpoints.LOGIN.value, payload={"username": "alice", "password": "pw"}
)
mock_post.reset_mock()
# Set experiment
http_service.set_experiment("exp-1", "dep-1")
mock_post.assert_called_once_with(
endpoint=AtlasEndpoints.SET_EXPERIMENT.value,
query_parameters={"experiment_id": "exp-1", "deployment_id": "dep-1"},
)
def test_handle_response_login(self, http_service, qtbot):
"""Test that handling a login response correctly decodes the token and starts the auth timer."""
exp = time.time() + 300
token = jwt.encode({"email": "alice@example.org", "exp": exp}, "secret", algorithm="HS256")
payload = ("{" f'"access_token": "{token}"' "}").encode()
reply = _FakeReply(
request_url="http://localhost:8000/user/login", status=200, payload=payload
)
with mock.patch.object(http_service, "get_user_info") as mock_get_user_info:
with qtbot.waitSignal(http_service.authentication_expires, timeout=1000) as blocker:
http_service._handle_response(reply)
assert blocker.args[0] == pytest.approx(exp)
assert http_service.auth_user_info is not None
assert http_service.auth_user_info.email == "alice@example.org"
assert http_service.auth_user_info.groups == set()
http_service.get_user_info.assert_called_once()
def test_handle_response_logout(self, http_service, qtbot):
"""Test handle response for logout."""
http_service._auth_user_info = AuthenticatedUserInfo(
email="alice@example.org", exp=time.time() + 60, groups={"staff"}, deployment_id="dep-1"
)
reply = _FakeReply(
request_url="http://localhost:8000/user/logout", status=200, payload=b"{}"
)
with qtbot.waitSignal(http_service.authenticated, timeout=1000) as blocker:
http_service._handle_response(reply)
assert blocker.args[0] == {}
assert http_service.auth_user_info is None
def test_handle_response_user_info(self, http_service):
"""Test handle response for user info endpoint correctly updates auth user info."""
http_service._auth_user_info = AuthenticatedUserInfo(
email="alice@example.org", exp=time.time() + 60, groups=set(), deployment_id="dep-1"
)
http_service._current_deployment_info = SimpleNamespace(deployment_id="dep-1")
reply = _FakeReply(
request_url="http://localhost:8000/user/me",
status=200,
payload=b'{"email": "alice@example.org", "groups": ["operators", "staff"]}',
)
with mock.patch.object(http_service, "get_deployment_info") as mock_get_deployment_info:
http_service._handle_response(reply)
assert http_service.auth_user_info is not None
assert http_service.auth_user_info.groups == {"operators", "staff"}
mock_get_deployment_info.assert_called_once_with(deployment_id="dep-1")
def test_handle_response_deployment_info(self, http_service, qtbot):
"""Test handling deployment info response"""
# Groups match: should emit authenticated signal with user info
http_service._auth_user_info = AuthenticatedUserInfo(
email="alice@example.org",
exp=time.time() + 60,
groups={"operators"},
deployment_id="dep-1",
)
reply = _FakeReply(
request_url="http://localhost:8000/deployments/id?deployment_id=dep-1",
status=200,
payload=b'{"owner_groups": ["operators"], "name": "Beamline Deployment"}',
)
with qtbot.waitSignal(http_service.authenticated, timeout=1000) as blocker:
http_service._handle_response(reply)
assert blocker.args[0]["email"] == "alice@example.org"
assert set(blocker.args[0]["groups"]) == {"operators"}
assert blocker.args[0]["deployment_id"] == "dep-1"
# Groups do not match: should show warning and logout
http_service._auth_user_info = AuthenticatedUserInfo(
email="alice@example.org",
exp=time.time() + 60,
groups={"operators"},
deployment_id="dep-1",
)
reply = _FakeReply(
request_url="http://localhost:8000/deployments/id?deployment_id=dep-1",
status=200,
payload=b'{"owner_groups": ["no-operators"], "name": "Beamline Deployment"}',
)
with (
mock.patch.object(http_service, "_show_warning") as mock_show_warning,
mock.patch.object(http_service, "logout") as mock_logout,
):
http_service._handle_response(reply)
mock_show_warning.assert_called_once()
mock_logout.assert_called_once()
def test_handle_response_emits_http_response(self, http_service, qtbot):
"""Test that _handle_response emits the http_response signal with correct parameters for a generic response."""
reply = _FakeReply(
request_url="http://localhost:8000/realms/experiments?realm_id=realm-1",
status=200,
payload=b'{"items": []}',
)
with qtbot.waitSignal(http_service.http_response, timeout=1000) as blocker:
http_service._handle_response(reply)
assert blocker.args[0]["request_url"] == (
"http://localhost:8000/realms/experiments?realm_id=realm-1"
)
assert blocker.args[0]["status"] == 200
assert blocker.args[0]["headers"] == {"content-type": "application/json"}
assert blocker.args[0]["data"] == {"items": []}
def test_handle_response_raises_for_invalid_status(self, http_service):
reply = _FakeReply(
request_url="http://localhost:8000/user/me",
status=401,
payload=b'{"detail": "Unauthorized"}',
)
with pytest.raises(BECAtlasHTTPError):
http_service._handle_response(reply, _override_slot_params={"raise_error": True})
@pytest.fixture
def experiment_info_message() -> ExperimentInfoMessage:
data = {
"_id": "p22622",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_xda_bs", "p22622"],
"realm_id": "TestBeamline",
"proposal": "12345967",
"title": "Test Experiment for Mat Card Widget",
"firstname": "John",
"lastname": "Doe",
"email": "john.doe@psi.ch",
"account": "doe_j",
"pi_firstname": "Jane",
"pi_lastname": "Smith",
"pi_email": "jane.smith@psi.ch",
"pi_account": "smith_j",
"eaccount": "e22622",
"pgroup": "p22622",
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
"proposal_submitted": "15/12/2024",
"proposal_expire": "31/12/2025",
"proposal_status": "Scheduled",
"delta_last_schedule": 30,
"mainproposal": "",
}
return ExperimentInfoMessage.model_validate(data)
@pytest.fixture
def experiment_info_list(experiment_info_message: ExperimentInfoMessage) -> list[dict]:
"""Fixture to provide a list of experiment info dictionaries."""
another_experiment_info = {
"_id": "p22623",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_xda_bs", "p22623"],
"realm_id": "TestBeamline",
"proposal": "",
"title": "Experiment without Proposal",
"firstname": "Alice",
"lastname": "Johnson",
"email": "alice.johnson@psi.ch",
"account": "johnson_a",
"pi_firstname": "Bob",
"pi_lastname": "Brown",
"pi_email": "bob.brown@psi.ch",
"pi_account": "brown_b",
"eaccount": "e22623",
"pgroup": "p22623",
"abstract": "",
"schedule": [],
"proposal_submitted": "",
"proposal_expire": "",
"proposal_status": "",
"delta_last_schedule": None,
"mainproposal": "",
}
return [
experiment_info_message.model_dump(),
ExperimentInfoMessage.model_validate(another_experiment_info).model_dump(),
]
class TestBECAtlasExperimentSelection:
def test_format_name(self, experiment_info_message: ExperimentInfoMessage):
"""Test utils format name"""
assert format_name(experiment_info_message) == "John Doe"
def test_format_schedule(self, experiment_info_message: ExperimentInfoMessage):
"""Test utils format schedule"""
assert format_schedule(experiment_info_message.schedule) == (
"2025-01-01 08:00",
"2025-01-03 18:00",
)
assert format_schedule(experiment_info_message.schedule, as_datetime=True) == (
datetime.datetime.strptime(
experiment_info_message.schedule[0]["start"], "%d/%m/%Y %H:%M:%S"
),
datetime.datetime.strptime(
experiment_info_message.schedule[0]["end"], "%d/%m/%Y %H:%M:%S"
),
)
assert format_schedule([]) == ("", "")
def test_format_datetime(self):
"""Test utils format datetime"""
dt = datetime.datetime(2025, 1, 1, 8, 0)
assert format_datetime(dt) == "2025-01-01 08:00"
assert format_datetime(None) == ""
@pytest.fixture
def mat_card(self, qtbot):
"""Fixture to create an ExperimentMatCard instance."""
card = ExperimentMatCard()
qtbot.addWidget(card)
qtbot.waitExposed(card)
return card
def test_set_experiment_info(
self, mat_card: ExperimentMatCard, experiment_info_message: ExperimentInfoMessage, qtbot
):
"""Test that set_experiment_info correctly updates the card's display based on the provided experiment info, whether it's a dictionary or an ExperimentInfoMessage instance."""
# Test with ExperimentInfoMessage instance
mat_card.set_experiment_info(experiment_info_message)
assert mat_card._card_pgroup.text() == "p22622"
assert mat_card._card_title.text() == "Next Experiment"
assert mat_card._abstract_label.text() == experiment_info_message.abstract.strip()
assert mat_card.experiment_info == experiment_info_message.model_dump()
assert mat_card._activate_button.isEnabled()
assert mat_card._activate_button.text() == "Activate"
# Test with dictionary input
mat_card.set_experiment_info(experiment_info_message.model_dump())
mat_card.set_title("Experiment Details")
assert mat_card._card_pgroup.text() == "p22622"
assert mat_card._card_title.text() == "Experiment Details"
assert mat_card._abstract_label.text() == experiment_info_message.abstract.strip()
assert mat_card.experiment_info == experiment_info_message.model_dump()
assert mat_card._activate_button.isEnabled()
assert mat_card._activate_button.text() == "Activate"
with qtbot.waitSignal(mat_card.experiment_selected, timeout=1000) as blocker:
mat_card._activate_button.click()
assert blocker.args[0] == experiment_info_message.model_dump()
def test_is_match(self):
"""Test is_match utility function for search functionality."""
data = {"name": "Test Experiment", "description": "This is a test."}
relevant_keys = ["name", "description"]
# Test exact match
assert is_match("Test Experiment", data, relevant_keys, enable_fuzzy=False)
assert not is_match("Nonexistent", data, relevant_keys, enable_fuzzy=False)
# Test fuzzy match
assert not is_match("Nonexistent", data, relevant_keys, enable_fuzzy=True)
assert is_match("Test Experimnt", data, relevant_keys, enable_fuzzy=True)
# Typo should still match with fuzzy enabled
assert is_match("Test Experiement", data, relevant_keys, enable_fuzzy=True)
@pytest.fixture
def experiment_selection(self, qtbot):
"""Fixture to create an ExperimentSelection instance with sample experiment info."""
selection = ExperimentSelection()
qtbot.addWidget(selection)
qtbot.waitExposed(selection)
return selection
def test_set_experiments(
self, experiment_selection: ExperimentSelection, experiment_info_list: list[dict]
):
"""Test that set_experiment_infos correctly populates the experiment selection with provided experiment info."""
with mock.patch.object(
experiment_selection._card_tab, "set_experiment_info"
) as mock_set_experiment_info:
experiment_selection.set_experiment_infos(experiment_info_list)
assert len(experiment_selection._experiment_infos) == 2
# Next experiment should be the first one as the second one has no schedule
mock_set_experiment_info.assert_called_once_with(experiment_info_list[0])
# Should be on card tab
assert experiment_selection._tabs.currentWidget() == experiment_selection._card_tab
def test_filter_functionality(
self, experiment_selection: ExperimentSelection, experiment_info_list: list[dict], qtbot
):
"""Test that the search functionality correctly filters experiments based on the search query."""
wid = experiment_selection
wid.set_experiment_infos(experiment_info_list)
# First move to the table tab
wid._tabs.setCurrentWidget(wid._table_tab)
assert wid._side_card.experiment_info == wid._next_experiment
# Initially, both experiments should be in the table
assert wid._table.rowCount() == 2
with qtbot.waitSignal(wid._with_proposals.toggled, timeout=1000):
wid._with_proposals.setChecked(False) # Should hide one experiment
assert wid._table.rowCount() == 1
with qtbot.waitSignal(wid._without_proposals.toggled, timeout=1000):
wid._without_proposals.setChecked(False) # Should hide the other experiment
assert wid._table.rowCount() == 0
with qtbot.waitSignals(
[wid._without_proposals.toggled, wid._with_proposals.toggled], timeout=1000
):
wid._without_proposals.setChecked(True)
wid._with_proposals.setChecked(True) # Should show both experiments again
assert wid._table.rowCount() == 2
# Click on first experiment and check if side card updates
with qtbot.waitSignal(wid._table.itemSelectionChanged, timeout=1000):
wid._table.selectRow(0) # Select the first experiment
pgroup = wid._table.item(0, 0).text() # pgroup
exp = [exp for exp in experiment_info_list if exp["pgroup"] == pgroup][0]
assert wid._side_card.experiment_info == exp
# Click on second experiment and check if side card updates
with qtbot.waitSignal(wid._table.itemSelectionChanged, timeout=1000):
wid._table.selectRow(1) # Select the second experiment
pgroup = wid._table.item(1, 0).text() # pgroup
exp = [exp for exp in experiment_info_list if exp["pgroup"] == pgroup][0]
assert wid._side_card.experiment_info == exp
wid.search_input.setText("Experiment without Proposal")
with mock.patch.object(wid, "_apply_row_filter") as mock_apply_row_filter:
with qtbot.waitSignal(wid.fuzzy_is_disabled.stateChanged, timeout=1000):
wid.fuzzy_is_disabled.setChecked(True) # Disable fuzzy search
mock_apply_row_filter.assert_called_once_with("Experiment without Proposal")
assert wid._enable_fuzzy_search is False
def test_emit_selected_experiment(
self, experiment_selection: ExperimentSelection, experiment_info_list: list[dict], qtbot
):
"""Test that clicking the activate button on the side card emits the experiment_selected signal with the correct experiment info."""
wid = experiment_selection
wid.set_experiment_infos(experiment_info_list)
wid._tabs.setCurrentWidget(wid._table_tab)
with qtbot.waitSignal(wid._table.itemSelectionChanged, timeout=1000):
wid._table.selectRow(1) # Select the second experiment
pgroup = wid._table.item(1, 0).text() # pgroup
exp = [exp for exp in experiment_info_list if exp["pgroup"] == pgroup][0]
with qtbot.waitSignal(wid.experiment_selected, timeout=1000) as blocker:
wid._side_card._activate_button.click()
assert blocker.args == [exp]
class TestBECAtlasAdminView:
@pytest.fixture
def admin_view(self, qtbot):
"""Fixture to create a BECAtlasAdminView instance."""
with mock.patch(
"bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_admin_view.BECAtlasAdminView._connect_dispatcher"
):
view = BECAtlasAdminView()
qtbot.addWidget(view)
qtbot.waitExposed(view)
return view
def test_init_and_login(self, admin_view: BECAtlasAdminView, qtbot):
"""Test that the BECAtlasAdminView initializes correctly."""
# Check that the atlas URL is set correctly
assert admin_view._atlas_url == "https://bec-atlas-dev.psi.ch/api/v1"
# Test that clicking the login button emits the credentials_entered signal with the correct username and password
with mock.patch.object(admin_view.atlas_http_service, "login") as mock_login:
with qtbot.waitSignal(admin_view.overview_widget.login_requested, timeout=1000):
admin_view.overview_widget._login.username.setText("alice")
admin_view.overview_widget._login.password.setText("password")
admin_view.overview_widget._login._emit_credentials()
mock_login.assert_called_once_with(username="alice", password="password")
mock_login.reset_mock()
admin_view._authenticated = True
with mock.patch.object(admin_view, "logout") as mock_logout:
with qtbot.waitSignal(admin_view.overview_widget.login_requested, timeout=1000):
admin_view.overview_widget._login.password.setText("password")
admin_view.overview_widget._login._emit_credentials()
mock_logout.assert_called_once()
mock_login.assert_called_once_with(username="alice", password="password")
def test_on_experiment_selected(
self, admin_view: BECAtlasAdminView, deployment_info: DeploymentInfoMessage, qtbot
):
"""Test that selecting an experiment in the overview widget correctly calls the HTTP service to set the experiment and updates the current experiment view."""
# First we need to simulate that we are authenticated and have deployment info
admin_view._update_deployment_info(deployment_info.model_dump(), {})
with mock.patch.object(
admin_view.atlas_http_service, "set_experiment"
) as mock_set_experiment:
with qtbot.waitSignal(
admin_view.experiment_selection.experiment_selected, timeout=1000
):
admin_view.experiment_selection.experiment_selected.emit(
deployment_info.active_session.experiment.model_dump()
)
mock_set_experiment.assert_called_once_with(
deployment_info.active_session.experiment.pgroup, deployment_info.deployment_id
)
@pytest.fixture
def deployment_info(
self, experiment_info_message: ExperimentInfoMessage
) -> DeploymentInfoMessage:
"""Fixture to provide a DeploymentInfoMessage instance."""
return DeploymentInfoMessage(
deployment_id="dep-1",
name="Test Deployment",
messaging_config=MessagingConfig(
signal=MessagingServiceScopeConfig(enabled=False),
teams=MessagingServiceScopeConfig(enabled=False),
scilog=MessagingServiceScopeConfig(enabled=False),
),
active_session=SessionInfoMessage(
experiment=experiment_info_message, name="Test Session"
),
)
def test_on_authenticated(
self, admin_view: BECAtlasAdminView, deployment_info: DeploymentInfoMessage, qtbot
):
"""Test that the on_authenticated method correctly updates the UI based on authentication state."""
# Simulate successful authentication
auth_info = AuthenticatedUserInfo(
email="alice@example.com",
exp=time.time() + 300,
groups={"operators"},
deployment_id="dep-1",
)
# First check that deployment info updates all fields correctly
admin_view._update_deployment_info(deployment_info.model_dump(), {})
assert admin_view.atlas_http_service._current_deployment_info == deployment_info
assert (
admin_view._atlas_info_widget._bl_info_label.text()
== f"{deployment_info.active_session.experiment.realm_id} @ {deployment_info.name}"
)
assert admin_view._atlas_info_widget._atlas_url_label.text() == admin_view._atlas_url
# Now run on_authenticated, this enables all toolbar buttons
# and calls fetch experiments. It also switches the overview widget
# to the current experiment view.
# Default should be on the overview widget
assert admin_view.stacked_layout.currentWidget() == admin_view.overview_widget
with mock.patch.object(
admin_view, "_fetch_available_experiments"
) as mock_fetch_experiments:
with qtbot.waitSignal(admin_view.authenticated, timeout=1000) as blocker:
admin_view._on_authenticated(auth_info.model_dump())
# Fetch experiments should be called
mock_fetch_experiments.assert_called_once()
assert blocker.args[0] is True
assert (
admin_view._atlas_info_widget._atlas_url_label.text()
== f"{admin_view._atlas_info_widget._atlas_url_text} | {auth_info.email}"
)
assert (
admin_view.toolbar.components.get_action("messaging_services").action.isEnabled()
is False
)
# Logout timer is running
logout_action = admin_view.toolbar.components.get_action("logout")
assert logout_action.action.isEnabled() is True
assert logout_action._tick_timer.isActive() is True
# Current Experiment widget should be visible in the overview
assert (
admin_view.overview_widget.stacked_layout.currentWidget()
== admin_view.overview_widget._experiment_overview_widget
)
# Click toolbar to switch to experiment selection
exp_select = admin_view.toolbar.components.get_action("experiment_selection")
assert exp_select.action.isEnabled() is True
with qtbot.waitSignal(exp_select.action.triggered, timeout=1000):
exp_select.action.trigger()
assert admin_view.stacked_layout.currentWidget() == admin_view.experiment_selection
# Now we simulate that the authentication expires
# This deactivates buttons, resets the overview widget
# and emits authenticated signal with False
with qtbot.waitSignal(admin_view.authenticated, timeout=1000) as blocker:
admin_view._on_authenticated({}) # Simulate not authenticated anymore
assert blocker.args[0] is False
assert logout_action._tick_timer.isActive() is False
assert admin_view._atlas_info_widget._atlas_url_label.text() == admin_view._atlas_url
assert (
admin_view.overview_widget.stacked_layout.currentWidget()
== admin_view.overview_widget._login_widget
)
# View should switch back to overview
assert admin_view.stacked_layout.currentWidget() == admin_view.overview_widget
def test_fetch_experiments(
self, admin_view: BECAtlasAdminView, deployment_info: DeploymentInfoMessage, qtbot
):
"""Test that _fetch_available_experiments correctly calls the HTTP service and updates the experiment selection widget."""
admin_view._update_deployment_info(deployment_info.model_dump(), {})
with mock.patch.object(
admin_view.atlas_http_service, "get_experiments_for_realm"
) as mock_get_experiments:
admin_view._fetch_available_experiments()
mock_get_experiments.assert_called_once_with(
deployment_info.active_session.experiment.realm_id
)
def test_on_http_response_received(
self, experiment_info_list: list[dict], admin_view: BECAtlasAdminView, qtbot
):
"""Test that _on_http_response_received correctly handles HTTP responses and updates the UI accordingly."""
realms = HTTPResponse(
request_url=f"{admin_view._atlas_url}/{AtlasEndpoints.REALMS_EXPERIMENTS}/experiments?realm_id=TestBeamline",
status=200,
headers={"content-type": "application/json"},
data=experiment_info_list,
)
with mock.patch.object(
admin_view.experiment_selection, "set_experiment_infos"
) as mock_set_experiment_infos:
admin_view._on_http_response_received(realms.model_dump())
mock_set_experiment_infos.assert_called_once_with(experiment_info_list)
set_experiment = HTTPResponse(
request_url=f"{admin_view._atlas_url}/{AtlasEndpoints.SET_EXPERIMENT}",
status=200,
headers={"content-type": "application/json"},
data={},
)
with mock.patch.object(admin_view, "_on_overview_selected") as mock_on_overview_selected:
admin_view._on_http_response_received(set_experiment.model_dump())
mock_on_overview_selected.assert_called_once()

View File

@@ -4,10 +4,45 @@ import time
from unittest import mock
import pytest
from bec_lib import service_config
from bec_lib.messages import ScanMessage
from bec_lib.serialization import MsgpackSerialization
from bec_widgets.utils.bec_dispatcher import QtRedisConnector, QtThreadSafeCallback
from bec_widgets.utils.bec_dispatcher import BECDispatcher, QtRedisConnector, QtThreadSafeCallback
def test_init_handles_client_and_config_arg():
# Client passed
self_mock = mock.MagicMock(_initialized=False)
with mock.patch.object(BECDispatcher, "start_cli_server"):
BECDispatcher.__init__(self_mock, client=mock.MagicMock(name="test_client"))
assert "test_client" in repr(self_mock.client)
# No client, service config object
self_mock.reset_mock()
self_mock._initialized = False
with (
mock.patch.object(BECDispatcher, "start_cli_server"),
mock.patch("bec_widgets.utils.bec_dispatcher.BECClient") as client_cls,
):
config = service_config.ServiceConfig()
BECDispatcher.__init__(self_mock, client=None, config=config)
client_cls.assert_called_with(
config=config, connector_cls=QtRedisConnector, name="BECWidgets"
)
# No client, service config string
self_mock.reset_mock()
self_mock._initialized = False
with (
mock.patch.object(BECDispatcher, "start_cli_server"),
mock.patch("bec_widgets.utils.bec_dispatcher.BECClient"),
mock.patch("bec_widgets.utils.bec_dispatcher.ServiceConfig") as svc_cfg,
mock.patch("bec_widgets.utils.bec_dispatcher.isinstance", return_value=False),
):
config = service_config.ServiceConfig()
BECDispatcher.__init__(self_mock, client=None, config="test_str")
svc_cfg.assert_called_with("test_str")
@pytest.fixture

View File

@@ -17,6 +17,8 @@ def dap_combobox(qtbot, mocked_client):
def test_dap_combobox_init(dap_combobox):
"""Test DapComboBox init."""
assert dap_combobox.fit_model_combobox is dap_combobox
assert dap_combobox.isEditable() is True
assert dap_combobox.fit_model_combobox.currentText() == "GaussianModel"
assert dap_combobox.available_models == ["GaussianModel", "LorentzModel", "SineModel"]
assert dap_combobox._validate_dap_model("GaussianModel") is True
@@ -71,3 +73,25 @@ def test_dap_combobox_currentTextchanged(dap_combobox):
dap_combobox.fit_model_updated.connect(my_callback)
dap_combobox.fit_model_combobox.setCurrentText("SineModel")
assert container[0] == "SineModel"
def test_dap_combobox_init_without_available_models(qtbot, mocked_client):
mocked_client.dap._available_dap_plugins = {}
widget = create_widget(qtbot, DapComboBox, client=mocked_client)
assert widget.available_models == []
assert widget.fit_model_combobox.count() == 0
assert widget.fit_model_combobox.currentText() == ""
def test_dap_combobox_invalid_manual_entry_highlighted(dap_combobox):
dap_combobox.setCurrentText("not-a-model")
assert dap_combobox.is_valid_input is False
assert "red" in dap_combobox.styleSheet()
dap_combobox.setCurrentText("GaussianModel")
assert dap_combobox.is_valid_input is True
assert "transparent" in dap_combobox.styleSheet()

View File

@@ -160,7 +160,7 @@ def test_signal_display(mocked_client, qtbot):
def test_signal_display_no_device(mocked_client, qtbot):
device_mock = mock.MagicMock()
mocked_client.client.device_manager.devices = {"test_device_1": device_mock}
mocked_client.device_manager.devices = {"test_device_1": device_mock}
signal_display = SignalDisplay(client=mocked_client, device="test_device_2")
qtbot.addWidget(signal_display)
assert (

View File

@@ -146,12 +146,12 @@ def test_signal_lineedit(device_signal_line_edit):
def test_device_signal_input_base_cleanup(qtbot, mocked_client):
with mock.patch.object(mocked_client.callbacks, "remove"):
widget = DeviceInputWidget(client=mocked_client)
widget.close()
widget.deleteLater()
widget = DeviceInputWidget(client=mocked_client)
widget.close()
widget.deleteLater()
mocked_client.callbacks.remove.assert_called_once_with(widget._device_update_register)
mocked_client.callbacks.remove.assert_called_once_with(widget._device_update_register)
def test_signal_combobox_get_signal_name_with_item_data(qtbot, device_signal_combobox):

View File

@@ -197,6 +197,163 @@ def test_image_setup_preview_signal_2d(qtbot, mocked_client):
np.testing.assert_array_equal(view.main_image.image, test_data)
def test_switching_device_disconnects_previous_preview_endpoint(qtbot, mocked_client, monkeypatch):
view = create_widget(qtbot, Image, client=mocked_client)
_set_signal_config(mocked_client, "eiger", "img", signal_class="PreviewSignal", ndim=2)
_set_signal_config(mocked_client, "waveform1d", "img", signal_class="PreviewSignal", ndim=2)
connected = []
disconnected = []
monkeypatch.setattr(
view.bec_dispatcher,
"connect_slot",
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
)
monkeypatch.setattr(
view.bec_dispatcher,
"disconnect_slot",
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
)
view.image(device="eiger", signal="img")
connected.clear()
disconnected.clear()
view.device = "waveform1d"
assert MessageEndpoints.device_preview("eiger", "img") in disconnected
assert MessageEndpoints.device_preview("waveform1d", "img") in connected
def test_switching_device_disconnects_previous_async_endpoint(qtbot, mocked_client, monkeypatch):
"""
Verify that switching device while async_update=True disconnects device_async_signal
endpoints for both scan_id and old_scan_id on the old device before reconnecting to
the new device.
"""
view = create_widget(qtbot, Image, client=mocked_client)
_set_signal_config(
mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=2, obj_name="async_obj"
)
_set_signal_config(
mocked_client, "waveform1d", "img", signal_class="AsyncSignal", ndim=2, obj_name="async_obj"
)
connected = []
disconnected = []
monkeypatch.setattr(
view.bec_dispatcher,
"connect_slot",
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
)
monkeypatch.setattr(
view.bec_dispatcher,
"disconnect_slot",
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
)
view.image(device="eiger", signal="img")
assert view.async_update is True
assert view.subscriptions["main"].async_signal_name == "async_obj"
view.scan_id = "scan_current"
view.old_scan_id = "scan_previous"
connected.clear()
disconnected.clear()
view.device = "waveform1d"
# Both scan_id and old_scan_id endpoints for the old device must be disconnected
assert (
MessageEndpoints.device_async_signal("scan_current", "eiger", "async_obj") in disconnected
)
assert (
MessageEndpoints.device_async_signal("scan_previous", "eiger", "async_obj") in disconnected
)
# The new device's async endpoint for the current scan must be connected
assert (
MessageEndpoints.device_async_signal("scan_current", "waveform1d", "async_obj") in connected
)
def test_switching_signal_disconnects_previous_preview_endpoint(qtbot, mocked_client, monkeypatch):
view = create_widget(qtbot, Image, client=mocked_client)
_set_signal_config(mocked_client, "eiger", "img_a", signal_class="PreviewSignal", ndim=2)
_set_signal_config(mocked_client, "eiger", "img_b", signal_class="PreviewSignal", ndim=2)
connected = []
disconnected = []
monkeypatch.setattr(
view.bec_dispatcher,
"connect_slot",
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
)
monkeypatch.setattr(
view.bec_dispatcher,
"disconnect_slot",
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
)
view.image(device="eiger", signal="img_a")
connected.clear()
disconnected.clear()
view.signal = "img_b"
assert MessageEndpoints.device_preview("eiger", "img_a") in disconnected
assert MessageEndpoints.device_preview("eiger", "img_b") in connected
def test_switching_signal_disconnects_previous_async_endpoint(qtbot, mocked_client, monkeypatch):
"""
When the current monitor is an async signal, switching to a different signal must
disconnect the previous async endpoint (based on scan_id/async_signal_name) before
reconnecting with the new signal's async endpoint.
"""
view = create_widget(qtbot, Image, client=mocked_client)
_set_signal_config(
mocked_client, "eiger", "img_a", signal_class="AsyncSignal", ndim=2, obj_name="async_obj_a"
)
_set_signal_config(
mocked_client, "eiger", "img_b", signal_class="AsyncSignal", ndim=2, obj_name="async_obj_b"
)
connected = []
disconnected = []
monkeypatch.setattr(
view.bec_dispatcher,
"connect_slot",
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
)
monkeypatch.setattr(
view.bec_dispatcher,
"disconnect_slot",
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
)
# Connect to img_a as an async signal; scan_id is None so no actual subscription is made
view.image(device="eiger", signal="img_a")
assert view.async_update is True
assert view.subscriptions["main"].async_signal_name == "async_obj_a"
assert view.subscriptions["main"].source == "device_monitor_2d"
# Simulate an active scan so that the async endpoint is real
view.scan_id = "scan_123"
connected.clear()
disconnected.clear()
# Switch to a different signal
view.signal = "img_b"
# The previous async endpoint for img_a must have been disconnected
expected_disconnect = MessageEndpoints.device_async_signal("scan_123", "eiger", "async_obj_a")
assert expected_disconnect in disconnected
# The new async endpoint for img_b must have been connected
expected_connect = MessageEndpoints.device_async_signal("scan_123", "eiger", "async_obj_b")
assert expected_connect in connected
def test_preview_signals_skip_0d_entries(qtbot, mocked_client, monkeypatch):
"""
Preview/async combobox should omit 0D signals.

View File

@@ -1,4 +1,4 @@
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import numpy as np
@@ -53,14 +53,16 @@ def test_scatter_waveform_update_with_scan_history(qtbot, mocked_client, monkeyp
swf = create_widget(qtbot, ScatterWaveform, client=mocked_client)
dummy_scan = create_dummy_scan_item()
mocked_client.history = MagicMock()
# .get_by_scan_id() typically returns historical data, but we abuse it here
# to return mock live data
mocked_client.history.get_by_scan_id.return_value = dummy_scan
mocked_client.history.__getitem__.return_value = dummy_scan
swf.plot("samx", "samy", "bpm4i", label="test_curve")
swf.update_with_scan_history(scan_id="dummy")
qtbot.wait(500)
assert swf.scan_item == dummy_scan
qtbot.waitUntil(lambda: swf.scan_item == dummy_scan, timeout=500)
qtbot.wait(200)
x_data, y_data = swf.main_curve.getData()
np.testing.assert_array_equal(x_data, [10, 20, 30])

View File

@@ -8,7 +8,7 @@ from .client_mocks import mocked_client
@pytest.fixture
def plot_widget_with_arrow_item(qtbot, mocked_client):
widget = Waveform(client=mocked_client())
widget = Waveform(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
@@ -17,7 +17,7 @@ def plot_widget_with_arrow_item(qtbot, mocked_client):
@pytest.fixture
def plot_widget_with_tick_item(qtbot, mocked_client):
widget = Waveform(client=mocked_client())
widget = Waveform(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)

View File

@@ -189,10 +189,10 @@ def test_bec_shell_startup_contains_gui_id(bec_shell_widget):
assert bec_shell._is_bec_shell
assert bec_shell._unique_id == "bec_shell"
assert bec_shell.startup_cmd == "bec --nogui"
with mock.patch.object(bec_shell.bec_dispatcher, "cli_server", None):
assert bec_shell.startup_cmd == "bec --nogui"
with mock.patch.object(bec_shell.bec_dispatcher, "cli_server") as mock_cli_server:
mock_cli_server.gui_id = "test_gui_id"
with mock.patch.object(bec_shell.bec_dispatcher.cli_server, "gui_id", "test_gui_id"):
assert bec_shell.startup_cmd == "bec --gui-id test_gui_id"