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

Compare commits

...

34 Commits

Author SHA1 Message Date
8f05239fcf docs: fix license reference 2025-05-12 15:09:11 +02:00
fcac2835dc docs: add badge for code style, version and license 2025-05-12 15:00:33 +02:00
07b4a98c26 ci: add ci status badge 2025-05-12 14:51:13 +02:00
8345dacb26 ci: add github workflows 2025-05-12 13:44:37 +02:00
semantic-release
531d9c621d 2.3.0
Automatically generated by python-semantic-release
2025-05-09 12:36:13 +00:00
dc151cdfe3 feat(bec_connector): ability to change object name during runtime 2025-05-09 14:27:44 +02:00
semantic-release
e0dfd56a0d 2.2.0
Automatically generated by python-semantic-release
2025-05-09 09:41:37 +00:00
1fb680abb4 feat(launcher): add support for launching plugin widget 2025-05-08 17:30:16 +02:00
b9e56c96cb refactor(launch_window): widget tile added 2025-05-08 13:50:01 +02:00
semantic-release
dd956f18fe 2.1.3
Automatically generated by python-semantic-release
2025-05-07 14:31:53 +00:00
cf59d31113 fix(bec-dispatcher): fix reference to boundmethods to avoid duplicated subscriptions 2025-05-07 11:08:06 +02:00
semantic-release
bc0e277332 2.1.2
Automatically generated by python-semantic-release
2025-05-06 11:09:41 +00:00
75a2780fe0 tests(user-interaction-e2e): add module scoped e2e tests with user interaction; closes #508 2025-05-06 11:28:12 +02:00
a6c479e42e build: remove flush-redis from ci job 2025-05-06 11:28:12 +02:00
64a4824054 fix(waveform): Ignore callbacks for on_async_readback from QtSender objects that are already destroyed; closes #497 2025-05-06 11:28:12 +02:00
1619446ec9 refactor(bec-status-box): add get_server_state user_access method to BECStatusBox 2025-05-06 11:28:12 +02:00
37f002427a refactor(bec-progressbar): add private method for bec_progressbar, udate client file 2025-05-06 11:28:12 +02:00
semantic-release
50cb70dcc6 2.1.1
Automatically generated by python-semantic-release
2025-05-06 08:37:48 +00:00
55f7efc4f5 fix: import add operator in client 2025-05-06 10:20:47 +02:00
be72c9f270 refactor: supply bec designer filename to function 2025-05-06 10:20:47 +02:00
c8cedc0124 wip 2025-05-06 08:54:36 +02:00
semantic-release
3fdbe4031e 2.1.0
Automatically generated by python-semantic-release
2025-05-05 11:10:40 +00:00
c16b9dce9c test(Dock): add validation for new dock creation with invalid name 2025-05-05 13:01:21 +02:00
9387275851 feat(SafeSlot): slot parameters can be overridden with kwarg; add option to raise 2025-05-05 13:01:21 +02:00
94463afdba fix: ensure rpc object do not collide with protected names 2025-05-05 13:01:21 +02:00
02563b10f3 refactor(colormap_widget): widget is rounded 2025-05-02 16:01:51 +02:00
fff4af2489 ci: install dev dependencies for formatter 2025-05-02 14:12:18 +02:00
452124b528 chore(formatter): upgrade to black v25 2025-05-02 14:12:18 +02:00
semantic-release
9c84e158ba 2.0.3
Automatically generated by python-semantic-release
2025-05-02 11:52:31 +00:00
58a0bc7974 fix(image_item): wrong user access name for rotation 2025-05-02 12:23:16 +02:00
770dbd4b63 fix(generate_cli): apply isort config 2025-05-02 12:23:16 +02:00
d22035f897 ci: add job to test that the generated client is up to date 2025-05-02 11:24:38 +02:00
semantic-release
fe21b39b7f 2.0.2
Automatically generated by python-semantic-release
2025-05-01 11:00:33 +00:00
1b78840fd8 fix(plot_base): no content margin for plot_widget window 2025-05-01 12:02:47 +02:00
48 changed files with 1615 additions and 159 deletions

15
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
name: Full CI
on: [push]
jobs:
formatter:
uses: ./.github/workflows/formatter.yml
unit-test:
uses: ./.github/workflows/pytest.yml
unit-test-matrix:
uses: ./.github/workflows/pytest-matrix.yml
end2end-test:
uses: ./.github/workflows/end2end-conda.yml

48
.github/workflows/end2end-conda.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: Run Pytest with Coverage
on: [workflow_call]
jobs:
pytest:
runs-on: ubuntu-latest
defaults:
run:
shell: bash -el {0}
env:
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
steps:
- uses: actions/checkout@v4
- name: Set up Conda
uses: conda-incubator/setup-miniconda@v3
with:
auto-update-conda: true
auto-activate-base: true
python-version: '3.11'
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
- name: Conda install and run pytest
run: |
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
cd ./bec
conda create -q -n test-environment python=3.11
source ./bin/install_bec_dev.sh -t
cd ../
pip install -e ./ophyd_devices
pip install -e .[dev,pyside6]
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end

61
.github/workflows/formatter.yml vendored Normal file
View File

@@ -0,0 +1,61 @@
name: Formatter and Pylint jobs
on: [workflow_call]
jobs:
Formatter:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Run black and isort
run: |
pip install black isort
pip install -e .[dev]
black --check --diff --color .
isort --check --diff ./
Pylint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pylint pylint-exit anybadge
- name: Run Pylint
run: |
mkdir -p ./pylint
set +e
pylint ./${{ github.event.repository.name }} --output-format=text > ./pylint/pylint.log
pylint-exit $?
set -e
- name: Extract Pylint Score
id: score
run: |
SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint.log)
echo "score=$SCORE" >> $GITHUB_OUTPUT
- name: Create Badge
run: |
anybadge --label=Pylint --file=./pylint/pylint.svg --value="${{ steps.score.outputs.score }}" 2=red 4=orange 8=yellow 10=green
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: pylint-artifacts
path: |
# ./pylint/pylint.log # not sure why this isn't working
./pylint/pylint.svg

48
.github/workflows/pytest-matrix.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: Run Pytest with different Python versions
on: [workflow_call]
jobs:
pytest-matrix:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
env:
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
- name: Clone and install dependencies
run: |
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
pip install -e ./ophyd_devices
pip install -e ./bec/bec_lib[dev]
pip install -e ./bec/bec_ipython_client
pip install -e .[dev,pyside6]
- name: Run Pytest
run: |
pip install pytest pytest-random-order
pytest -v --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests

47
.github/workflows/pytest.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: Run Pytest with Coverage
on: [workflow_call]
jobs:
pytest:
runs-on: ubuntu-latest
env:
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
- name: Clone and install dependencies
run: |
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
pip install -e ./ophyd_devices
pip install -e ./bec/bec_lib[dev]
pip install -e ./bec/bec_ipython_client
pip install -e .[dev,pyside6]
- name: Run Pytest with Coverage
run: |
pip install coverage pytest pytest-random-order
coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --maxfail=2 --random-order --full-trace ./tests/unit_tests
coverage report
coverage xml

View File

@@ -13,7 +13,7 @@ variables:
value: main
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH
CHECK_PKG_VERSIONS:
description: Whether to run additional tests against min/max/random selection of dependencies. Set to 1 for running.
description: Whether to run additional tests against min/max/random selection of dependencies. Set to 1 for running.
value: 0
workflow:
@@ -77,7 +77,7 @@ formatter:
stage: Formatter
needs: []
script:
- pip install bec_lib[dev]
- pip install -e ./[dev]
- isort --check --diff --line-length=100 --profile=black --multi-line=3 --trailing-comma ./
- black --check --diff --color --line-length=100 --skip-magic-trailing-comma ./
rules:
@@ -162,6 +162,20 @@ tests:
- tests/reference_failures/
when: always
generate-client-check:
stage: test
needs: []
variables:
QT_QPA_PLATFORM: "offscreen"
script:
- *clone-repos
- *install-os-packages
- *install-repos
- pip install -e .[dev,pyside6]
- bw-generate-cli --target bec_widgets
# if there are changes in the generated files, fail the job
- git diff --exit-code
test-matrix:
parallel:
matrix:
@@ -189,7 +203,7 @@ test-matrix:
end-2-end-conda:
stage: End2End
needs: []
image: continuumio/miniconda3
image: continuumio/miniconda3:25.1.1-2
allow_failure: false
variables:
QT_QPA_PLATFORM: "offscreen"
@@ -216,7 +230,7 @@ end-2-end-conda:
- pip install -e ./ophyd_devices
- pip install -e .[dev,pyside6]
- pytest -v --files-path ./ --start-servers --flush-redis --random-order ./tests/end-2-end
- pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
artifacts:
when: on_failure
@@ -231,7 +245,7 @@ end-2-end-conda:
- if: '$CI_PIPELINE_SOURCE == "parent_pipeline"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^pre_release.*$/'
- if: "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^pre_release.*$/"
semver:
stage: Deploy

View File

@@ -1,6 +1,127 @@
# CHANGELOG
## v2.3.0 (2025-05-09)
### Features
- **bec_connector**: Ability to change object name during runtime
([`dc151cd`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/dc151cdfe39f1f0507eeee307a35c1677ae4d8c5))
## v2.2.0 (2025-05-09)
### Features
- **launcher**: Add support for launching plugin widget
([`1fb680a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1fb680abb40668e72007c245f32c80112466c46e))
### Refactoring
- **launch_window**: Widget tile added
([`b9e56c9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b9e56c96cbae561beb893cedb7d18e9b6a7bfc76))
## v2.1.3 (2025-05-07)
### Bug Fixes
- **bec-dispatcher**: Fix reference to boundmethods to avoid duplicated subscriptions
([`cf59d31`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cf59d311132cd1a21f1893c19cc9f2a7e45101d0))
## v2.1.2 (2025-05-06)
### Bug Fixes
- **waveform**: Ignore callbacks for on_async_readback from QtSender objects that are already
destroyed; closes #497
([`64a4824`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/64a48240546846fdf4541c2adf3a0a5a0829f948))
### Build System
- Remove flush-redis from ci job
([`a6c479e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a6c479e42ea2a47c45e5a323bb3072bab503ecf1))
### Refactoring
- **bec-progressbar**: Add private method for bec_progressbar, udate client file
([`37f0024`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/37f002427ad5da01164ae3b0f4983695fe61c243))
- **bec-status-box**: Add get_server_state user_access method to BECStatusBox
([`1619446`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1619446ec9839cfa1c666a3790a0c2abc449c4a8))
## v2.1.1 (2025-05-06)
### Bug Fixes
- Import add operator in client
([`55f7efc`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/55f7efc4f586128dfb66fc6a8eb5d3a9f32bf61e))
### Refactoring
- Supply bec designer filename to function
([`be72c9f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/be72c9f2708c93dab24d4383f5622e38cf1dc8a2))
## v2.1.0 (2025-05-05)
### Bug Fixes
- Ensure rpc object do not collide with protected names
([`94463af`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/94463afdba11fe2da5958a371ef49572889b8622))
### Chores
- **formatter**: Upgrade to black v25
([`452124b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/452124b528c41db14d1e34ab98db95f6f7230ad6))
### Continuous Integration
- Install dev dependencies for formatter
([`fff4af2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fff4af2489bdea0cf4f6f8db68db59fba411c25e))
### Features
- **SafeSlot**: Slot parameters can be overridden with kwarg; add option to raise
([`9387275`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/93872758517177503b1f868376a6095670131844))
### Refactoring
- **colormap_widget**: Widget is rounded
([`02563b1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/02563b10f3c90bddc069446dfe4137aa5a9727cb))
### Testing
- **Dock**: Add validation for new dock creation with invalid name
([`c16b9dc`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c16b9dce9ce629b794d731cd7f3282a59f8b8c59))
## v2.0.3 (2025-05-02)
### Bug Fixes
- **generate_cli**: Apply isort config
([`770dbd4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/770dbd4b63baba588871a4d4ffa77d44872d085b))
- **image_item**: Wrong user access name for rotation
([`58a0bc7`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/58a0bc79742e7e7578988711a9840ed6041d9a69))
### Continuous Integration
- Add job to test that the generated client is up to date
([`d22035f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d22035f8974ac51ae1b6efc0e2b3749ca0a674ff))
## v2.0.2 (2025-05-01)
### Bug Fixes
- **plot_base**: No content margin for plot_widget window
([`1b78840`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1b78840fd87ea0f156c73beeb57c6c06f685f7b1))
## v2.0.1 (2025-04-30)
### Bug Fixes

View File

@@ -1,5 +1,12 @@
# BEC Widgets
[![CI](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml/badge.svg)](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml)
[![badge](https://img.shields.io/pypi/v/bec-widgets)](https://pypi.org/project/bec-widgets/)
[![License](https://img.shields.io/github/license/bec-project/bec_widgets)](./LICENSE)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
**⚠️ Important Notice:**
🚨 **PyQt6 is no longer supported** due to incompatibilities with Qt Designer. Please use **PySide6** instead. 🚨

View File

@@ -2,10 +2,10 @@ from __future__ import annotations
import os
import xml.etree.ElementTree as ET
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Callable
from bec_lib.logger import bec_logger
from qtpy.QtCore import Qt, Signal
from qtpy.QtCore import Qt, Signal # type: ignore
from qtpy.QtGui import QPainter, QPainterPath, QPixmap
from qtpy.QtWidgets import (
QApplication,
@@ -21,8 +21,10 @@ from qtpy.QtWidgets import (
import bec_widgets
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.utils.toolbar import ModularToolBar
@@ -35,6 +37,8 @@ from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtCore import QObject
from bec_widgets.utils.bec_widget import BECWidget
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
@@ -141,6 +145,7 @@ class LaunchWindow(BECMainWindow):
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
self.app = QApplication.instance()
self.tiles: dict[str, LaunchTile] = {}
# Toolbar
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
@@ -156,58 +161,105 @@ class LaunchWindow(BECMainWindow):
self.central_widget.layout = QHBoxLayout(self.central_widget)
self.setCentralWidget(self.central_widget)
self.tile_dock_area = LaunchTile(
self.register_tile(
name="dock_area",
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
top_label="Get started",
main_label="BEC Dock Area",
description="Highly flexible and customizable dock area application with modular widgets.",
action_button=lambda: self.launch("dock_area"),
show_selector=False,
)
self.tile_dock_area.setFixedSize(*self.TILE_SIZE)
self.tile_auto_update = LaunchTile(
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
self._update_available_auto_updates()
)
self.register_tile(
name="auto_update",
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "auto_update.png"),
top_label="Get automated",
main_label="BEC Auto Update Dock Area",
description="Dock area with auto update functionality for BEC widgets plotting.",
action_button=self._open_auto_update,
show_selector=True,
selector_items=list(self.available_auto_updates.keys()) + ["Default"],
)
self.tile_auto_update.setFixedSize(*self.TILE_SIZE)
self.tile_ui_file = LaunchTile(
self.register_tile(
name="custom_ui_file",
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "ui_loader_tile.png"),
top_label="Get customized",
main_label="Launch Custom UI File",
description="GUI application with custom UI file.",
action_button=self._open_custom_ui_file,
show_selector=False,
)
self.tile_ui_file.setFixedSize(*self.TILE_SIZE)
# Add tiles to the main layout
self.central_widget.layout.addWidget(self.tile_dock_area)
self.central_widget.layout.addWidget(self.tile_auto_update)
self.central_widget.layout.addWidget(self.tile_ui_file)
# hacky solution no time to waste
self.tiles = [self.tile_dock_area, self.tile_auto_update, self.tile_ui_file]
# Connect signals
self.tile_dock_area.action_button.clicked.connect(lambda: self.launch("dock_area"))
self.tile_auto_update.action_button.clicked.connect(self._open_auto_update)
self.tile_ui_file.action_button.clicked.connect(self._open_custom_ui_file)
self._update_theme()
# Auto updates
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
self._update_available_auto_updates()
)
if self.tile_auto_update.selector is not None:
self.tile_auto_update.selector.addItems(
list(self.available_auto_updates.keys()) + ["Default"]
# plugin widgets
self.available_widgets: dict[str, BECWidget] = get_all_plugin_widgets()
if self.available_widgets:
plugin_repo_name = next(iter(self.available_widgets.values())).__module__.split(".")[0]
plugin_repo_name = plugin_repo_name.removesuffix("_bec").upper()
self.register_tile(
name="widget",
icon_path=os.path.join(
MODULE_PATH, "assets", "app_icons", "widget_launch_tile.png"
),
top_label="Get quickly started",
main_label=f"Launch a {plugin_repo_name} Widget",
description=f"GUI application with one widget from the {plugin_repo_name} repository.",
action_button=self._open_widget,
show_selector=True,
selector_items=list(self.available_widgets.keys()),
)
self._update_theme()
self.register = RPCRegister()
self.register.callbacks.append(self._turn_off_the_lights)
self.register.broadcast()
def register_tile(
self,
name: str,
icon_path: str | None = None,
top_label: str | None = None,
main_label: str | None = None,
description: str | None = None,
action_button: Callable | None = None,
show_selector: bool = False,
selector_items: list[str] | None = None,
):
"""
Register a tile in the launcher window.
Args:
name(str): The name of the tile.
icon_path(str): The path to the icon.
top_label(str): The top label of the tile.
main_label(str): The main label of the tile.
description(str): The description of the tile.
action_button(callable): The action to be performed when the button is clicked.
show_selector(bool): Whether to show a selector or not.
selector_items(list[str]): The items to be shown in the selector.
"""
tile = LaunchTile(
icon_path=icon_path,
top_label=top_label,
main_label=main_label,
description=description,
show_selector=show_selector,
)
tile.setFixedSize(*self.TILE_SIZE)
if action_button:
tile.action_button.clicked.connect(action_button)
if show_selector and selector_items:
tile.selector.addItems(selector_items)
self.central_widget.layout.addWidget(tile)
self.tiles[name] = tile
def launch(
self,
launch_script: str,
@@ -235,10 +287,8 @@ class LaunchWindow(BECMainWindow):
raise ValueError(
f"Name {name} must be unique for dock areas, but already exists: {existing_dock_areas}."
)
if not WidgetContainerUtils.has_name_valid_chars(name):
raise ValueError(
f"Name {name} contains invalid characters. Only alphanumeric characters, underscores, and dashes are allowed."
)
WidgetContainerUtils.raise_for_invalid_name(name)
else:
name = "dock_area"
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
@@ -258,6 +308,12 @@ class LaunchWindow(BECMainWindow):
auto_update = kwargs.pop("auto_update", None)
return self._launch_auto_update(auto_update)
if launch_script == "widget":
widget = kwargs.pop("widget", None)
if widget is None:
raise ValueError("Widget name must be provided.")
return self._launch_widget(widget)
launch = getattr(bw_launch, launch_script, None)
if launch is None:
raise ValueError(f"Launch script {launch_script} not found.")
@@ -275,6 +331,7 @@ class LaunchWindow(BECMainWindow):
else:
window = BECMainWindow()
window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
window.show()
return result_widget
@@ -284,6 +341,8 @@ class LaunchWindow(BECMainWindow):
raise ValueError("UI file must be provided for custom UI file launch.")
filename = os.path.basename(ui_file).split(".")[0]
WidgetContainerUtils.raise_for_invalid_name(filename)
tree = ET.parse(ui_file)
root = tree.getroot()
# Check if the top-level widget is a QMainWindow
@@ -321,11 +380,28 @@ class LaunchWindow(BECMainWindow):
window.show()
return window
def _launch_widget(self, widget: type[BECWidget]) -> QWidget:
name = pascal_to_snake(widget.__name__)
WidgetContainerUtils.raise_for_invalid_name(name)
window = BECMainWindow()
widget_instance = widget(root_widget=True, object_name=name)
assert isinstance(widget_instance, QWidget)
QApplication.processEvents()
window.setCentralWidget(widget_instance)
window.resize(window.minimumSizeHint())
window.setWindowTitle(f"BEC - {widget_instance.objectName()}")
window.show()
return window
def apply_theme(self, theme: str):
"""
Change the theme of the application.
"""
for tile in self.tiles:
for tile in self.tiles.values():
tile.apply_theme(theme)
super().apply_theme(theme)
@@ -334,14 +410,25 @@ class LaunchWindow(BECMainWindow):
"""
Open the auto update window.
"""
if self.tile_auto_update.selector is None:
if self.tiles["auto_update"].selector is None:
auto_update = None
else:
auto_update = self.tile_auto_update.selector.currentText()
auto_update = self.tiles["auto_update"].selector.currentText()
if auto_update == "Default":
auto_update = None
return self.launch("auto_update", auto_update=auto_update)
def _open_widget(self):
"""
Open a widget from the available widgets.
"""
if self.tiles["widget"].selector is None:
return
widget = self.tiles["widget"].selector.currentText()
if widget not in self.available_widgets:
raise ValueError(f"Widget {widget} not found in available widgets.")
return self.launch("widget", widget=self.available_widgets[widget])
@SafeSlot(popup_error=True)
def _open_custom_ui_file(self):
"""

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -7,6 +7,7 @@ import enum
import inspect
import traceback
from functools import reduce
from operator import add
from typing import Literal, Optional
from bec_lib.logger import bec_logger
@@ -469,6 +470,12 @@ class BECProgressBar(RPCBase):
>>> progressbar.label_template = "$value / $percentage %"
"""
@rpc_call
def _get_label(self) -> str:
"""
Return the label text. mostly used for testing rpc.
"""
class BECQueue(RPCBase):
"""Widget to display the BEC queue."""
@@ -483,6 +490,12 @@ class BECQueue(RPCBase):
class BECStatusBox(RPCBase):
"""An autonomous widget to display the status of BEC services."""
@rpc_call
def get_server_state(self) -> "str":
"""
Get the state ("RUNNING", "BUSY", "IDLE", "ERROR") of the BEC server
"""
@rpc_call
def remove(self):
"""
@@ -1317,14 +1330,14 @@ class ImageItem(RPCBase):
@property
@rpc_call
def rotation(self) -> "Optional[int]":
def num_rotation_90(self) -> "Optional[int]":
"""
Get or set the number of 90° rotations to apply.
"""
@rotation.setter
@num_rotation_90.setter
@rpc_call
def rotation(self) -> "Optional[int]":
def num_rotation_90(self) -> "Optional[int]":
"""
Get or set the number of 90° rotations to apply.
"""

View File

@@ -41,6 +41,7 @@ class ClientGenerator:
import inspect
import traceback
from functools import reduce
from operator import add
from typing import Literal, Optional
"""
if self._base
@@ -222,18 +223,18 @@ class {class_name}(RPCBase):"""
# Combine header and content, then format with black
full_content = self.header + "\n" + self.content
try:
formatted_content = black.format_str(full_content, mode=black.FileMode(line_length=100))
formatted_content = black.format_str(full_content, mode=black.Mode(line_length=100))
except black.NothingChanged:
formatted_content = full_content
isort.Config(
config = isort.Config(
profile="black",
line_length=100,
multi_line_output=3,
include_trailing_comma=True,
include_trailing_comma=False,
known_first_party=["bec_widgets"],
)
formatted_content = isort.code(formatted_content)
formatted_content = isort.code(formatted_content, config=config)
with open(file_name, "w", encoding="utf-8") as file:
file.write(formatted_content)
@@ -318,5 +319,5 @@ def main():
if __name__ == "__main__": # pragma: no cover
import sys
sys.argv = ["bw-generate-cli", "--target", "csaxs_bec"]
sys.argv = ["bw-generate-cli", "--target", "bec_widgets"]
main()

View File

@@ -205,6 +205,17 @@ class BECConnector:
f"This is not necessarily an error as the parent may be deleted before the child and includes already a cleanup. The following exception was raised:\n{content}"
)
def change_object_name(self, name: str) -> None:
"""
Change the object name of the widget. Unregister old name and register the new one.
Args:
name (str): The new object name.
"""
self.rpc_register.remove_rpc(self)
self.setObjectName(name.replace("-", "_").replace(" ", "_"))
QTimer.singleShot(0, self._update_object_name)
def _update_object_name(self) -> None:
"""
Enforce a unique object name among siblings and register the object for RPC.

View File

@@ -16,9 +16,9 @@ if PYSIDE6:
from PySide6.scripts.pyside_tool import (
_extend_path_var,
init_virtual_env,
qt_tool_wrapper,
is_pyenv_python,
is_virtual_env,
qt_tool_wrapper,
ui_tool_binary,
)
@@ -78,7 +78,7 @@ def list_editable_packages() -> set[str]:
return editable_packages
def patch_designer(): # pragma: no cover
def patch_designer(cmd_args: list[str] = []): # pragma: no cover
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
@@ -119,7 +119,7 @@ def patch_designer(): # pragma: no cover
editable_packages = list_editable_packages()
for pckg in editable_packages:
_extend_path_var("PYTHONPATH", pckg, True)
qt_tool_wrapper(ui_tool_binary("designer"), sys.argv[1:])
qt_tool_wrapper(ui_tool_binary("designer"), cmd_args)
def find_plugin_paths(base_path: Path):
@@ -147,7 +147,7 @@ def set_plugin_environment_variable(plugin_paths):
# Patch the designer function
def main(): # pragma: no cover
def open_designer(cmd_args: list[str] = []): # pragma: no cover
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Exiting...")
return
@@ -160,7 +160,11 @@ def main(): # pragma: no cover
set_plugin_environment_variable(plugin_paths)
patch_designer()
patch_designer(cmd_args)
def main():
open_designer(sys.argv[1:])
if __name__ == "__main__": # pragma: no cover

View File

@@ -4,8 +4,9 @@ import collections
import random
import string
from collections.abc import Callable
from typing import TYPE_CHECKING, Union
from typing import TYPE_CHECKING, DefaultDict, Hashable, Union
import louie
import redis
from bec_lib.client import BECClient
from bec_lib.logger import bec_logger
@@ -25,21 +26,41 @@ if TYPE_CHECKING: # pragma: no cover
class QtThreadSafeCallback(QObject):
"""QtThreadSafeCallback is a wrapper around a callback function to make it thread-safe for Qt."""
cb_signal = pyqtSignal(dict, dict)
def __init__(self, cb):
def __init__(self, cb: Callable, cb_info: dict | None = None):
"""
Initialize the QtThreadSafeCallback.
Args:
cb (Callable): The callback function to be wrapped.
cb_info (dict, optional): Additional information about the callback. Defaults to None.
"""
super().__init__()
self.cb_info = cb_info
self.cb = cb
self.cb_ref = louie.saferef.safe_ref(cb)
self.cb_signal.connect(self.cb)
self.topics = set()
def __hash__(self):
# make 2 differents QtThreadSafeCallback to look
# identical when used as dictionary keys, if the
# callback is the same
return id(self.cb)
return f"{id(self.cb_ref)}{self.cb_info}".__hash__()
def __eq__(self, other):
if not isinstance(other, QtThreadSafeCallback):
return False
return self.cb_ref == other.cb_ref and self.cb_info == other.cb_info
def __call__(self, msg_content, metadata):
if self.cb_ref() is None:
# callback has been deleted
return
self.cb_signal.emit(msg_content, metadata)
@@ -86,7 +107,7 @@ class BECDispatcher:
cls,
client=None,
config: str | ServiceConfig | None = None,
gui_id: str = None,
gui_id: str | None = None,
*args,
**kwargs,
):
@@ -99,7 +120,9 @@ class BECDispatcher:
if self._initialized:
return
self._slots = collections.defaultdict(set)
self._registered_slots: DefaultDict[Hashable, QtThreadSafeCallback] = (
collections.defaultdict()
)
self.client = client
if self.client is None:
@@ -141,6 +164,7 @@ class BECDispatcher:
self,
slot: Callable,
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
cb_info: dict | None = None,
**kwargs,
) -> None:
"""Connect widget's qt slot, so that it is called on new pub/sub topic message.
@@ -149,11 +173,15 @@ class BECDispatcher:
slot (Callable): A slot method/function that accepts two inputs: content and metadata of
the corresponding pub/sub message
topics (EndpointInfo | str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
"""
slot = QtThreadSafeCallback(slot)
self.client.connector.register(topics, cb=slot, **kwargs)
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
if qt_slot not in self._registered_slots:
self._registered_slots[qt_slot] = qt_slot
qt_slot = self._registered_slots[qt_slot]
self.client.connector.register(topics, cb=qt_slot, **kwargs)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
self._slots[slot].update(set(topics_str))
qt_slot.topics.update(set(topics_str))
def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
"""
@@ -166,16 +194,16 @@ class BECDispatcher:
# find the right slot to disconnect from ;
# slot callbacks are wrapped in QtThreadSafeCallback objects,
# but the slot we receive here is the original callable
for connected_slot in self._slots:
for connected_slot in self._registered_slots.values():
if connected_slot.cb == slot:
break
else:
return
self.client.connector.unregister(topics, cb=connected_slot)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
self._slots[connected_slot].difference_update(set(topics_str))
if not self._slots[connected_slot]:
del self._slots[connected_slot]
self._registered_slots[connected_slot].topics.difference_update(set(topics_str))
if not self._registered_slots[connected_slot].topics:
del self._registered_slots[connected_slot]
def disconnect_topics(self, topics: Union[str, list]):
"""
@@ -186,11 +214,16 @@ class BECDispatcher:
"""
self.client.connector.unregister(topics)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
for slot in list(self._slots.keys()):
slot_topics = self._slots[slot]
slot_topics.difference_update(set(topics_str))
if not slot_topics:
del self._slots[slot]
remove_slots = []
for connected_slot in self._registered_slots.values():
connected_slot.topics.difference_update(set(topics_str))
if not connected_slot.topics:
remove_slots.append(connected_slot)
for connected_slot in remove_slots:
self._registered_slots.pop(connected_slot, None)
def disconnect_all(self, *args, **kwargs):
"""

View File

@@ -21,7 +21,7 @@ def _submodule_specs(module: ModuleType) -> tuple[ModuleSpec | None, ...]:
def _loaded_submodules_from_specs(
submodule_specs: tuple[ModuleSpec | None, ...]
submodule_specs: tuple[ModuleSpec | None, ...],
) -> Generator[ModuleType, None, None]:
"""Load all submodules from the given specs."""
for submodule in (

View File

@@ -1,6 +1,6 @@
""" This custom class is a thin wrapper around the SignalProxy class to allow signal calls to be blocked.
"""This custom class is a thin wrapper around the SignalProxy class to allow signal calls to be blocked.
Unblocking the proxy needs to be done through the slot unblock_proxy. The most likely use case for this class is
when the callback function is potentially initiating a slower progress, i.e. requesting a data analysis routine to
when the callback function is potentially initiating a slower progress, i.e. requesting a data analysis routine to
analyse data. Requesting a new fit may lead to request piling up and an overall slow done of performance. This proxy
will allow you to decide by yourself when to unblock and execute the callback again."""

View File

@@ -1,11 +1,10 @@
from __future__ import annotations
import itertools
from typing import Literal, Type
from typing import Any, Type
from qtpy.QtWidgets import QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.cli.client_utils import BECGuiClient
class WidgetContainerUtils:
@@ -73,3 +72,36 @@ class WidgetContainerUtils:
return None
else:
raise ValueError(f"No widget of class {widget_class} found.")
@staticmethod
def name_is_protected(name: str, container: Any = None) -> bool:
"""
Check if the name is not protected.
Args:
name(str): The name to be checked.
Returns:
bool: True if the name is not protected, False otherwise.
"""
if container is None:
container = BECGuiClient
gui_client_methods = set(filter(lambda x: not x.startswith("_"), dir(container)))
return name in gui_client_methods
@staticmethod
def raise_for_invalid_name(name: str, container: Any = None) -> None:
"""
Check if the name is valid. If not, raise a ValueError.
Args:
name(str): The name to be checked.
Raises:
ValueError: If the name is not valid.
"""
if not WidgetContainerUtils.has_name_valid_chars(name):
raise ValueError(
f"Name '{name}' contains invalid characters. Only alphanumeric characters, underscores, and dashes are allowed."
)
if WidgetContainerUtils.name_is_protected(name, container):
raise ValueError(f"Name '{name}' is protected. Please choose another name.")

View File

@@ -99,16 +99,30 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
'verify_sender' keyword argument can be passed with boolean value if the sender should be verified
before executing the slot. If True, the slot will only execute if the sender is a QObject. This is
useful to prevent function calls from already deleted objects.
'raise_error' keyword argument can be passed with boolean value if the error should be raised
after the error is displayed. This is useful to propagate the error to the caller but should be used
with great care to avoid segfaults.
The keywords above are stored in a container which can be overridden by passing
'_override_slot_params' keyword argument with a dictionary containing the keywords to override.
This is useful to override the default behavior of the decorator for a specific function call.
"""
popup_error = bool(slot_kwargs.pop("popup_error", False))
verify_sender = bool(slot_kwargs.pop("verify_sender", False))
_slot_params = {
"popup_error": bool(slot_kwargs.pop("popup_error", False)),
"verify_sender": bool(slot_kwargs.pop("verify_sender", False)),
"raise_error": bool(slot_kwargs.pop("raise_error", False)),
}
def error_managed(method):
@Slot(*slot_args, **slot_kwargs)
@functools.wraps(method)
def wrapper(*args, **kwargs):
_override_slot_params = kwargs.pop("_override_slot_params", {})
_slot_params.update(_override_slot_params)
try:
if not verify_sender or len(args) == 0:
if not _slot_params["verify_sender"] or len(args) == 0:
return method(*args, **kwargs)
_instance = args[0]
@@ -126,11 +140,11 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
except Exception:
slot_name = f"{method.__module__}.{method.__qualname__}"
error_msg = traceback.format_exc()
if popup_error:
ErrorPopupUtility().custom_exception_hook(
*sys.exc_info(), popup_error=popup_error
)
if _slot_params["popup_error"]:
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=True)
logger.error(f"SafeSlot error in slot '{slot_name}':\n{error_msg}")
if _slot_params["raise_error"]:
raise
return wrapper

View File

@@ -1,5 +1,5 @@
""" Module for a thin wrapper (LinearRegionWrapper) around the LinearRegionItem in pyqtgraph.
The class is mainly designed for usage with the BECWaveform and 1D plots. """
"""Module for a thin wrapper (LinearRegionWrapper) around the LinearRegionItem in pyqtgraph.
The class is mainly designed for usage with the BECWaveform and 1D plots."""
from __future__ import annotations

View File

@@ -303,11 +303,7 @@ class BECDock(BECWidget, Dock):
shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied.
"""
if name is not None:
if not WidgetContainerUtils.has_name_valid_chars(name):
raise ValueError(
f"Name {name} contains invalid characters. "
f"Only alphanumeric characters and underscores are allowed."
)
WidgetContainerUtils.raise_for_invalid_name(name, container=self)
if row is None:
row = self.layout.rowCount()

View File

@@ -366,11 +366,8 @@ class BECDockArea(BECWidget, QWidget):
f"Name {name} must be unique for docks, but already exists in DockArea "
f"with name: {self.object_name} and id {self.gui_id}."
)
if not WidgetContainerUtils.has_name_valid_chars(name):
raise ValueError(
f"Name {name} contains invalid characters. "
f"Only alphanumeric characters and underscores are allowed."
)
WidgetContainerUtils.raise_for_invalid_name(name, container=self)
else: # Name is not provided
name = WidgetContainerUtils.generate_unique_name(name="dock", list_of_names=dock_names)

View File

@@ -1,4 +1,4 @@
""" Module for a PositionerGroup widget to control a positioner device."""
"""Module for a PositionerGroup widget to control a positioner device."""
from __future__ import annotations

View File

@@ -1,4 +1,4 @@
""" Module for DapComboBox widget class to select a DAP model from a combobox. """
"""Module for DapComboBox widget class to select a DAP model from a combobox."""
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, Signal, Slot

View File

@@ -1,5 +1,5 @@
"""
BECConsole is a Qt widget that runs a Bash shell.
BECConsole is a Qt widget that runs a Bash shell.
BECConsole VT100 emulation is powered by Pyte,
(https://github.com/selectel/pyte).
@@ -56,12 +56,12 @@ control_keys_mapping = {
QtCore.Qt.Key_G: b"\x07", # Ctrl-G (Bell)
QtCore.Qt.Key_H: b"\x08", # Ctrl-H (Backspace)
QtCore.Qt.Key_I: b"\x09", # Ctrl-I (Tab)
QtCore.Qt.Key_J: b"\x0A", # Ctrl-J (Line Feed)
QtCore.Qt.Key_K: b"\x0B", # Ctrl-K (Vertical Tab)
QtCore.Qt.Key_L: b"\x0C", # Ctrl-L (Form Feed)
QtCore.Qt.Key_M: b"\x0D", # Ctrl-M (Carriage Return)
QtCore.Qt.Key_N: b"\x0E", # Ctrl-N
QtCore.Qt.Key_O: b"\x0F", # Ctrl-O
QtCore.Qt.Key_J: b"\x0a", # Ctrl-J (Line Feed)
QtCore.Qt.Key_K: b"\x0b", # Ctrl-K (Vertical Tab)
QtCore.Qt.Key_L: b"\x0c", # Ctrl-L (Form Feed)
QtCore.Qt.Key_M: b"\x0d", # Ctrl-M (Carriage Return)
QtCore.Qt.Key_N: b"\x0e", # Ctrl-N
QtCore.Qt.Key_O: b"\x0f", # Ctrl-O
QtCore.Qt.Key_P: b"\x10", # Ctrl-P
QtCore.Qt.Key_Q: b"\x11", # Ctrl-Q
QtCore.Qt.Key_R: b"\x12", # Ctrl-R
@@ -72,10 +72,10 @@ control_keys_mapping = {
QtCore.Qt.Key_W: b"\x17", # Ctrl-W
QtCore.Qt.Key_X: b"\x18", # Ctrl-X
QtCore.Qt.Key_Y: b"\x19", # Ctrl-Y
QtCore.Qt.Key_Z: b"\x1A", # Ctrl-Z
QtCore.Qt.Key_Escape: b"\x1B", # Ctrl-Escape
QtCore.Qt.Key_Backslash: b"\x1C", # Ctrl-\
QtCore.Qt.Key_Underscore: b"\x1F", # Ctrl-_
QtCore.Qt.Key_Z: b"\x1a", # Ctrl-Z
QtCore.Qt.Key_Escape: b"\x1b", # Ctrl-Escape
QtCore.Qt.Key_Backslash: b"\x1c", # Ctrl-\
QtCore.Qt.Key_Underscore: b"\x1f", # Ctrl-_
}
normal_keys_mapping = {
@@ -89,7 +89,7 @@ normal_keys_mapping = {
QtCore.Qt.Key_Left: b"\x02",
QtCore.Qt.Key_Up: b"\x10",
QtCore.Qt.Key_Right: b"\x06",
QtCore.Qt.Key_Down: b"\x0E",
QtCore.Qt.Key_Down: b"\x0e",
QtCore.Qt.Key_PageUp: b"\x49",
QtCore.Qt.Key_PageDown: b"\x51",
QtCore.Qt.Key_F1: b"\x1b\x31",

View File

@@ -61,8 +61,8 @@ class ImageItem(BECConnector, pg.ImageItem):
"fft.setter",
"log",
"log.setter",
"rotation",
"rotation.setter",
"num_rotation_90",
"num_rotation_90.setter",
"transpose",
"transpose.setter",
"get_data",

View File

@@ -98,6 +98,7 @@ class PlotBase(BECWidget, QWidget):
self._ui_mode = UIMode.POPUP if popups else UIMode.SIDE
self.axis_settings_dialog = None
self.plot_widget = pg.GraphicsLayoutWidget(parent=self)
self.plot_widget.ci.setContentsMargins(0, 0, 0, 0)
self.plot_item = pg.PlotItem(viewBox=BECViewBox(enableMenu=True))
self.plot_widget.addItem(self.plot_item)
self.side_panel = SidePanel(self, orientation="left", panel_max_width=280)
@@ -795,6 +796,7 @@ class PlotBase(BECWidget, QWidget):
"""
self.plot_item.showAxis("top", value)
self.plot_item.showAxis("right", value)
self.property_changed.emit("outer_axes", value)
@SafeProperty(bool, doc="Show inner axes of the plot widget.")
@@ -814,6 +816,7 @@ class PlotBase(BECWidget, QWidget):
"""
self.plot_item.showAxis("bottom", value)
self.plot_item.showAxis("left", value)
self._apply_x_label()
self._apply_y_label()
self.property_changed.emit("inner_axes", value)

View File

@@ -1182,10 +1182,11 @@ class Waveform(PlotBase):
self.on_async_readback,
MessageEndpoints.device_async_readback(self.scan_id, name),
from_start=True,
cb_info={"scan_id": self.scan_id},
)
logger.info(f"Setup async curve {name}")
@SafeSlot(dict, dict)
@SafeSlot(dict, dict, verify_sender=True)
def on_async_readback(self, msg, metadata):
"""
Get async data readback. This code needs to be fast, therefor we try
@@ -1204,6 +1205,14 @@ class Waveform(PlotBase):
msg(dict): Message with the async data.
metadata(dict): Metadata of the message.
"""
sender = self.sender()
if not hasattr(sender, "cb_info"):
logger.info(f"Sender {sender} has no cb_info.")
return
scan_id = sender.cb_info.get("scan_id", None)
if scan_id != self.scan_id:
logger.info("Scan ID mismatch, ignoring async readback.")
instruction = metadata.get("async_update", {}).get("type")
if instruction not in ["add", "add_slice", "replace"]:
logger.warning(f"Invalid async update instruction: {instruction}")
@@ -1212,6 +1221,7 @@ class Waveform(PlotBase):
plot_mode = self.x_axis_mode["name"]
for curve in self._async_curves:
x_data = None # Reset x_data
y_data = None # Reset y_data
# Get the curve data
async_data = msg["signals"].get(curve.config.signal.entry, None)
if async_data is None:

View File

@@ -21,6 +21,7 @@ class BECProgressBar(BECWidget, QWidget):
"set_minimum",
"label_template",
"label_template.setter",
"_get_label",
]
ICON_NAME = "page_control"
@@ -235,6 +236,10 @@ class BECProgressBar(BECWidget, QWidget):
(value - self._user_minimum) / (self._user_maximum - self._user_minimum) * self._maximum
)
def _get_label(self) -> str:
"""Return the label text. mostly used for testing rpc."""
return self.center_label.text()
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)

View File

@@ -76,6 +76,7 @@ class BECStatusBox(BECWidget, CompactPopupWidget):
PLUGIN = True
CORE_SERVICES = ["DeviceServer", "ScanServer", "SciHub", "ScanBundler", "FileWriterManager"]
USER_ACCESS = ["get_server_state", "remove"]
service_update = Signal(BECServiceInfoContainer)
bec_core_state = Signal(str)
@@ -134,6 +135,10 @@ class BECStatusBox(BECWidget, CompactPopupWidget):
"QTreeWidget::item:selected {}"
)
def get_server_state(self) -> str:
"""Get the state ("RUNNING", "BUSY", "IDLE", "ERROR") of the BEC server"""
return self.status_container[self.box_name]["info"].status
def _create_status_widget(
self, service_name: str, status=BECStatus, info: dict = None, metrics: dict = None
) -> StatusItem:

View File

@@ -1,4 +1,4 @@
""" Module for a StatusItem widget to display status and metrics for a BEC service.
"""Module for a StatusItem widget to display status and metrics for a BEC service.
The widget is bound to be used with the BECStatusBox widget."""
import enum

View File

@@ -1,4 +1,4 @@
""" Utilities for filtering and formatting in the LogPanel"""
"""Utilities for filtering and formatting in the LogPanel"""
from __future__ import annotations

View File

@@ -1,4 +1,5 @@
from pyqtgraph.widgets.ColorMapButton import ColorMapButton
from qtpy import QtCore, QtGui
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtWidgets import QSizePolicy, QVBoxLayout, QWidget
@@ -6,6 +7,23 @@ from bec_widgets.utils import Colors
from bec_widgets.utils.bec_widget import BECWidget
class RoundedColorMapButton(ColorMapButton):
"""Thin wrapper around pyqtgraph ColorMapButton to add rounded clipping."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
def paintEvent(self, evt):
painter = QtGui.QPainter(self)
painter.setRenderHint(QtGui.QPainter.Antialiasing)
path = QtGui.QPainterPath()
path.addRoundedRect(self.rect(), 8, 8)
painter.setClipPath(path)
self.paintColorMap(painter, self.contentsRect())
painter.end()
class BECColorMapWidget(BECWidget, QWidget):
colormap_changed_signal = Signal(str)
ICON_NAME = "palette"
@@ -15,7 +33,7 @@ class BECColorMapWidget(BECWidget, QWidget):
def __init__(self, parent=None, cmap: str = "plasma", **kwargs):
super().__init__(parent=parent, **kwargs)
# Create the ColorMapButton
self.button = ColorMapButton()
self.button = RoundedColorMapButton()
# Set the size policy and minimum width
size_policy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.0.1"
version = "2.3.0"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [
@@ -16,7 +16,7 @@ dependencies = [
"bec_ipython_client>=2.21.4, <=4.0", # needed for jupyter console
"bec_lib>=3.29, <=4.0",
"bec_qthemes~=0.7, >=0.7",
"black~=24.0", # needed for bw-generate-cli
"black~=25.0", # needed for bw-generate-cli
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
"pydantic~=2.0",
"pyqtgraph~=0.13",
@@ -31,6 +31,7 @@ dependencies = [
dev = [
"coverage~=7.0",
"fakeredis~=2.23, >=2.23.2",
"isort~=5.13, >=5.13.2",
"pytest-bec-e2e>=2.21.4, <=4.0",
"pytest-qt~=4.4",
"pytest-random-order~=1.1",

View File

@@ -4,8 +4,7 @@ import random
import pytest
from bec_widgets.cli.client_utils import BECGuiClient, _start_plot_process
from bec_widgets.utils import BECDispatcher
from bec_widgets.cli.client_utils import BECGuiClient
# pylint: disable=unused-argument
# pylint: disable=redefined-outer-name
@@ -28,7 +27,7 @@ def gui_id():
return f"figure_{random.randint(0,100)}" # make a new gui id each time, to ensure no 'gui is alive' zombie key can perturbate
@pytest.fixture
@pytest.fixture(scope="function")
def connected_client_gui_obj(qtbot, gui_id, bec_client_lib):
"""
Fixture to create a new BECGuiClient object and start a server in the background.
@@ -42,22 +41,3 @@ def connected_client_gui_obj(qtbot, gui_id, bec_client_lib):
yield gui
finally:
gui.kill_server()
@pytest.fixture(scope="session")
def connected_gui_with_scope_session(qtbot, gui_id, bec_client_lib):
"""
Fixture to create a new BECGuiClient object and start a server in the background.
This fixture is scoped to the session, meaning it remains alive for all tests in the session.
We can use this fixture to create a gui object that is used across multiple tests, and
simulate a real-world scenario where the gui is not restarted for each test.
"""
gui = BECGuiClient(gui_id=gui_id)
try:
gui.start(wait=True)
# After the server started, we need to wait until the bec exists in the namespace
qtbot.waitUntil(lambda: hasattr(gui, "bec"), timeout=5000)
yield gui
finally:
gui.kill_server()

View File

@@ -1,5 +1,5 @@
"""
Test module for the gui object within the BEC IPython client.
Test module for the gui object within the BEC IPython client.
"""
from unittest import mock

View File

@@ -136,7 +136,7 @@ def test_async_plotting(qtbot, bec_client_lib, connected_client_gui_obj):
dev.waveform.sim.select_model("GaussianModel")
dev.waveform.sim.params = {"amplitude": 1000, "center": 4000, "sigma": 300}
dev.waveform.async_update.set("add").wait()
dev.waveform.waveform_shape.set(1000).wait()
dev.waveform.waveform_shape.set(10000).wait()
wf = dock.new("wf_dock").new("Waveform")
curve = wf.plot(y_name="waveform")

View File

@@ -9,7 +9,7 @@ from bec_widgets.cli.rpc.rpc_base import RPCReference
def test_rpc_reference_objects(connected_client_gui_obj):
gui = connected_client_gui_obj
dock = gui.window_list[0].new("dock")
dock = gui.window_list[0].new()
plt = dock.new(name="fig", widget="Waveform")
plt.plot(x_name="samx", y_name="bpm4i")

View File

@@ -0,0 +1,82 @@
"""
End-2-End test fixtures for module scoped testing. The fixtures overwrite the default versions used
for the function scoped tests. The fixtures will only be created once for this entire module, meaning
that any test can be used to test user interaction and potential leakage of threads or other resources across
different widgets.
"""
import random
import pytest
from bec_ipython_client import BECIPythonClient
from bec_lib.redis_connector import RedisConnector
from bec_lib.service_config import ServiceConfig
from bec_lib.tests.utils import wait_for_empty_queue
from pytestqt.plugin import QtBot
from bec_widgets.cli.client_utils import BECGuiClient
# pylint: disable=unused-argument
# pylint: disable=redefined-outer-name
@pytest.fixture(scope="module")
def gui_id():
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturbate"""
return f"figure_{random.randint(0,100)}"
@pytest.fixture(scope="module")
def bec_ipython_client_with_demo_config(
bec_redis_fixture, bec_services_config_file_path, bec_servers
):
"""Fixture to create a BECIPythonClient with a demo config."""
config = ServiceConfig(bec_services_config_file_path)
bec = BECIPythonClient(config, RedisConnector, forced=True)
bec.start()
bec.config.load_demo_config()
try:
yield bec
finally:
bec.shutdown()
bec._client._reset_singleton()
@pytest.fixture(scope="module")
def bec_client_lib(bec_ipython_client_with_demo_config):
"""Fixture to create a BECIPythonClient with a demo config."""
bec = bec_ipython_client_with_demo_config
bec.queue.request_queue_reset()
bec.queue.request_scan_continuation()
wait_for_empty_queue(bec)
yield bec
@pytest.fixture(scope="module")
def qtbot_scope_module(qapp, request):
"""
Fixture used to create a QtBot instance for using during testing.
Make sure to call addWidget for each top-level widget you create to ensure
that they are properly closed after the test ends.
"""
result = QtBot(request)
return result
@pytest.fixture(scope="module")
def connected_client_gui_obj(qtbot_scope_module, gui_id, bec_client_lib):
"""
Fixture to create a new BECGuiClient object and start a server in the background.
This fixture is scoped to the session, meaning it remains alive for all tests in the session.
We can use this fixture to create a gui object that is used across multiple tests, and
simulate a real-world scenario where the gui is not restarted for each test.
"""
gui = BECGuiClient(gui_id=gui_id)
try:
gui.start(wait=True)
qtbot_scope_module.waitUntil(lambda: hasattr(gui, "bec"), timeout=5000)
yield gui
finally:
gui.kill_server()

View File

@@ -0,0 +1,667 @@
"""
End-to-end tests single gui instance across the full session.
Each test will use the same gui instance, simulating a real-world scenario where the gui is not
restarted for each test. The interaction is tested through the rpc calls.
Note: wait_for_namespace_created is a utility method that helps to wait for the namespace to be
created in the gui. This is necessary because the rpc calls are asynchronous and the namespace
may not be created immediately after the rpc call is made.
"""
from __future__ import annotations
import random
from typing import TYPE_CHECKING, Any
import numpy as np
import pytest
from bec_widgets.cli.client import BECDockArea
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
PYTEST_TIMEOUT = 50
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.cli import client
from bec_widgets.cli.client_utils import BECGuiClient
# pylint: disable=redefined-outer-name
# pylint: disable=too-many-arguments
# pylint: disable=protected-access
# pylint: disable=unused-variable
def wait_for_namespace_change(
qtbot,
gui: BECGuiClient,
parent_widget: RPCBase | RPCReference,
object_name: str,
widget_gui_id: str,
timeout: float = 10000,
exists: bool = True,
):
"""
Utility method to wait for the namespace to be created in the widget.
Args:
qtbot: The qtbot fixture.
gui: The client_utils.BECGuiClient 'gui' object from the CLI.
parent_widget: The widget that creates a new widget.
object_name: The name of the widget that was created. Must appear as attribute in namespace of parent.
widget_gui_id: The gui_id of the created widget.
timeout: The timeout in milliseconds for the qtbot to wait for changes to appear.
exists: If True, wait for the object to be created. If False, wait for the object to be removed.
"""
# GUI object is not registered in the registry (yet)
if parent_widget is gui:
def check_reference_registered():
# Check server registry
obj = gui._server_registry.get(widget_gui_id, None)
if obj is None:
if not exists:
return True
return False
# CHeck Ipython registry
obj = gui._ipython_registry.get(widget_gui_id, None)
if obj is None:
if not exists:
return True
return False
else:
def check_reference_registered():
# Check server registry
obj = gui._server_registry.get(widget_gui_id, None)
if obj is None:
if not exists:
return True
return False
# CHeck Ipython registry
obj = gui._ipython_registry.get(widget_gui_id, None)
if obj is None:
if not exists:
return True
return False
# Check reference registry
ref = parent_widget._rpc_references.get(widget_gui_id, None)
if exists:
return ref is not None
return ref is None
try:
qtbot.waitUntil(check_reference_registered, timeout=timeout)
except Exception as e:
raise RuntimeError(
f"Timeout waiting for {parent_widget.object_name}.{object_name} to be created."
) from e
def create_widget(
qtbot, gui: BECGuiClient, widget_cls_name: str
) -> tuple[RPCReference, RPCReference]:
"""Utility method to create a widget and wait for the namespaces to be created."""
if hasattr(gui, "dock_area"):
dock_area: client.BECDockArea = gui.dock_area
else:
dock_area: client.BECDockArea = gui.new(name="dock_area")
wait_for_namespace_change(qtbot, gui, gui, dock_area.object_name, dock_area._gui_id)
dock: client.BECDock = dock_area.new()
wait_for_namespace_change(qtbot, gui, dock_area, dock.object_name, dock._gui_id)
widget = dock.new(widget=widget_cls_name)
wait_for_namespace_change(qtbot, gui, dock, widget.object_name, widget._gui_id)
return dock, widget
@pytest.fixture(scope="module")
def random_generator_from_seed(request):
"""Fixture to get a random seed for the following tests."""
seed = request.config.getoption("--random-order-seed").split(":")[-1]
try:
seed = int(seed)
except ValueError: # Should not be required...
seed = 42
rng = random.Random(seed)
yield rng
def maybe_remove_dock_area(qtbot, gui: BECGuiClient, random_int_gen: random.Random):
"""Utility method to remove all dock_ares from gui object, likelihood 50%."""
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.delete("dock_area")
wait_for_namespace_change(
qtbot, gui=gui, parent_widget=gui, object_name=name, widget_gui_id=gui_id, exists=False
)
@pytest.mark.timeout(PYTEST_TIMEOUT)
def test_widgets_e2e_abort_button(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the AbortButton widget."""
gui: BECGuiClient = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.AbortButton)
dock: client.BECDock
widget: client.AbortButton
# No rpc calls to check so far
# Try detaching the dock
dock.detach()
# 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_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, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.BECProgressBar)
dock: client.BECDock
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, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.BECQueue)
dock: client.BECDock
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, dock, widget
dock, 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, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.DapComboBox)
dock: client.BECDock
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, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.DeviceBrowser)
dock: client.BECDock
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_device_combo_box(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the DeviceComboBox widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.DeviceComboBox)
dock: client.BECDock
widget: client.DeviceComboBox
# No rpc calls to check so far, maybe set_device should be exposed
# 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_line_edit(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the DeviceLineEdit widget."""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.DeviceLineEdit)
dock: client.BECDock
widget: client.DeviceLineEdit
# No rpc calls to check so far
# Should probably have a set_device method
# No rpc calls to check so far, maybe set_device should be exposed
# 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, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.Image)
dock: client.BECDock
widget: client.Image
scans = bec.scans
dev = bec.device_manager.devices
# Test rpc calls
img = widget.image(dev.eiger)
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.device_monitor.get_data(
dev.eiger, count=1
) # Get last image from Redis monitor 2D endpoint
assert np.allclose(img.get_data(), last_img)
# 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, dock, widget
# dock, widget = create_widget(qtbot, gui, gui.available_widgets.LogPanel)
# dock: client.BECDock
# 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, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.Minesweeper)
dock: client.BECDock
widget: client.MineSweeper
# 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_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, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.MotorMap)
dock: client.BECDock
widget: client.MotorMap
# 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)
# 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, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.MultiWaveform)
dock: client.BECDock
widget: client.MultiWaveform
# 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
# 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_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, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.PositionIndicator)
dock: client.BECDock
widget: client.PositionIndicator
# TODO check what these rpc calls are supposed to do! Issue created #461
widget.set_value(5)
# 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, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox)
dock: client.BECDock
widget: client.PositionerBox
# 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)
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.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, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.PositionerBox2D)
dock: client.BECDock
widget: client.PositionerBox2D
# 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)
# 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.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, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.PositionerControlLine)
dock: client.BECDock
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)
@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"""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.RingProgressBar)
dock: client.BECDock
widget: client.RingProgressBar
# Test rpc calls
dev = bec.device_manager.devices
scans = bec.scans
# Do a scan
scans.line_scan(dev.samx, -3, 3, steps=50, 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)
@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, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.ScanControl)
dock: client.BECDock
widget: client.ScanControl
# 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_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, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.ScatterWaveform)
dock: client.BECDock
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 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_stop_button(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the StopButton widget"""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.StopButton)
dock: client.BECDock
widget: client.StopButton
# 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_resume_button(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the StopButton widget"""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.ResumeButton)
dock: client.BECDock
widget: client.ResumeButton
# 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_reset_button(qtbot, connected_client_gui_obj, random_generator_from_seed):
"""Test the StopButton widget"""
gui = connected_client_gui_obj
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.ResetButton)
dock: client.BECDock
widget: client.ResetButton
# 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_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, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.TextBox)
dock: client.BECDock
widget: client.TextBox
# 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)
@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, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.Waveform)
dock: client.BECDock
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()
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)
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)

View File

@@ -82,3 +82,52 @@ def test_bec_connector_submit_task(bec_connector):
while not completed:
QApplication.processEvents()
time.sleep(0.1)
def test_bec_connector_change_object_name(bec_connector):
# Store the original object name and RPC register state
original_name = bec_connector.objectName()
original_gui_id = bec_connector.gui_id
# Call the method with a new name
new_name = "new_test_name"
bec_connector.change_object_name(new_name)
# Process events to allow the single shot timer to execute
QApplication.processEvents()
# Verify that the object name was changed correctly
assert bec_connector.objectName() == new_name
assert bec_connector.object_name == new_name
# Verify that the object is registered in the RPC register with the new name
assert bec_connector.rpc_register.object_is_registered(bec_connector)
# Verify that the object with the original name is no longer registered
# The object should still have the same gui_id
assert bec_connector.gui_id == original_gui_id
# Check that no object with the original name exists in the RPC register
all_objects = bec_connector.rpc_register.list_all_connections().values()
assert not any(obj.objectName() == original_name for obj in all_objects)
# Store the current name for the next test
previous_name = bec_connector.objectName()
# Test with spaces and hyphens
name_with_spaces_and_hyphens = "test name-with-hyphens"
expected_name = "test_name_with_hyphens"
bec_connector.change_object_name(name_with_spaces_and_hyphens)
# Process events to allow the single shot timer to execute
QApplication.processEvents()
# Verify that the object name was changed correctly with replacements
assert bec_connector.objectName() == expected_name
assert bec_connector.object_name == expected_name
# Verify that the object is still registered in the RPC register after the second name change
assert bec_connector.rpc_register.object_is_registered(bec_connector)
# Verify that the object with the previous name is no longer registered
all_objects = bec_connector.rpc_register.list_all_connections().values()
assert not any(obj.objectName() == previous_name for obj in all_objects)

View File

@@ -7,7 +7,7 @@ import pytest
from bec_lib.messages import ScanMessage
from bec_lib.serialization import MsgpackSerialization
from bec_widgets.utils.bec_dispatcher import QtRedisConnector
from bec_widgets.utils.bec_dispatcher import QtRedisConnector, QtThreadSafeCallback
@pytest.fixture
@@ -27,6 +27,7 @@ def bec_dispatcher_w_connector(bec_dispatcher, topics_msg_list, send_msg_event):
connector = QtRedisConnector("localhost:1", redis_class_mock)
bec_dispatcher.client.connector = connector
yield bec_dispatcher
connector.shutdown()
dummy_msg = MsgpackSerialization.dumps(ScanMessage(point_id=0, scan_id="0", data={}))
@@ -62,7 +63,6 @@ def test_dispatcher_disconnect_all(bec_dispatcher_w_connector, qtbot, send_msg_e
@pytest.mark.parametrize("topics_msg_list", [(("topic1", dummy_msg), ("topic2", dummy_msg))])
def test_dispatcher_disconnect_one(bec_dispatcher_w_connector, qtbot, send_msg_event):
# test for BEC issue #276
bec_dispatcher = bec_dispatcher_w_connector
cb1 = mock.Mock(spec=[])
cb2 = mock.Mock(spec=[])
@@ -86,12 +86,21 @@ def test_dispatcher_2_cb_same_topic(bec_dispatcher_w_connector, qtbot, send_msg_
cb1 = mock.Mock(spec=[])
cb2 = mock.Mock(spec=[])
num_slots = len(bec_dispatcher._registered_slots)
bec_dispatcher.connect_slot(cb1, "topic1")
bec_dispatcher.connect_slot(cb2, "topic1")
# The redis connector should only subscribe once to the topic
assert len(bec_dispatcher.client.connector._topics_cb) == 1
assert len(bec_dispatcher._slots) == 2
# The the given topic, two callbacks should be registered
assert len(bec_dispatcher.client.connector._topics_cb["topic1"]) == 2
# The dispatcher should have two slots
assert len(bec_dispatcher._registered_slots) == num_slots + 2
bec_dispatcher.disconnect_slot(cb1, "topic1")
assert len(bec_dispatcher._slots) == 1
assert len(bec_dispatcher._registered_slots) == num_slots + 1
send_msg_event.set()
qtbot.wait(10)
@@ -99,9 +108,31 @@ def test_dispatcher_2_cb_same_topic(bec_dispatcher_w_connector, qtbot, send_msg_
cb2.assert_called_once()
@pytest.mark.parametrize("topics_msg_list", [(("topic1", dummy_msg),)])
def test_dispatcher_2_cb_same_topic_same_slot(bec_dispatcher_w_connector, qtbot, send_msg_event):
bec_dispatcher = bec_dispatcher_w_connector
cb1 = mock.Mock(spec=[])
bec_dispatcher.connect_slot(cb1, "topic1")
bec_dispatcher.connect_slot(cb1, "topic1")
assert len(bec_dispatcher.client.connector._topics_cb) == 1
assert (
len(list(filter(lambda slot: slot.cb == cb1, bec_dispatcher._registered_slots.values())))
== 1
)
send_msg_event.set()
qtbot.wait(10)
assert cb1.call_count == 1
bec_dispatcher.disconnect_slot(cb1, "topic1")
assert (
len(list(filter(lambda slot: slot.cb == cb1, bec_dispatcher._registered_slots.values())))
== 0
)
@pytest.mark.parametrize("topics_msg_list", [(("topic1", dummy_msg), ("topic2", dummy_msg))])
def test_dispatcher_2_topic_same_cb(bec_dispatcher_w_connector, qtbot, send_msg_event):
# test for BEC issue #276
bec_dispatcher = bec_dispatcher_w_connector
cb1 = mock.Mock(spec=[])
@@ -114,3 +145,36 @@ def test_dispatcher_2_topic_same_cb(bec_dispatcher_w_connector, qtbot, send_msg_
send_msg_event.set()
qtbot.wait(10)
cb1.assert_called_once()
@pytest.mark.parametrize("topics_msg_list", [(("topic1", dummy_msg), ("topic2", dummy_msg))])
def test_dispatcher_2_topic_same_cb_with_boundmethod(
bec_dispatcher_w_connector, qtbot, send_msg_event
):
bec_dispatcher = bec_dispatcher_w_connector
class MockObject:
def mock_slot(self, msg, metadata):
pass
cb1 = MockObject()
bec_dispatcher.connect_slot(cb1.mock_slot, "topic1", {"metadata": "test"})
bec_dispatcher.connect_slot(cb1.mock_slot, "topic1", {"metadata": "test"})
def _get_slots():
return list(
filter(
lambda slot: slot == QtThreadSafeCallback(cb1.mock_slot, {"metadata": "test"}),
bec_dispatcher._registered_slots.values(),
)
)
assert len(bec_dispatcher.client.connector._topics_cb) == 1
assert len(_get_slots()) == 1
bec_dispatcher.disconnect_slot(cb1.mock_slot, "topic1")
assert len(bec_dispatcher.client.connector._topics_cb) == 0
assert len(_get_slots()) == 0
send_msg_event.set()
qtbot.wait(10)

View File

@@ -89,6 +89,13 @@ def test_undock_and_dock_docks(bec_dock_area, qtbot):
assert len(bec_dock_area.dock_area.tempAreas) == 0
def test_new_dock_raises_for_invalid_name(bec_dock_area):
with pytest.raises(ValueError):
bec_dock_area.new(
name="new", _override_slot_params={"popup_error": False, "raise_error": True}
)
###################################
# Toolbar Actions
###################################

View File

@@ -74,6 +74,7 @@ def test_client_generator_with_black_formatting():
import inspect
import traceback
from functools import reduce
from operator import add
from typing import Literal, Optional
from bec_lib.logger import bec_logger

View File

@@ -64,7 +64,7 @@ def test_launch_window_launch_ui_file_raises_for_qmainwindow(bec_launch_window):
def test_launch_window_launch_default_auto_update(bec_launch_window):
# Mock the auto update selection
bec_launch_window.tile_auto_update.selector.setCurrentText("Default")
bec_launch_window.tiles["auto_update"].selector.setCurrentText("Default")
# Call the method to launch the auto update
res = bec_launch_window._open_auto_update()
@@ -82,11 +82,11 @@ def test_launch_window_launch_plugin_auto_update(bec_launch_window):
class PluginAutoUpdate(AutoUpdates): ...
bec_launch_window.available_auto_updates = {"PluginAutoUpdate": PluginAutoUpdate}
bec_launch_window.tile_auto_update.selector.clear()
bec_launch_window.tile_auto_update.selector.addItems(
bec_launch_window.tiles["auto_update"].selector.clear()
bec_launch_window.tiles["auto_update"].selector.addItems(
list(bec_launch_window.available_auto_updates.keys()) + ["Default"]
)
bec_launch_window.tile_auto_update.selector.setCurrentText("PluginAutoUpdate")
bec_launch_window.tiles["auto_update"].selector.setCurrentText("PluginAutoUpdate")
res = bec_launch_window._open_auto_update()
assert isinstance(res, PluginAutoUpdate)
assert res.windowTitle() == "BEC - PluginAutoUpdate"

View File

@@ -116,7 +116,7 @@ def fill_commponents(components: dict[str, DynamicFormItem]):
def test_griditems_are_correct_class(
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]]
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
):
_, components = metadata_widget
assert isinstance(components["sample_name"], StrMetadataField)
@@ -162,7 +162,7 @@ def test_validation(metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormIt
def test_numbers_clipped_to_limits(
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]]
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
):
widget, components = metadata_widget = metadata_widget
fill_commponents(components)

View File

@@ -1,4 +1,6 @@
import json
from types import SimpleNamespace
from unittest import mock
from unittest.mock import MagicMock
import numpy as np
@@ -19,6 +21,8 @@ from tests.unit_tests.client_mocks import (
from .conftest import create_widget
# pylint: disable=unexpected-keyword-arg
##################################################
# Waveform widget base functionality tests
##################################################
@@ -541,7 +545,14 @@ def test_on_async_readback_add_update(qtbot, mocked_client):
msg = {"signals": {"async_device": {"value": [100, 200], "timestamp": [1001, 1002]}}}
metadata = {"async_update": {"max_shape": [None], "type": "add"}}
wf.on_async_readback(msg, metadata)
cb_info_ret = {"scan_id": wf.scan_id}
def ret_sender():
return SimpleNamespace(cb_info={"scan_id": wf.scan_id})
with mock.patch.object(wf, "sender", side_effect=ret_sender):
wf.on_async_readback(msg, metadata, _override_slot_params={"verify_sender": False})
x_data, y_data = c.get_data()
assert len(x_data) == 5
@@ -553,7 +564,8 @@ def test_on_async_readback_add_update(qtbot, mocked_client):
# instruction='replace'
msg2 = {"signals": {"async_device": {"value": [999], "timestamp": [555]}}}
metadata2 = {"async_update": {"max_shape": [None], "type": "replace"}}
wf.on_async_readback(msg2, metadata2)
with mock.patch.object(wf, "sender", side_effect=ret_sender):
wf.on_async_readback(msg2, metadata2, _override_slot_params={"verify_sender": False})
x_data2, y_data2 = c.get_data()
np.testing.assert_array_equal(x_data2, [0])
@@ -568,7 +580,8 @@ def test_on_async_readback_add_update(qtbot, mocked_client):
metadata = {
"async_update": {"max_shape": [None, waveform_shape], "index": 0, "type": "add_slice"}
}
wf.on_async_readback(msg, metadata)
with mock.patch.object(wf, "sender", side_effect=ret_sender):
wf.on_async_readback(msg, metadata, _override_slot_params={"verify_sender": False})
# Old data should be deleted since the slice_index did not match
x_data, y_data = c.get_data()
@@ -595,7 +608,8 @@ def test_on_async_readback_add_update(qtbot, mocked_client):
metadata = {
"async_update": {"max_shape": [None, waveform_shape], "index": 0, "type": "add_slice"}
}
wf.on_async_readback(msg, metadata)
with mock.patch.object(wf, "sender", side_effect=ret_sender):
wf.on_async_readback(msg, metadata, _override_slot_params={"verify_sender": False})
x_data, y_data = c.get_data()
assert len(y_data) == waveform_shape
assert len(x_data) == waveform_shape
@@ -616,7 +630,8 @@ def test_on_async_readback_add_update(qtbot, mocked_client):
}
}
metadata = {"async_update": {"type": "replace"}}
wf.on_async_readback(msg, metadata)
with mock.patch.object(wf, "sender", side_effect=ret_sender):
wf.on_async_readback(msg, metadata, _override_slot_params={"verify_sender": False})
x_data, y_data = c.get_data()
assert np.array_equal(y_data, np.array(range(waveform_shape)))