1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-08 09:47:52 +02:00

Compare commits

...

87 Commits

Author SHA1 Message Date
semantic-release
5c11fde0a9 0.72.0
Automatically generated by python-semantic-release
2024-06-24 11:47:52 +00:00
4ca1efeeb8 feat(connector): added threadpool wrapper 2024-06-24 13:41:10 +02:00
aa7ce2ea27 tests(status_box_test): temporary disabled tests for status_box due to high rate of failures 2024-06-24 13:15:59 +02:00
semantic-release
174f0cdcb6 0.71.1
Automatically generated by python-semantic-release
2024-06-23 15:30:25 +00:00
860517a321 fix: don't print exception if the auto-update module cannot be found in plugins 2024-06-23 17:23:39 +02:00
semantic-release
66daae6d9e 0.71.0
Automatically generated by python-semantic-release
2024-06-23 12:33:09 +00:00
83001a0d82 test(scan_control):e2e tests added 2024-06-23 12:53:15 +02:00
1b7921a7f2 doc(scan_control): docs added 2024-06-23 12:12:22 +02:00
8badb6adc1 fix(cleanup): cleanup added to device_input widgets and scan_control 2024-06-23 12:12:20 +02:00
37682e7b8a fix(scan_group_box): added row counter based on widgets 2024-06-23 12:11:30 +02:00
56e74a0e7d test(scan_control): tests added 2024-06-23 12:11:30 +02:00
ec4574ed5c fix(scan_control): added default min limit for args bundle if specified 2024-06-23 12:11:30 +02:00
21d20e0fc7 fix(device_line_edit):SizePolicy fixed for 100 horizontal 2024-06-23 12:11:30 +02:00
7ce3a83c58 fix(scan_control): argbox delete later added to prevent overlapping gui if scan changed 2024-06-23 12:11:30 +02:00
6dff1879c4 fix(scan_control): only scans with defined gui_config are allowed 2024-06-23 12:11:30 +02:00
c09644b29d tests WIP 2024-06-23 12:11:30 +02:00
d8cf44134c feat(scan_group_box): scan box for args and kwargs separated from ScanControlGUI code 2024-06-23 12:11:30 +02:00
ca856384f3 fix(WidgetIO): find handlers within base classes 2024-06-23 12:11:30 +02:00
4e2c9df6a4 refactor(device_line_edit): renamed default_device to default 2024-06-23 12:11:27 +02:00
8b822e0fa8 fix(scan_control): adapted widget to scan BEC gui config 2024-06-23 12:10:33 +02:00
67d398caf7 fix(scan_control): scan_control.py combatible with the newest BEC versions, test disabled 2024-06-23 12:10:33 +02:00
semantic-release
c2c27f8279 0.70.0
Automatically generated by python-semantic-release
2024-06-21 17:22:25 +00:00
50b3422528 fix(bec-desiger+plugins): imports fixed, PYSIDE6 check to not enable run plugins with pyqt6 2024-06-21 18:34:20 +02:00
4639eee0b9 feat(bec-designer): automatic plugin discovery 2024-06-21 18:34:20 +02:00
b4b27aea3d feat(device_line_edit): plugin added to bec-designer 2024-06-21 17:40:53 +02:00
e483b282db feat(device_combobox): plugin added to bec-designer 2024-06-21 17:30:02 +02:00
36391db607 feat: added entry point for bec-designer 2024-06-21 16:46:04 +02:00
5362334ff3 feat(utils/bec-designer): added startup script to launched QtDesigner compatible with conda environments 2024-06-21 16:46:04 +02:00
fdf11d8147 docs: fix typo in link 2024-06-21 14:44:50 +02:00
semantic-release
204f653b72 0.69.0
Automatically generated by python-semantic-release
2024-06-21 12:25:10 +00:00
48ae950d57 feat(widgets): added vscode widget 2024-06-21 13:47:05 +02:00
925c893f3f fix(generate_cli): fixed rpc generate for classes without user access; closes #226 2024-06-21 13:47:05 +02:00
semantic-release
b54423a151 0.68.0
Automatically generated by python-semantic-release
2024-06-21 11:35:42 +00:00
ce374163ca fix: ignore GUI server output (any output will go to log file)
If a logger is given to log `_start_log_process`, the server stdout and
stderr streams will be redirected as log entries with levels DEBUG or ERROR
in their parent process
2024-06-21 12:32:59 +02:00
3644f344da feat: properly handle SIGINT (ctrl-c) in BEC GUI server -> calls qapplication.quit() 2024-06-21 12:32:59 +02:00
d1266a1ce1 feat: bec-gui-server: redirect stdout and stderr (if any) as proper debug and error log entries 2024-06-21 12:32:59 +02:00
f7d0b0768a fix: do not create 'BECClient' logger when instantiating BECDispatcher 2024-06-21 12:32:59 +02:00
630616ec72 feat: add logger for BEC GUI server 2024-06-21 12:32:59 +02:00
semantic-release
7f7bef7581 0.67.0
Automatically generated by python-semantic-release
2024-06-21 08:42:15 +00:00
d2f2b206bb refactor: Change inheritance to QTreeWidget from QWidget 2024-06-21 10:27:15 +02:00
6fa1c06053 docs: add widget to documentation 2024-06-21 10:26:14 +02:00
5d4ca816cd test: add test suite for bec_status_box and status_item 2024-06-20 18:08:26 +02:00
443b6c1d7b feat: introduce BECStatusBox Widget 2024-06-20 17:45:44 +02:00
505a5ec833 Update file requirements.txt 2024-06-20 17:28:53 +02:00
semantic-release
3a7289bf5e 0.66.1
Automatically generated by python-semantic-release
2024-06-20 14:49:13 +00:00
2718bc6247 fix: fixed shutdown for pyside 2024-06-20 16:31:16 +02:00
semantic-release
515d2651bf 0.66.0
Automatically generated by python-semantic-release
2024-06-20 12:19:39 +00:00
ef25f56380 feat(rpc): discover widgets automatically 2024-06-20 14:04:30 +02:00
semantic-release
5b280ccc1e 0.65.2
Automatically generated by python-semantic-release
2024-06-20 12:04:07 +00:00
cbbd23aa33 fix(pyqt): webengine must be imported before qcoreapplication 2024-06-20 13:50:44 +02:00
semantic-release
860d0ad014 0.65.1
Automatically generated by python-semantic-release
2024-06-20 08:15:53 +00:00
fa344a5799 fix: prevent segfault by closing the QCoreApplication, if any 2024-06-20 10:05:23 +02:00
semantic-release
3919de5bd5 0.65.0
Automatically generated by python-semantic-release
2024-06-20 07:36:53 +00:00
1a0a98a453 test(device_input): tests added 2024-06-20 09:25:43 +02:00
d79f7e9ccd fix(device_input_base): bug with setting config and overwriting default device and filter 2024-06-20 09:25:43 +02:00
50e41ff261 feat(device_input): DeviceLineEdit with QCompleter added 2024-06-20 09:25:43 +02:00
430b282039 feat(device_combobox): DeviceInputBase and DeviceComboBox added 2024-06-20 09:25:43 +02:00
semantic-release
17133771bb 0.64.2
Automatically generated by python-semantic-release
2024-06-19 14:50:20 +00:00
e5a7d47b21 fix(client_utils): added close rpc command to shutdown of gui from bec_ipython_client 2024-06-19 16:36:05 +02:00
semantic-release
71ec61e27b 0.64.1
Automatically generated by python-semantic-release
2024-06-19 11:54:08 +00:00
b3575eb068 test: moved rpc_classes test 2024-06-19 13:38:46 +02:00
216511b951 fix(widgets): removed widget module import of sub widgets 2024-06-19 13:38:46 +02:00
6dabbf874f refactor(utils): moved get_rpc_widgets to plugin_utils 2024-06-19 13:38:46 +02:00
semantic-release
d5aad06c88 0.64.0
Automatically generated by python-semantic-release
2024-06-19 11:35:37 +00:00
5d6672069e fix(plot_base): font size is set with setScale which is scaling the whole legend window 2024-06-19 13:26:10 +02:00
140ad83380 test: add tests 2024-06-19 13:26:10 +02:00
ea805d1362 feat: add option to change size of the fonts 2024-06-19 13:26:10 +02:00
9e16f2faf9 docs: fix links in developer section 2024-06-14 14:34:26 +02:00
2a36d9364f docs: refactor developer section, add widget tutorial 2024-06-14 14:24:38 +02:00
27426ce7a5 ci: add job optional dependency check 2024-06-14 11:47:42 +02:00
semantic-release
69adadd6d7 0.63.2
Automatically generated by python-semantic-release
2024-06-14 08:14:22 +00:00
6f96498de6 fix: do not import "server" in client, prevents from having trouble with QApplication creation order
Like with QtWebEngine
2024-06-13 15:14:30 +02:00
836b6e64f6 Reapply "feat: implement non-polling, interruptible waiting of gui instruction response with timeout"
This reverts commit fe04dd80e5.
2024-06-13 15:14:30 +02:00
semantic-release
fab7dd7eec 0.63.1
Automatically generated by python-semantic-release
2024-06-13 13:12:54 +00:00
9263f8ef5c fix: just terminate the remote process in close() instead of communicating
The proper finalization sequence will be executed by the remote process
on SIGTERM
2024-06-13 14:56:21 +02:00
semantic-release
658728efef 0.63.0
Automatically generated by python-semantic-release
2024-06-13 12:47:57 +00:00
6b8432f5b2 refactor: add pydantic config, add change_theme 2024-06-13 14:08:22 +02:00
bc709c4184 docs: add documentation 2024-06-13 08:14:50 +02:00
b49462abeb test: add test for text box 2024-06-13 08:14:50 +02:00
d9d4e3c9bf feat: add textbox widget 2024-06-13 08:08:46 +02:00
fe04dd80e5 Revert "feat: implement non-polling, interruptible waiting of gui instruction response with timeout"
This reverts commit abc6caa2d0
2024-06-12 17:19:08 +02:00
semantic-release
718950cf0d 0.62.0
Automatically generated by python-semantic-release
2024-06-12 10:01:48 +00:00
17a0068757 doc: add documentation about creating custom GUI applications embedding BEC Widgets 2024-06-12 11:54:47 +02:00
abc6caa2d0 feat: implement non-polling, interruptible waiting of gui instruction response with timeout 2024-06-12 11:43:08 +02:00
semantic-release
99fb82561b 0.61.0
Automatically generated by python-semantic-release
2024-06-12 06:27:41 +00:00
61ba08d0b8 feat(widgets/stop_button): General stop button added 2024-06-12 01:11:06 +02:00
40b5688158 refactor: improve labe of auto_update script 2024-06-10 08:27:32 +02:00
90 changed files with 4299 additions and 862 deletions

View File

@@ -22,6 +22,13 @@ workflow:
include:
- template: Security/Secret-Detection.gitlab-ci.yml
- project: "bec/awi_utils"
file: "/templates/check-packages-job.yml"
inputs:
stage: test
path: "."
pytest_args: "-v --random-order tests/"
exclude_packages: ""
# different stages in the pipeline
stages:
@@ -32,21 +39,21 @@ stages:
- Deploy
.install-qt-webengine-deps: &install-qt-webengine-deps
- apt-get -y install libnss3 libxdamage1 libasound2 libatomic1 libxcursor1
- export QTWEBENGINE_DISABLE_SANDBOX=1
- apt-get -y install libnss3 libxdamage1 libasound2 libatomic1 libxcursor1
- export QTWEBENGINE_DISABLE_SANDBOX=1
.clone-repos: &clone-repos
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
.install-os-packages: &install-os-packages
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- *install-qt-webengine-deps
- apt-get update
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
- *install-qt-webengine-deps
before_script:
- if [[ "$CI_PROJECT_PATH" != "bec/bec_widgets" ]]; then
- if [[ "$CI_PROJECT_PATH" != "bec/bec_widgets" ]]; then
echo -e "\033[35;1m Using branch $CHILD_PIPELINE_BRANCH of BEC Widgets \033[0;m";
test -d bec_widgets || git clone --branch $CHILD_PIPELINE_BRANCH https://gitlab.psi.ch/bec/bec_widgets.git; cd bec_widgets;
fi
@@ -92,10 +99,10 @@ pylint-check:
- git fetch origin $CI_MERGE_REQUEST_TARGET_BRANCH_NAME
# Identify changed Python files
- if [ "$CI_PIPELINE_SOURCE" == "merge_request_event" ]; then
TARGET_BRANCH_COMMIT_SHA=$(git rev-parse origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME);
CHANGED_FILES=$(git diff --name-only $TARGET_BRANCH_COMMIT_SHA HEAD | grep '\.py$' || true);
TARGET_BRANCH_COMMIT_SHA=$(git rev-parse origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME);
CHANGED_FILES=$(git diff --name-only $TARGET_BRANCH_COMMIT_SHA HEAD | grep '\.py$' || true);
else
CHANGED_FILES=$(git diff --name-only $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA | grep '\.py$' || true);
CHANGED_FILES=$(git diff --name-only $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA | grep '\.py$' || true);
fi
- if [ -z "$CHANGED_FILES" ]; then echo "No Python files changed."; exit 0; fi
@@ -120,7 +127,7 @@ tests:
stage: test
needs: []
variables:
QT_QPA_PLATFORM: "offscreen"
QT_QPA_PLATFORM: "offscreen"
script:
- *clone-repos
- *install-os-packages
@@ -141,21 +148,21 @@ tests:
test-matrix:
parallel:
matrix:
- PYTHON_VERSION:
- "3.10"
- "3.11"
- "3.12"
QT_PCKG:
- "pyside6"
- "pyqt5"
- "pyqt6"
- PYTHON_VERSION:
- "3.10"
- "3.11"
- "3.12"
QT_PCKG:
- "pyside6"
- "pyqt5"
- "pyqt6"
stage: AdditionalTests
needs: []
variables:
QT_QPA_PLATFORM: "offscreen"
PYTHON_VERSION: ""
QT_PCKG: ""
QT_QPA_PLATFORM: "offscreen"
PYTHON_VERSION: ""
QT_PCKG: ""
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:$PYTHON_VERSION
script:
- *clone-repos
@@ -226,7 +233,7 @@ semver:
- pip install python-semantic-release==9.* wheel build twine
- export GL_TOKEN=$CI_UPDATES
- semantic-release -vv version
# check if any artifacts were created
- if [ ! -d dist ]; then echo No release will be made; exit 0; fi
- twine upload dist/* -u __token__ -p $CI_PYPI_TOKEN --skip-existing
@@ -242,7 +249,7 @@ pages:
variables:
TARGET_BRANCH: $CI_COMMIT_REF_NAME
rules:
- if: '$CI_COMMIT_TAG != null'
- if: "$CI_COMMIT_TAG != null"
variables:
TARGET_BRANCH: $CI_COMMIT_TAG
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'

View File

@@ -1,160 +1,153 @@
# CHANGELOG
## v0.60.0 (2024-06-08)
### Ci
* ci: added git fetch for target branch ([`fc4f4f8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fc4f4f81ad1be99cf5112f2188a46c5bed2679ee))
* ci: fixed pylint-check ([`6b1d582`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6b1d5827d6599f06a3acd316060a8d25f0686d54))
* ci: cleanup ([`11173b9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/11173b9c0a7dc4b36e35962042e5b86407da49f1))
## v0.72.0 (2024-06-24)
### Feature
* feat: added isort to bw-generate-cli ([`f0391f5`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f0391f59c9eb0a51b693fccfe2e399e869d35dda))
* feat(connector): added threadpool wrapper ([`4ca1efe`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4ca1efeeb8955604069f7b98374c7f82e1a8da67))
* feat: added entry point for bw-generate-cli ([`1c7f491`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1c7f4912ce5998e666276969bf4af8656d619a91))
### Unknown
* feat(cli): auto-discover rpc-enabled widgets ([`df1be10`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/df1be10057a5e85a3f35bef1c1b27366b6727276))
* tests(status_box_test): temporary disabled tests for status_box due to high rate of failures ([`aa7ce2e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/aa7ce2ea27bb9564d4f5104bbff30725b8656453))
## v0.71.1 (2024-06-23)
### Fix
* fix: removed BECConnector from rpc client interface ([`6428e38`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6428e38ab94c15a2c904e75cc6404bb6d0394e04))
* fix: don't print exception if the auto-update module cannot be found in plugins ([`860517a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/860517a3211075d1f6e2af7fa6a567b9e0cd77f3))
* fix: added bec_ipython_client as dependency; needed for jupyter widget ([`006a089`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/006a0894b85cba3b2773737ed6fe3e92c81cdee0))
## v0.71.0 (2024-06-23)
* fix(BECFigure): removed duplicated user access for plot ([`954c576`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/954c576131f7deac669ddf9f51eeb1d41b6f92b7))
### Feature
* fix(bec_connector): field validator should be a classmethod ([`867720a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/867720a897b6713bd0df9af71ffdd11a6a380f7d))
* feat(scan_group_box): scan box for args and kwargs separated from ScanControlGUI code ([`d8cf441`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d8cf44134c30063e586771f9068947fef7a306d1))
### Fix
* fix(cleanup): cleanup added to device_input widgets and scan_control ([`8badb6a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8badb6adc1d003dbf0b2b1a800c34821f3fc9aa3))
* fix(scan_group_box): added row counter based on widgets ([`37682e7`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/37682e7b8a6ede38308880d285e41a948d6fe831))
* fix(scan_control): added default min limit for args bundle if specified ([`ec4574e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ec4574ed5c2c85ea6fbbe2b98f162a8e1220653b))
* fix(scan_control): argbox delete later added to prevent overlapping gui if scan changed ([`7ce3a83`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7ce3a83c58cb69c2bf7cb7f4eaba7e6a2ca6c546))
* fix(scan_control): only scans with defined gui_config are allowed ([`6dff187`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6dff1879c4178df0f8ebfd35101acdebb028d572))
* fix(WidgetIO): find handlers within base classes ([`ca85638`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ca856384f380dabf28d43f1cd48511af784c035b))
* fix(scan_control): adapted widget to scan BEC gui config ([`8b822e0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8b822e0fa8e28f080b9a4bf81948a7280a4c07bf))
* fix(scan_control): scan_control.py combatible with the newest BEC versions, test disabled ([`67d398c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/67d398caf74e08ab25a70cc5d85a5f0c2de8212d))
### Refactor
* refactor: minor cleanup ([`3adf6cf`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3adf6cfd586355c8b8ce7fdc9722f868e22287c5))
* refactor: disabled pylint for auto-gen client ([`b15816c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b15816ca9fd3e4ae87cca5fcfe029b4dfca570ca))
* refactor(isort): added bec_widgets as known first party package ([`9c5a471`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9c5a471234ed2928e4527b079436db2a807c5f6f))
* refactor(dock): parent_dock_area changed to orig_area (native for pyqtgraph) ([`2b40602`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2b40602bdc593ece0447ec926c2100414bd5cf67))
* refactor(device_line_edit): renamed default_device to default ([`4e2c9df`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4e2c9df6a4979d935285fd7eba17fd7fd455a35c))
### Test
* test: added missing pylint statement to header ([`f662985`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f6629852ebc2b4ee239fa560cc310a5ae2627cf7))
* test(scan_control): tests added ([`56e74a0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/56e74a0e7da72d18e89bc30d1896dbf9ef97cd6b))
### Unknown
## v0.59.1 (2024-06-07)
* test(scan_control):e2e tests added ([`83001a0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/83001a0d8267e1320549b07032857dcf46ecd293))
### Fix
* doc(scan_control): docs added ([`1b7921a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1b7921a7f2e3bcc846219a2a7aa0de0fd27bb8fe))
* fix(curve): set_color_map_z typo fixed in user access ([`e7838b0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e7838b0f2fc23b0a232ed7d68fbd7f3493a91b9e))
* fix(device_line_edit):SizePolicy fixed for 100 horizontal ([`21d20e0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/21d20e0fc78e9a3853abe802733388cce119ce20))
* tests WIP ([`c09644b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c09644b29ddb291c91dc58bcd6ebf02ff45cab36))
## v0.59.0 (2024-06-07)
### Build
* build: added webengine dependency ([`d56c549`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d56c5493cd28f379d04a79d90b01c73b0760da1b))
### Ci
* ci: merged additional tests to parallel matrix job ([`178fe4d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/178fe4d2da3a959f7cd90e7ea0f47314dc1ef4ed))
* ci: added webengine dependencies ([`2d79ef8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2d79ef8fe5e52c61f4a78782770377cd6b41958b))
## v0.70.0 (2024-06-21)
### Documentation
* docs: added website docs ([`cf6e5a4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cf6e5a40fc8320e9898a446a5bf14b77e94ef013))
* docs: fix typo in link ([`fdf11d8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fdf11d8147750e379af9b17792761a267b49ae53))
### Feature
* feat(widget): added simple website widget with rpc ([`64abd67`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/64abd67b9b416bff9c89880b248d6e8639aa1e70))
* feat(bec-designer): automatic plugin discovery ([`4639eee`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4639eee0b975ebd7a946e0e290449f5b88c372eb))
* feat(device_line_edit): plugin added to bec-designer ([`b4b27ae`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b4b27aea3d8c08fa3d5d5514c69dbde32721d1dc))
## v0.58.1 (2024-06-07)
* feat(device_combobox): plugin added to bec-designer ([`e483b28`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e483b282db20a81182b87938ea172654092419b5))
* feat: added entry point for bec-designer ([`36391db`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/36391db60735d57b371211791ddf8d3d00cebcf1))
* feat(utils/bec-designer): added startup script to launched QtDesigner compatible with conda environments ([`5362334`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5362334ff3b07fc83653323a084a4b6946bade96))
### Fix
* fix(dock): new dock can be detached upon creation ([`02a2608`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/02a26086c4540127a11c235cba30afc4fd712007))
* fix(bec-desiger+plugins): imports fixed, PYSIDE6 check to not enable run plugins with pyqt6 ([`50b3422`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/50b3422528d46d74317e8c903b6286e868ab7fe0))
## v0.58.0 (2024-06-07)
## v0.69.0 (2024-06-21)
### Feature
* feat(utils.colors): general color validators ([`3094632`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/30946321348abc349fb4003dc39d0232dc19606c))
* feat(widgets): added vscode widget ([`48ae950`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/48ae950d57b454307ce409e2511f7b7adf3cfc6b))
### Fix
* fix: bar colormap dynamic setting ([`67fd5e8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/67fd5e8581f60fe64027ac57f1f12cefa4d28343))
* fix(generate_cli): fixed rpc generate for classes without user access; closes #226 ([`925c893`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/925c893f3ff4337fc8b4d237c8ffc19a597b0996))
* fix: formatting isort ([`bf699ec`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/bf699ec1fbe2aacd31854e84fb0438c336840fcf))
## v0.68.0 (2024-06-21)
* fix(curve): 2D scatter updated if color_map_z is changed ([`6985ff0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6985ff0fcef9791b53198206ec8cbccd1d65ef99))
### Feature
* fix(curve): color_map_z setting works ([`33f7be4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/33f7be42c512402dab3fdd9781a8234e3ec5f4ba))
* feat: properly handle SIGINT (ctrl-c) in BEC GUI server -> calls qapplication.quit() ([`3644f34`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3644f344da2df674bc0d5740c376a86b9d0dfe95))
### Test
* feat: bec-gui-server: redirect stdout and stderr (if any) as proper debug and error log entries ([`d1266a1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d1266a1ce148ff89557a039e3a182a87a3948f49))
* test(color): validation tests added ([`c0ddece`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c0ddeceeeabacbf33019a8f24b18821926dc17ac))
* feat: add logger for BEC GUI server ([`630616e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/630616ec729f60aa0b4d17a9e0379f9c6198eb96))
### Fix
## v0.57.7 (2024-06-07)
* fix: ignore GUI server output (any output will go to log file)
If a logger is given to log `_start_log_process`, the server stdout and
stderr streams will be redirected as log entries with levels DEBUG or ERROR
in their parent process ([`ce37416`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ce374163cab87a92847409051739777bc505a77b))
* fix: do not create 'BECClient' logger when instantiating BECDispatcher ([`f7d0b07`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f7d0b0768ace42a33e2556bb33611d4f02e5a6d9))
## v0.67.0 (2024-06-21)
### Documentation
* docs: added schema of BECDockArea and BECFigure ([`828067f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/828067f486a905eb4678538df58e2bdd6c770de1))
* docs: add widget to documentation ([`6fa1c06`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6fa1c06053131dabd084bb3cf13c853b5d3ce833))
### Fix
### Feature
* fix: add model_config to pydantic models to allow runtime checks after creation ([`ca5e8d2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ca5e8d2fbbffbf221cc5472710fef81a33ee29d6))
## v0.57.6 (2024-06-06)
### Fix
* fix(bar): docstrings extended ([`edb1775`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/edb1775967c3ff0723d0edad2b764f1ffc832b7c))
## v0.57.5 (2024-06-06)
### Documentation
* docs(figure): docs adjusted to be compatible with new signature ([`c037b87`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c037b87675af91b26e8c7c60e76622d4ed4cf5d5))
### Fix
* fix(waveform): added .plot method with the same signature as BECFigure.plot ([`8479caf`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8479caf53a7325788ca264e5bd9aee01f1d4c5a0))
* fix(plot_base): .plot removed from plot_base.py, because there is no use case for it ([`82e2c89`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/82e2c898d2e26f786b2d481f85c647472675e75b))
* feat: introduce BECStatusBox Widget ([`443b6c1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/443b6c1d7b02c772fda02e2d1eefd5bd40249e0c))
### Refactor
* refactor(figure): logic for .add_image and .image consolidated; logic for .add_plot and .plot consolidated ([`52bc322`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/52bc322b2b8d3ef92ff3480e61bddaf32464f976))
* refactor: Change inheritance to QTreeWidget from QWidget ([`d2f2b20`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d2f2b206bb0eab60b8a9b0d0ac60a6b7887fa6fb))
### Test
## v0.57.4 (2024-06-06)
* test: add test suite for bec_status_box and status_item ([`5d4ca81`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5d4ca816cdedec4c88aba9eb326f85392504ea1c))
### Unknown
* Update file requirements.txt ([`505a5ec`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/505a5ec8334ff4422913b3a7b79d39bcb42ad535))
## v0.66.1 (2024-06-20)
### Fix
* fix(docks): set_title do update dock internal _name now ([`15cbc21`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/15cbc21e5bb3cf85f5822d44a2b3665b5aa2f346))
* fix: fixed shutdown for pyside ([`2718bc6`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2718bc624731301756df524d0d5beef6cb1c1430))
* fix(docks): docks widget_list adn dockarea panels return values fixed ([`ffae5ee`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ffae5ee54e6b43da660131092452adff195ba4fb))
## v0.66.0 (2024-06-20)
### Feature
## v0.57.3 (2024-06-06)
* feat(rpc): discover widgets automatically ([`ef25f56`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ef25f5638032f931ceb292540ada618508bb2aed))
### Documentation
* docs(bar): docs updated ([`4be0d14`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4be0d14b7445c2322c2aef86257db168a841265c))
## v0.65.2 (2024-06-20)
### Fix
* fix(ring): automatic updates are disabled uf user specify updates manually with .set_update; 'scan_progres' do not reset number of rings ([`e883dba`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e883dbad814dbcc0a19c341041c6d836e58a5918))
* fix(pyqt): webengine must be imported before qcoreapplication ([`cbbd23a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cbbd23aa33095141e4c265719d176c4aa8c25996))
* fix(ring): enable_auto_updates(True) do not reset properties of already setup bars ([`a2abad3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a2abad344f4c0039516eb60a825afb6822c5b19a))
* fix(ring): set_min_max accepts floats ([`d44b1cf`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d44b1cf8b107cf02deedd9154b77d01c7f9ed05d))
## v0.65.1 (2024-06-20)

View File

@@ -118,7 +118,7 @@ class AutoUpdates:
if not dev_y:
return
fig.clear_all()
plt = fig.plot(x_name=dev_x, y_name=dev_y)
plt = fig.plot(x_name=dev_x, y_name=dev_y, label=f"Scan {info.scan_number} - {dev_y}")
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
def simple_grid_scan(self, info: ScanInfo) -> None:
@@ -132,7 +132,9 @@ class AutoUpdates:
dev_y = info.scan_report_devices[1]
dev_z = self.get_selected_device(info.monitored_devices, self.gui.selected_device)
fig.clear_all()
plt = fig.plot(x_name=dev_x, y_name=dev_y, z_name=dev_z, label=f"Scan {info.scan_number}")
plt = fig.plot(
x_name=dev_x, y_name=dev_y, z_name=dev_z, label=f"Scan {info.scan_number} - {dev_z}"
)
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
def best_effort(self, info: ScanInfo) -> None:
@@ -147,5 +149,5 @@ class AutoUpdates:
if not dev_y:
return
fig.clear_all()
plt = fig.plot(x_name=dev_x, y_name=dev_y, label=f"Scan {info.scan_number}")
plt = fig.plot(x_name=dev_x, y_name=dev_y, label=f"Scan {info.scan_number} - {dev_y}")
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)

View File

@@ -13,10 +13,14 @@ class Widgets(str, enum.Enum):
Enum for the available widgets.
"""
BECStatusBox = "BECStatusBox"
BECDock = "BECDock"
BECDockArea = "BECDockArea"
BECFigure = "BECFigure"
ScanControl = "ScanControl"
SpiralProgressBar = "SpiralProgressBar"
TextBox = "TextBox"
VSCodeEditor = "VSCodeEditor"
WebsiteWidget = "WebsiteWidget"
@@ -1043,33 +1047,37 @@ class BECImageShow(RPCBase):
- y_scale: Literal["linear", "log"]
- x_lim: tuple
- y_lim: tuple
- legend_label_size: int
"""
@rpc_call
def set_title(self, title: "str"):
def set_title(self, title: "str", size: "int" = None):
"""
Set the title of the plot widget.
Args:
title(str): Title of the plot widget.
size(int): Font size of the title.
"""
@rpc_call
def set_x_label(self, label: "str"):
def set_x_label(self, label: "str", size: "int" = None):
"""
Set the label of the x-axis.
Args:
label(str): Label of the x-axis.
size(int): Font size of the label.
"""
@rpc_call
def set_y_label(self, label: "str"):
def set_y_label(self, label: "str", size: "int" = None):
"""
Set the label of the y-axis.
Args:
label(str): Label of the y-axis.
size(int): Font size of the label.
"""
@rpc_call
@@ -1267,33 +1275,37 @@ class BECPlotBase(RPCBase):
- y_scale: Literal["linear", "log"]
- x_lim: tuple
- y_lim: tuple
- legend_label_size: int
"""
@rpc_call
def set_title(self, title: "str"):
def set_title(self, title: "str", size: "int" = None):
"""
Set the title of the plot widget.
Args:
title(str): Title of the plot widget.
size(int): Font size of the title.
"""
@rpc_call
def set_x_label(self, label: "str"):
def set_x_label(self, label: "str", size: "int" = None):
"""
Set the label of the x-axis.
Args:
label(str): Label of the x-axis.
size(int): Font size of the label.
"""
@rpc_call
def set_y_label(self, label: "str"):
def set_y_label(self, label: "str", size: "int" = None):
"""
Set the label of the y-axis.
Args:
label(str): Label of the y-axis.
size(int): Font size of the label.
"""
@rpc_call
@@ -1369,6 +1381,33 @@ class BECPlotBase(RPCBase):
Remove the plot widget from the figure.
"""
@rpc_call
def set_legend_label_size(self, size: "int" = None):
"""
Set the font size of the legend.
Args:
size(int): Font size of the legend.
"""
class BECStatusBox(RPCBase):
@property
@rpc_call
def config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
class BECWaveform(RPCBase):
@property
@@ -1515,33 +1554,37 @@ class BECWaveform(RPCBase):
- y_scale: Literal["linear", "log"]
- x_lim: tuple
- y_lim: tuple
- legend_label_size: int
"""
@rpc_call
def set_title(self, title: "str"):
def set_title(self, title: "str", size: "int" = None):
"""
Set the title of the plot widget.
Args:
title(str): Title of the plot widget.
size(int): Font size of the title.
"""
@rpc_call
def set_x_label(self, label: "str"):
def set_x_label(self, label: "str", size: "int" = None):
"""
Set the label of the x-axis.
Args:
label(str): Label of the x-axis.
size(int): Font size of the label.
"""
@rpc_call
def set_y_label(self, label: "str"):
def set_y_label(self, label: "str", size: "int" = None):
"""
Set the label of the y-axis.
Args:
label(str): Label of the y-axis.
size(int): Font size of the label.
"""
@rpc_call
@@ -1617,6 +1660,69 @@ class BECWaveform(RPCBase):
Remove the plot widget from the figure.
"""
@rpc_call
def set_legend_label_size(self, size: "int" = None):
"""
Set the font size of the legend.
Args:
size(int): Font size of the legend.
"""
class DeviceComboBox(RPCBase):
@property
@rpc_call
def config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
class DeviceInputBase(RPCBase):
@property
@rpc_call
def config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
class DeviceLineEdit(RPCBase):
@property
@rpc_call
def config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
class Ring(RPCBase):
@rpc_call
@@ -1718,6 +1824,24 @@ class Ring(RPCBase):
"""
class ScanControl(RPCBase):
@property
@rpc_call
def config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
class SpiralProgressBar(RPCBase):
@rpc_call
def get_all_rpc(self) -> "dict":
@@ -1897,6 +2021,57 @@ class SpiralProgressBar(RPCBase):
"""
class StopButton(RPCBase):
@property
@rpc_call
def config_dict(self) -> "dict":
"""
Get the configuration of the widget.
Returns:
dict: The configuration of the widget.
"""
@rpc_call
def get_all_rpc(self) -> "dict":
"""
Get all registered RPC objects.
"""
class TextBox(RPCBase):
@rpc_call
def set_color(self, background_color: str, font_color: str) -> None:
"""
Set the background color of the widget.
Args:
background_color (str): The color to set the background in HEX.
font_color (str): The color to set the font in HEX.
"""
@rpc_call
def set_text(self, text: str) -> None:
"""
Set the text of the widget.
Args:
text (str): The text to set.
"""
@rpc_call
def set_font_size(self, size: int) -> None:
"""
Set the font size of the text in the widget.
Args:
size (int): The font size to set.
"""
class VSCodeEditor(RPCBase): ...
class WebsiteWidget(RPCBase):
@rpc_call
def set_url(self, url: str) -> None:

View File

@@ -13,8 +13,9 @@ from functools import wraps
from typing import TYPE_CHECKING
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import isinstance_based_on_class_name, lazy_import, lazy_import_from
from qtpy.QtCore import QCoreApplication
from qtpy.QtCore import QEventLoop, QSocketNotifier, QTimer
import bec_widgets.cli.client as client
from bec_widgets.cli.auto_updates import AutoUpdates
@@ -24,11 +25,15 @@ if TYPE_CHECKING:
from bec_widgets.cli.client import BECDockArea, BECFigure
from bec_lib.serialization import MsgpackSerialization
messages = lazy_import("bec_lib.messages")
# from bec_lib.connector import MessageObject
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
logger = bec_logger.logger
def rpc_call(func):
"""
@@ -61,46 +66,64 @@ def rpc_call(func):
return wrapper
def _get_output(process) -> None:
def _get_output(process, logger) -> None:
log_func = {process.stdout: logger.debug, process.stderr: logger.error}
stream_buffer = {process.stdout: [], process.stderr: []}
try:
os.set_blocking(process.stdout.fileno(), False)
os.set_blocking(process.stderr.fileno(), False)
while process.poll() is None:
readylist, _, _ = select.select([process.stdout, process.stderr], [], [], 1)
if process.stdout in readylist:
output = process.stdout.read(1024)
for stream in (process.stdout, process.stderr):
buf = stream_buffer[stream]
if stream in readylist:
buf.append(stream.read(4096))
output, _, remaining = "".join(buf).rpartition("\n")
if output:
print(output, end="")
if process.stderr in readylist:
error_output = process.stderr.read(1024)
if error_output:
print(error_output, end="", file=sys.stderr)
log_func[stream](output)
buf.clear()
buf.append(remaining)
except Exception as e:
print(f"Error reading process output: {str(e)}")
def _start_plot_process(gui_id, gui_class, config) -> None:
def _start_plot_process(gui_id, gui_class, config, logger=None) -> None:
"""
Start the plot in a new process.
Logger must be a logger object with "debug" and "error" functions,
or it can be left to "None" as default. None means output from the
process will not be captured.
"""
# pylint: disable=subprocess-run-check
monitor_module = importlib.import_module("bec_widgets.cli.server")
monitor_path = monitor_module.__file__
command = ["bec-gui-server", "--id", gui_id, "--gui_class", gui_class.__name__]
if config:
command.extend(["--config", config])
command = [
sys.executable,
"-u",
monitor_path,
"--id",
gui_id,
"--config",
config,
"--gui_class",
gui_class.__name__,
]
process = subprocess.Popen(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
process_output_processing_thread = threading.Thread(target=_get_output, args=(process,))
process_output_processing_thread.start()
env_dict = os.environ.copy()
env_dict["PYTHONUNBUFFERED"] = "1"
if logger is None:
stdout_redirect = subprocess.DEVNULL
stderr_redirect = subprocess.DEVNULL
else:
stdout_redirect = subprocess.PIPE
stderr_redirect = subprocess.PIPE
process = subprocess.Popen(
command,
text=True,
start_new_session=True,
stdout=stdout_redirect,
stderr=stderr_redirect,
env=env_dict,
)
if logger is None:
process_output_processing_thread = None
else:
process_output_processing_thread = threading.Thread(
target=_get_output, args=(process, logger)
)
process_output_processing_thread.start()
return process, process_output_processing_thread
@@ -112,13 +135,16 @@ class BECGuiClientMixin:
self.auto_updates = self._get_update_script()
self._target_endpoint = MessageEndpoints.scan_status()
self._selected_device = None
self.stderr_output = []
def _get_update_script(self) -> AutoUpdates | None:
eps = imd.entry_points(group="bec.widgets.auto_updates")
for ep in eps:
if ep.name == "plugin_widgets_update":
try:
spec = importlib.util.find_spec(ep.module)
# if the module is not found, we skip it
if spec is None:
continue
return ep.load()(gui=self)
except Exception as e:
print(f"Error loading auto update script from plugin: {str(e)}")
@@ -164,7 +190,7 @@ class BECGuiClientMixin:
if self._process is None or self._process.poll() is not None:
self._start_update_script()
self._process, self._process_output_processing_thread = _start_plot_process(
self._gui_id, self.__class__, self._client._service_config.redis
self._gui_id, self.__class__, self._client._service_config.config_path
)
while not self.gui_is_alive():
print("Waiting for GUI to start...")
@@ -172,28 +198,21 @@ class BECGuiClientMixin:
def close(self) -> None:
"""
Close the figure.
Close the gui window.
"""
if self._process is None:
return
if self.gui_is_alive():
self._run_rpc("close", (), wait_for_rpc_response=True)
else:
self._run_rpc("close", (), wait_for_rpc_response=False)
self._process.terminate()
self._process_output_processing_thread.join()
self._process = None
self._client.shutdown()
def print_log(self) -> None:
"""
Print the log of the plot process.
"""
if self._process is None:
return
print("".join(self.stderr_output))
# Flush list
self.stderr_output.clear()
self._run_rpc("close", (), wait_for_rpc_response=False)
while self.gui_is_alive():
time.sleep(0.2)
self._client.shutdown()
if self._process:
self._process.terminate()
if self._process_output_processing_thread:
self._process_output_processing_thread.join()
self._process = None
class RPCResponseTimeoutError(Exception):
@@ -205,6 +224,48 @@ class RPCResponseTimeoutError(Exception):
)
class QtRedisMessageWaiter:
def __init__(self, redis_connector, message_to_wait):
self.ev_loop = QEventLoop()
self.response = None
self.connector = redis_connector
self.message_to_wait = message_to_wait
self.pubsub = redis_connector._redis_conn.pubsub()
self.pubsub.subscribe(self.message_to_wait.endpoint)
fd = self.pubsub.connection._sock.fileno()
self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
self.notifier.activated.connect(self._pubsub_readable)
def _msg_received(self, msg_obj):
self.response = msg_obj.value
self.ev_loop.quit()
def wait(self, timeout=1):
timer = QTimer()
timer.singleShot(timeout * 1000, self.ev_loop.quit)
self.ev_loop.exec_()
timer.stop()
self.notifier.setEnabled(False)
self.pubsub.close()
return self.response
def _pubsub_readable(self, fd):
while True:
msg = self.pubsub.get_message()
if msg:
if msg["type"] == "subscribe":
# get_message buffers, so we may already have the answer
# let's check...
continue
else:
break
else:
return
channel = msg["channel"].decode()
msg = MessageObject(topic=channel, value=MsgpackSerialization.loads(msg["data"]))
self.connector._execute_callback(self._msg_received, msg, {})
class RPCBase:
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> None:
self._client = BECDispatcher().client
@@ -231,7 +292,7 @@ class RPCBase:
parent = parent._parent
return parent
def _run_rpc(self, method, *args, wait_for_rpc_response=True, **kwargs):
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs):
"""
Run the RPC call.
@@ -253,16 +314,24 @@ class RPCBase:
# pylint: disable=protected-access
receiver = self._root._gui_id
if wait_for_rpc_response:
redis_msg = QtRedisMessageWaiter(
self._client.connector, MessageEndpoints.gui_instruction_response(request_id)
)
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
if not wait_for_rpc_response:
return None
response = self._wait_for_response(request_id)
# get class name
if not response.content["accepted"]:
raise ValueError(response.content["message"]["error"])
msg_result = response.content["message"].get("result")
return self._create_widget_from_msg_result(msg_result)
if wait_for_rpc_response:
response = redis_msg.wait(timeout)
if response is None:
raise RPCResponseTimeoutError(request_id, timeout)
# get class name
if not response.accepted:
raise ValueError(response.message["error"])
msg_result = response.message.get("result")
return self._create_widget_from_msg_result(msg_result)
def _create_widget_from_msg_result(self, msg_result):
if msg_result is None:
@@ -285,30 +354,6 @@ class RPCBase:
return cls(parent=self, **msg_result)
return msg_result
def _wait_for_response(self, request_id: str, timeout: int = 5):
"""
Wait for the response from the server.
Args:
request_id(str): The request ID.
timeout(int): The timeout in seconds.
Returns:
The response from the server.
"""
start_time = time.time()
response = None
while response is None and self.gui_is_alive() and (time.time() - start_time) < timeout:
response = self._client.connector.get(
MessageEndpoints.gui_instruction_response(request_id)
)
QCoreApplication.processEvents() # keep UI responsive (and execute signals/slots)
if response is None and (time.time() - start_time) >= timeout:
raise RPCResponseTimeoutError(request_id, timeout)
return response
def gui_is_alive(self):
"""
Check if the GUI is alive.

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import argparse
import importlib
import inspect
import os
import sys
@@ -10,9 +9,8 @@ from typing import Literal
import black
import isort
from qtpy.QtWidgets import QGraphicsWidget, QWidget
from bec_widgets.utils import BECConnector
from bec_widgets.utils.plugin_utils import get_rpc_classes
if sys.version_info >= (3, 11):
from typing import get_overloads
@@ -85,6 +83,9 @@ class {class_name}(RPCBase, BECGuiClientMixin):"""
else:
self.content += f"""
class {class_name}(RPCBase):"""
if not cls.USER_ACCESS:
self.content += """...
"""
for method in cls.USER_ACCESS:
obj = getattr(cls, method)
if isinstance(obj, property):
@@ -138,50 +139,6 @@ class {class_name}(RPCBase):"""
with open(file_name, "w", encoding="utf-8") as file:
file.write(formatted_content)
@staticmethod
def get_rpc_classes(
repo_name: str,
) -> dict[Literal["connector_classes", "top_level_classes"], list[type]]:
"""
Get all RPC-enabled classes in the specified repository.
Args:
repo_name(str): The name of the repository.
Returns:
dict: A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
"""
connector_classes = []
top_level_classes = []
anchor_module = importlib.import_module(f"{repo_name}.widgets")
directory = os.path.dirname(anchor_module.__file__)
for root, _, files in sorted(os.walk(directory)):
for file in files:
if not file.endswith(".py") or file.startswith("__"):
continue
path = os.path.join(root, file)
subs = os.path.dirname(os.path.relpath(path, directory)).split("/")
if len(subs) == 1 and not subs[0]:
module_name = file.split(".")[0]
else:
module_name = ".".join(subs + [file.split(".")[0]])
module = importlib.import_module(f"{repo_name}.widgets.{module_name}")
for name in dir(module):
obj = getattr(module, name)
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
continue
if isinstance(obj, type) and issubclass(obj, BECConnector):
connector_classes.append(obj)
if len(subs) == 1 and (
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
):
top_level_classes.append(obj)
return {"connector_classes": connector_classes, "top_level_classes": top_level_classes}
def main():
"""
@@ -197,7 +154,7 @@ def main():
current_path = os.path.dirname(__file__)
client_path = os.path.join(current_path, "client.py")
rpc_classes = ClientGenerator.get_rpc_classes("bec_widgets")
rpc_classes = get_rpc_classes("bec_widgets")
rpc_classes["connector_classes"].sort(key=lambda x: x.__name__)
generator = ClientGenerator()

View File

@@ -1,20 +1,37 @@
from bec_widgets.utils import BECConnector
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.spiral_progress_bar.spiral_progress_bar import SpiralProgressBar
from bec_widgets.widgets.website.website import WebsiteWidget
class RPCWidgetHandler:
"""Handler class for creating widgets from RPC messages."""
widget_classes = {
"BECFigure": BECFigure,
"SpiralProgressBar": SpiralProgressBar,
"Website": WebsiteWidget,
}
def __init__(self):
self._widget_classes = None
@staticmethod
def create_widget(widget_type, **kwargs) -> BECConnector:
@property
def widget_classes(self):
"""
Get the available widget classes.
Returns:
dict: The available widget classes.
"""
if self._widget_classes is None:
self.update_available_widgets()
return self._widget_classes
def update_available_widgets(self):
"""
Update the available widgets.
Returns:
None
"""
from bec_widgets.utils.plugin_utils import get_rpc_classes
clss = get_rpc_classes("bec_widgets")
self._widget_classes = {cls.__name__: cls for cls in clss["top_level_classes"]}
def create_widget(self, widget_type, **kwargs) -> BECConnector:
"""
Create a widget from an RPC message.
@@ -25,7 +42,12 @@ class RPCWidgetHandler:
Returns:
widget(BECConnector): The created widget.
"""
widget_class = RPCWidgetHandler.widget_classes.get(widget_type)
if self._widget_classes is None:
self.update_available_widgets()
widget_class = self._widget_classes.get(widget_type)
if widget_class:
return widget_class(**kwargs)
raise ValueError(f"Unknown widget type: {widget_type}")
widget_handler = RPCWidgetHandler()

View File

@@ -1,17 +1,24 @@
import inspect
import signal
import sys
from contextlib import redirect_stderr, redirect_stdout
from typing import Union
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig
from bec_lib.utils.import_utils import lazy_import
from qtpy.QtCore import QTimer
from bec_widgets.cli.rpc_register import RPCRegister
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_dispatcher import QtRedisConnector
from bec_widgets.widgets.dock.dock_area import BECDockArea
from bec_widgets.widgets.figure import BECFigure
messages = lazy_import("bec_lib.messages")
logger = bec_logger.logger
class BECWidgetsCLIServer:
@@ -40,7 +47,7 @@ class BECWidgetsCLIServer:
self._shutdown_event = False
self._heartbeat_timer = QTimer()
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
self._heartbeat_timer.start(200) # Emit heartbeat every 1 seconds
self._heartbeat_timer.start(200)
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
@@ -105,7 +112,7 @@ class BECWidgetsCLIServer:
self.client.connector.set(
MessageEndpoints.gui_heartbeat(self.gui_id),
messages.StatusMessage(name=self.gui_id, status=1, info={}),
expire=10,
expire=1,
)
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
@@ -114,7 +121,24 @@ class BECWidgetsCLIServer:
self.client.shutdown()
if __name__ == "__main__": # pragma: no cover
class SimpleFileLikeFromLogOutputFunc:
def __init__(self, log_func):
self._log_func = log_func
def write(self, buffer):
for line in buffer.rstrip().splitlines():
line = line.rstrip()
if line:
self._log_func(line)
def flush(self):
return
def close(self):
return
def main():
import argparse
import os
import sys
@@ -125,16 +149,6 @@ if __name__ == "__main__": # pragma: no cover
import bec_widgets
app = QApplication(sys.argv)
app.setApplicationName("BEC Figure")
module_path = os.path.dirname(bec_widgets.__file__)
icon = QIcon()
icon.addFile(os.path.join(module_path, "assets", "bec_widgets_icon.png"), size=QSize(48, 48))
app.setWindowIcon(icon)
win = QMainWindow()
win.setWindowTitle("BEC Widgets")
parser = argparse.ArgumentParser(description="BEC Widgets CLI Server")
parser.add_argument("--id", type=str, help="The id of the server")
parser.add_argument(
@@ -142,7 +156,7 @@ if __name__ == "__main__": # pragma: no cover
type=str,
help="Name of the gui class to be rendered. Possible values: \n- BECFigure\n- BECDockArea",
)
parser.add_argument("--config", type=str, help="Config to connect to redis.")
parser.add_argument("--config", type=str, help="Config file")
args = parser.parse_args()
@@ -157,12 +171,45 @@ if __name__ == "__main__": # pragma: no cover
)
gui_class = BECFigure
server = BECWidgetsCLIServer(gui_id=args.id, config=args.config, gui_class=gui_class)
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.debug)):
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)):
app = QApplication(sys.argv)
app.setApplicationName("BEC Figure")
module_path = os.path.dirname(bec_widgets.__file__)
icon = QIcon()
icon.addFile(
os.path.join(module_path, "assets", "bec_widgets_icon.png"), size=QSize(48, 48)
)
app.setWindowIcon(icon)
gui = server.gui
win.setCentralWidget(gui)
win.resize(800, 600)
win.show()
win = QMainWindow()
win.setWindowTitle("BEC Widgets")
app.aboutToQuit.connect(server.shutdown)
sys.exit(app.exec())
service_config = ServiceConfig(args.config)
bec_logger.configure(
service_config.redis,
QtRedisConnector,
service_name="BECWidgetsCLIServer",
service_config=service_config.service_config,
)
server = BECWidgetsCLIServer(gui_id=args.id, config=service_config, gui_class=gui_class)
gui = server.gui
win.setCentralWidget(gui)
win.resize(800, 600)
win.show()
app.aboutToQuit.connect(server.shutdown)
def sigint_handler(*args):
# display message, for people to let it terminate gracefully
print("Caught SIGINT, exiting")
app.quit()
signal.signal(signal.SIGINT, sigint_handler)
sys.exit(app.exec())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -10,8 +10,8 @@ from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils import BECDispatcher, UILoader
from bec_widgets.widgets import BECFigure
from bec_widgets.widgets.dock.dock_area import BECDockArea
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.jupyter_console.jupyter_console import BECJupyterConsole
# class JupyterConsoleWidget(RichJupyterWidget): # pragma: no cover:
@@ -136,17 +136,18 @@ if __name__ == "__main__": # pragma: no cover
module_path = os.path.dirname(bec_widgets.__file__)
app = QApplication(sys.argv)
app.setApplicationName("Jupyter Console")
app.setApplicationDisplayName("Jupyter Console")
qdarktheme.setup_theme("auto")
icon = QIcon()
icon.addFile(os.path.join(module_path, "assets", "terminal_icon.png"), size=QSize(48, 48))
app.setWindowIcon(icon)
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
client.start()
app = QApplication(sys.argv)
app.setApplicationName("Jupyter Console")
app.setApplicationDisplayName("Jupyter Console")
# qdarktheme.setup_theme("auto")
icon = QIcon()
icon.addFile(os.path.join(module_path, "assets", "terminal_icon.png"), size=QSize(48, 48))
app.setWindowIcon(icon)
win = JupyterConsoleWindow()
win.show()

View File

@@ -0,0 +1,17 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
"""PySide6 port of the Qt Designer taskmenuextension example from Qt v6.x"""
import sys
from bec_ipython_client.main import BECIPythonClient
from qtpy.QtWidgets import QApplication
from tictactoe import TicTacToe
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
window = TicTacToe()
window.state = "-X-XO----"
window.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,12 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QPyDesignerCustomWidgetCollection
from tictactoe import TicTacToe
from tictactoeplugin import TicTacToePlugin
# Set PYSIDE_DESIGNER_PLUGINS to point to this directory and load the plugin
if __name__ == "__main__": # pragma: no cover
QPyDesignerCustomWidgetCollection.addCustomWidget(TicTacToePlugin())

View File

@@ -0,0 +1,4 @@
{
"files": ["tictactoe.py", "main.py", "registertictactoe.py", "tictactoeplugin.py",
"tictactoetaskmenu.py"]
}

View File

@@ -0,0 +1,135 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtCore import Property, QPoint, QRect, QSize, Qt, Slot
from qtpy.QtGui import QPainter, QPen
from qtpy.QtWidgets import QWidget
EMPTY = "-"
CROSS = "X"
NOUGHT = "O"
DEFAULT_STATE = "---------"
class TicTacToe(QWidget): # pragma: no cover
def __init__(self, parent=None):
super().__init__(parent)
self._state = DEFAULT_STATE
self._turn_number = 0
def minimumSizeHint(self):
return QSize(200, 200)
def sizeHint(self):
return QSize(200, 200)
def setState(self, new_state):
self._turn_number = 0
self._state = DEFAULT_STATE
for position in range(min(9, len(new_state))):
mark = new_state[position]
if mark == CROSS or mark == NOUGHT:
self._turn_number += 1
self._change_state_at(position, mark)
position += 1
self.update()
def state(self):
return self._state
@Slot()
def clear_board(self):
self._state = DEFAULT_STATE
self._turn_number = 0
self.update()
def _change_state_at(self, pos, new_state):
self._state = self._state[:pos] + new_state + self._state[pos + 1 :]
def mousePressEvent(self, event):
if self._turn_number == 9:
self.clear_board()
return
for position in range(9):
cell = self._cell_rect(position)
if cell.contains(event.position().toPoint()):
if self._state[position] == EMPTY:
new_state = CROSS if self._turn_number % 2 == 0 else NOUGHT
self._change_state_at(position, new_state)
self._turn_number += 1
self.update()
def paintEvent(self, event):
with QPainter(self) as painter:
painter.setRenderHint(QPainter.Antialiasing)
painter.setPen(QPen(Qt.darkGreen, 1))
painter.drawLine(self._cell_width(), 0, self._cell_width(), self.height())
painter.drawLine(2 * self._cell_width(), 0, 2 * self._cell_width(), self.height())
painter.drawLine(0, self._cell_height(), self.width(), self._cell_height())
painter.drawLine(0, 2 * self._cell_height(), self.width(), 2 * self._cell_height())
painter.setPen(QPen(Qt.darkBlue, 2))
for position in range(9):
cell = self._cell_rect(position)
if self._state[position] == CROSS:
painter.drawLine(cell.topLeft(), cell.bottomRight())
painter.drawLine(cell.topRight(), cell.bottomLeft())
elif self._state[position] == NOUGHT:
painter.drawEllipse(cell)
painter.setPen(QPen(Qt.yellow, 3))
for position in range(0, 8, 3):
if (
self._state[position] != EMPTY
and self._state[position + 1] == self._state[position]
and self._state[position + 2] == self._state[position]
):
y = self._cell_rect(position).center().y()
painter.drawLine(0, y, self.width(), y)
self._turn_number = 9
for position in range(3):
if (
self._state[position] != EMPTY
and self._state[position + 3] == self._state[position]
and self._state[position + 6] == self._state[position]
):
x = self._cell_rect(position).center().x()
painter.drawLine(x, 0, x, self.height())
self._turn_number = 9
if (
self._state[0] != EMPTY
and self._state[4] == self._state[0]
and self._state[8] == self._state[0]
):
painter.drawLine(0, 0, self.width(), self.height())
self._turn_number = 9
if (
self._state[2] != EMPTY
and self._state[4] == self._state[2]
and self._state[6] == self._state[2]
):
painter.drawLine(0, self.height(), self.width(), 0)
self._turn_number = 9
def _cell_rect(self, position):
h_margin = self.width() / 30
v_margin = self.height() / 30
row = int(position / 3)
column = position - 3 * row
pos = QPoint(column * self._cell_width() + h_margin, row * self._cell_height() + v_margin)
size = QSize(self._cell_width() - 2 * h_margin, self._cell_height() - 2 * v_margin)
return QRect(pos, size)
def _cell_width(self):
return self.width() / 3
def _cell_height(self):
return self.height() / 3
state = Property(str, state, setState)

View File

@@ -0,0 +1,68 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
from tictactoe import TicTacToe
from tictactoetaskmenu import TicTacToeTaskMenuFactory
DOM_XML = """
<ui language='c++'>
<widget class='TicTacToe' name='ticTacToe'>
<property name='geometry'>
<rect>
<x>0</x>
<y>0</y>
<width>200</width>
<height>200</height>
</rect>
</property>
<property name='state'>
<string>-X-XO----</string>
</property>
</widget>
</ui>
"""
class TicTacToePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = TicTacToe(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return QIcon()
def includeFile(self):
return "tictactoe"
def initialize(self, form_editor):
self._form_editor = form_editor
manager = form_editor.extensionManager()
iid = TicTacToeTaskMenuFactory.task_menu_iid()
manager.registerExtensions(TicTacToeTaskMenuFactory(manager), iid)
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "TicTacToe"
def toolTip(self):
return "Tic Tac Toe Example, demonstrating class QDesignerTaskMenuExtension (Python)"
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,67 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtCore import Slot
from qtpy.QtDesigner import QExtensionFactory, QPyDesignerTaskMenuExtension
from qtpy.QtGui import QAction
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout
from tictactoe import TicTacToe
class TicTacToeDialog(QDialog): # pragma: no cover
def __init__(self, parent):
super().__init__(parent)
layout = QVBoxLayout(self)
self._ticTacToe = TicTacToe(self)
layout.addWidget(self._ticTacToe)
button_box = QDialogButtonBox(
QDialogButtonBox.Ok | QDialogButtonBox.Cancel | QDialogButtonBox.Reset
)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
reset_button = button_box.button(QDialogButtonBox.Reset)
reset_button.clicked.connect(self._ticTacToe.clear_board)
layout.addWidget(button_box)
def set_state(self, new_state):
self._ticTacToe.setState(new_state)
def state(self):
return self._ticTacToe.state
class TicTacToeTaskMenu(QPyDesignerTaskMenuExtension):
def __init__(self, ticTacToe, parent):
super().__init__(parent)
self._ticTacToe = ticTacToe
self._edit_state_action = QAction("Edit State...", None)
self._edit_state_action.triggered.connect(self._edit_state)
def taskActions(self):
return [self._edit_state_action]
def preferredEditAction(self):
return self._edit_state_action
@Slot()
def _edit_state(self):
dialog = TicTacToeDialog(self._ticTacToe)
dialog.set_state(self._ticTacToe.state)
if dialog.exec() == QDialog.Accepted:
self._ticTacToe.state = dialog.state()
class TicTacToeTaskMenuFactory(QExtensionFactory):
def __init__(self, extension_manager):
super().__init__(extension_manager)
@staticmethod
def task_menu_iid():
return "org.qt-project.Qt.Designer.TaskMenu"
def createExtension(self, object, iid, parent):
if iid != TicTacToeTaskMenuFactory.task_menu_iid():
return None
if object.__class__.__name__ != "TicTacToe":
return None
return TicTacToeTaskMenu(object, parent)

View File

@@ -1,3 +1,5 @@
from qtpy.QtWebEngineWidgets import QWebEngineView
from .bec_connector import BECConnector, ConnectionConfig
from .bec_dispatcher import BECDispatcher
from .bec_table import BECTable

View File

@@ -2,10 +2,11 @@
from __future__ import annotations
import time
from typing import Optional, Type
from typing import Optional
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
from bec_lib.utils.import_utils import lazy_import_from
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import QObject, QRunnable, QThreadPool, Signal
from qtpy.QtCore import Slot as pyqtSlot
from bec_widgets.cli.rpc_register import RPCRegister
@@ -33,6 +34,31 @@ class ConnectionConfig(BaseModel):
return v
class WorkerSignals(QObject):
progress = Signal(dict)
completed = Signal()
class Worker(QRunnable):
"""
Worker class to run a function in a separate thread.
"""
def __init__(self, func, *args, **kwargs):
super().__init__()
self.signals = WorkerSignals()
self.func = func
self.args = args
self.kwargs = kwargs
def run(self):
"""
Run the specified function in the thread.
"""
self.func(*self.args, **self.kwargs)
self.signals.completed.emit()
class BECConnector:
"""Connection mixin class for all BEC widgets, to handle BEC client and device manager"""
@@ -63,6 +89,43 @@ class BECConnector:
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self)
self._thread_pool = QThreadPool.globalInstance()
def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker:
"""
Submit a task to run in a separate thread. The task will run the specified
function with the provided arguments and emit the completed signal when done.
Use this method if you want to wait for a task to complete without blocking the
main thread.
Args:
fn: Function to run in a separate thread.
*args: Arguments for the function.
on_complete: Slot to run when the task is complete.
**kwargs: Keyword arguments for the function.
Returns:
worker: The worker object that will run the task.
Examples:
>>> def my_function(a, b):
>>> print(a + b)
>>> self.submit_task(my_function, 1, 2)
>>> def my_function(a, b):
>>> print(a + b)
>>> def on_complete():
>>> print("Task complete")
>>> self.submit_task(my_function, 1, 2, on_complete=on_complete)
"""
worker = Worker(fn, *args, **kwargs)
if on_complete:
worker.signals.completed.connect(on_complete)
self._thread_pool.start(worker)
return worker
def get_all_rpc(self) -> dict:
"""Get all registered RPC objects."""
all_connections = self.rpc_register.list_all_connections()

View File

@@ -0,0 +1,87 @@
import os
import sys
import sysconfig
from pathlib import Path
from qtpy import PYSIDE6
if PYSIDE6:
from PySide6.scripts.pyside_tool import (
_extend_path_var,
init_virtual_env,
is_pyenv_python,
is_virtual_env,
qt_tool_wrapper,
ui_tool_binary,
)
import bec_widgets
def patch_designer(): # pragma: no cover
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
init_virtual_env()
major_version = sys.version_info[0]
minor_version = sys.version_info[1]
os.environ["PY_MAJOR_VERSION"] = str(major_version)
os.environ["PY_MINOR_VERSION"] = str(minor_version)
if sys.platform == "linux":
version = f"{major_version}.{minor_version}"
library_name = f"libpython{version}{sys.abiflags}.so"
if is_pyenv_python():
library_name = str(Path(sysconfig.get_config_var("LIBDIR")) / library_name)
os.environ["LD_PRELOAD"] = library_name
elif sys.platform == "darwin":
library_name = f"libpython{major_version}.{minor_version}.dylib"
lib_path = str(Path(sysconfig.get_config_var("LIBDIR")) / library_name)
os.environ["DYLD_INSERT_LIBRARIES"] = lib_path
elif sys.platform == "win32":
if is_virtual_env():
_extend_path_var("PATH", os.fspath(Path(sys._base_executable).parent), True)
qt_tool_wrapper(ui_tool_binary("designer"), sys.argv[1:])
def find_plugin_paths(base_path: Path):
"""
Recursively find all directories containing a .pyproject file.
"""
plugin_paths = []
for path in base_path.rglob("*.pyproject"):
plugin_paths.append(str(path.parent))
return plugin_paths
def set_plugin_environment_variable(plugin_paths):
"""
Set the PYSIDE_DESIGNER_PLUGINS environment variable with the given plugin paths.
"""
current_paths = os.environ.get("PYSIDE_DESIGNER_PLUGINS", "")
if current_paths:
current_paths = current_paths.split(os.pathsep)
else:
current_paths = []
current_paths.extend(plugin_paths)
os.environ["PYSIDE_DESIGNER_PLUGINS"] = os.pathsep.join(current_paths)
# Patch the designer function
def main(): # pragma: no cover
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Exiting...")
return
base_dir = Path(os.path.dirname(bec_widgets.__file__)).resolve()
plugin_paths = find_plugin_paths(base_dir)
set_plugin_environment_variable(plugin_paths)
patch_designer()
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -9,7 +9,7 @@ import redis
from bec_lib.client import BECClient
from bec_lib.redis_connector import MessageObject, RedisConnector
from bec_lib.service_config import ServiceConfig
from qtpy.QtCore import QObject
from qtpy.QtCore import PYQT5, PYQT6, PYSIDE2, PYSIDE6, QCoreApplication, QObject
from qtpy.QtCore import Signal as pyqtSignal
if TYPE_CHECKING:
@@ -66,11 +66,17 @@ class QtRedisConnector(RedisConnector):
cb(msg.content, msg.metadata)
class BECClientWithoutLoggerInit(BECClient):
def _initialize_logger(self):
return
class BECDispatcher:
"""Utility class to keep track of slots connected to a particular redis connector"""
_instance = None
_initialized = False
qapp = None
def __new__(cls, client=None, config: str = None, *args, **kwargs):
if cls._instance is None:
@@ -78,22 +84,28 @@ class BECDispatcher:
cls._initialized = False
return cls._instance
def __init__(self, client=None, config: str = None):
def __init__(self, client=None, config: str | ServiceConfig = None):
if self._initialized:
return
if not QCoreApplication.instance():
BECDispatcher.qapp = QCoreApplication([])
self._slots = collections.defaultdict(set)
self.client = client
if self.client is None:
if config is not None:
host, port = config.split(":")
redis_config = {"host": host, "port": port}
self.client = BECClient(
config=ServiceConfig(redis=redis_config), connector_cls=QtRedisConnector
if not isinstance(config, ServiceConfig):
# config is supposed to be a path
config = ServiceConfig(config)
self.client = BECClientWithoutLoggerInit(
config=config, connector_cls=QtRedisConnector
) # , forced=True)
else:
self.client = BECClient(connector_cls=QtRedisConnector) # , forced=True)
self.client = BECClientWithoutLoggerInit(
connector_cls=QtRedisConnector
) # , forced=True)
else:
if self.client.started:
# have to reinitialize client to use proper connector
@@ -112,6 +124,16 @@ class BECDispatcher:
cls._instance = None
cls._initialized = False
if not cls.qapp:
return
# shutdown QCoreApp if it exists
if PYQT5 or PYQT6:
cls.qapp.exit()
elif PYSIDE2 or PYSIDE6:
cls.qapp.shutdown()
cls.qapp = None
def connect_slot(
self, slot: Callable, topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]]
) -> None:

View File

@@ -1,6 +1,10 @@
import importlib
import inspect
import os
from typing import Literal
from bec_lib.plugin_helper import _get_available_plugins
from qtpy.QtWidgets import QGraphicsWidget, QWidget
from bec_widgets.utils import BECConnector
@@ -38,3 +42,47 @@ def get_plugin_widgets() -> dict[str, BECConnector]:
def _filter_plugins(obj):
return inspect.isclass(obj) and issubclass(obj, BECConnector)
def get_rpc_classes(
repo_name: str,
) -> dict[Literal["connector_classes", "top_level_classes"], list[type]]:
"""
Get all RPC-enabled classes in the specified repository.
Args:
repo_name(str): The name of the repository.
Returns:
dict: A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
"""
connector_classes = []
top_level_classes = []
anchor_module = importlib.import_module(f"{repo_name}.widgets")
directory = os.path.dirname(anchor_module.__file__)
for root, _, files in sorted(os.walk(directory)):
for file in files:
if not file.endswith(".py") or file.startswith("__"):
continue
path = os.path.join(root, file)
subs = os.path.dirname(os.path.relpath(path, directory)).split("/")
if len(subs) == 1 and not subs[0]:
module_name = file.split(".")[0]
else:
module_name = ".".join(subs + [file.split(".")[0]])
module = importlib.import_module(f"{repo_name}.widgets.{module_name}")
for name in dir(module):
obj = getattr(module, name)
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
continue
if isinstance(obj, type) and issubclass(obj, BECConnector):
connector_classes.append(obj)
if len(subs) == 1 and (
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
):
top_level_classes.append(obj)
return {"connector_classes": connector_classes, "top_level_classes": top_level_classes}

View File

@@ -119,7 +119,7 @@ class WidgetIO:
widget: Widget instance.
ignore_errors(bool, optional): Whether to ignore if no handler is found.
"""
handler_class = WidgetIO._handlers.get(type(widget))
handler_class = WidgetIO._find_handler(widget)
if handler_class:
return handler_class().get_value(widget) # Instantiate the handler
if not ignore_errors:
@@ -136,12 +136,28 @@ class WidgetIO:
value: Value to set.
ignore_errors(bool, optional): Whether to ignore if no handler is found.
"""
handler_class = WidgetIO._handlers.get(type(widget))
handler_class = WidgetIO._find_handler(widget)
if handler_class:
handler_class().set_value(widget, value) # Instantiate the handler
elif not ignore_errors:
raise ValueError(f"No handler for widget type: {type(widget)}")
@staticmethod
def _find_handler(widget):
"""
Find the appropriate handler for the widget by checking its base classes.
Args:
widget: Widget instance.
Returns:
handler_class: The handler class if found, otherwise None.
"""
for base in type(widget).__mro__:
if base in WidgetIO._handlers:
return WidgetIO._handlers[base]
return None
################## for exporting and importing widget hierarchies ##################

View File

@@ -1,4 +1,5 @@
from .dock import BECDock, BECDockArea
from .figure import BECFigure, FigureConfig
from .scan_control import ScanControl
from .spiral_progress_bar import SpiralProgressBar
# from .buttons import StopButton
# from .dock import BECDock, BECDockArea
# from .figure import BECFigure, FigureConfig
# from .scan_control import ScanControl
# from .spiral_progress_bar import SpiralProgressBar

View File

@@ -0,0 +1,352 @@
"""This module contains the BECStatusBox widget, which displays the status of different BEC services in a collapsible tree widget.
The widget automatically updates the status of all running BEC services, and displays their status.
"""
from __future__ import annotations
import sys
from typing import TYPE_CHECKING
import qdarktheme
from bec_lib.utils.import_utils import lazy_import_from
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import QObject, QTimer, Signal, Slot
from qtpy.QtWidgets import QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.widgets.bec_status_box.status_item import StatusItem
if TYPE_CHECKING:
from bec_lib.client import BECClient
# TODO : Put normal imports back when Pydantic gets faster
BECStatus = lazy_import_from("bec_lib.messages", ("BECStatus",))
class BECStatusBoxConfig(ConnectionConfig):
pass
class BECServiceInfoContainer(BaseModel):
"""Container to store information about the BEC services."""
service_name: str
status: BECStatus | str = Field(
default="NOTCONNECTED",
description="The status of the service. Can be any of the BECStatus names, or NOTCONNECTED.",
)
info: dict
metrics: dict | None
model_config: dict = {"validate_assignment": True}
@field_validator("status")
@classmethod
def validate_status(cls, v):
"""Validate input for status. Accept BECStatus and NOTCONNECTED.
Args:
v (BECStatus | str): The input value.
Returns:
str: The validated status.
"""
if v in list(BECStatus.__members__.values()):
return v.name
if v in list(BECStatus.__members__.keys()) or v == "NOTCONNECTED":
return v
raise ValueError(
f"Status must be one of {BECStatus.__members__.values()} or 'NOTCONNECTED'. Input {v}"
)
class BECServiceStatusMixin(QObject):
"""A mixin class to update the service status, and metrics.
It emits a signal 'services_update' when the service status is updated.
Args:
client (BECClient): The client object to connect to the BEC server.
"""
services_update = Signal(dict, dict)
def __init__(self, client: BECClient):
super().__init__()
self.client = client
self._service_update_timer = QTimer()
self._service_update_timer.timeout.connect(self._get_service_status)
self._service_update_timer.start(1000)
def _get_service_status(self):
"""Pull latest service and metrics updates from REDIS for all services, and emit both via 'services_update' signal."""
# pylint: disable=protected-access
self.client._update_existing_services()
self.services_update.emit(self.client._services_info, self.client._services_metric)
class BECStatusBox(BECConnector, QTreeWidget):
"""A widget to display the status of different BEC services.
This widget automatically updates the status of all running BEC services, and displays their status.
Information about the individual services is collapsible, and double clicking on
the individual service will display the metrics about the service.
Args:
parent Optional : The parent widget for the BECStatusBox. Defaults to None.
service_name Optional(str): The name of the top service label. Defaults to "BEC Server".
client Optional(BECClient): The client object to connect to the BEC server. Defaults to None
config Optional(BECStatusBoxConfig | dict): The configuration for the status box. Defaults to None.
gui_id Optional(str): The unique id for the widget. Defaults to None.
"""
CORE_SERVICES = ["DeviceServer", "ScanServer", "SciHub", "ScanBundler", "FileWriterManager"]
service_update = Signal(dict)
bec_core_state = Signal(str)
def __init__(
self,
parent=None,
service_name: str = "BEC Server",
client: BECClient = None,
config: BECStatusBoxConfig | dict = None,
gui_id: str = None,
):
if config is None:
config = BECStatusBoxConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = BECStatusBoxConfig(**config)
super().__init__(client=client, config=config, gui_id=gui_id)
QTreeWidget.__init__(self, parent=parent)
self.service_name = service_name
self.config = config
self.bec_service_info_container = {}
self.tree_items = {}
self.tree_top_item = None
self.bec_service_status = BECServiceStatusMixin(client=self.client)
self.init_ui()
self.bec_service_status.services_update.connect(self.update_service_status)
self.bec_core_state.connect(self.update_top_item_status)
self.itemDoubleClicked.connect(self.on_tree_item_double_clicked)
def init_ui(self) -> None:
"""Initialize the UI for the status box, and add QTreeWidget as the basis for the status box."""
self.init_ui_tree_widget()
top_label = self._create_status_widget(self.service_name, status=BECStatus.IDLE)
self.tree_top_item = QTreeWidgetItem()
self.tree_top_item.setExpanded(True)
self.tree_top_item.setDisabled(True)
self.addTopLevelItem(self.tree_top_item)
self.setItemWidget(self.tree_top_item, 0, top_label)
self.service_update.connect(top_label.update_config)
def _create_status_widget(
self, service_name: str, status=BECStatus, info: dict = None, metrics: dict = None
) -> StatusItem:
"""Creates a StatusItem (QWidget) for the given service, and stores all relevant
information about the service in the bec_service_info_container.
Args:
service_name (str): The name of the service.
status (BECStatus): The status of the service.
info Optional(dict): The information about the service. Default is {}
metric Optional(dict): Metrics for the respective service. Default is None
Returns:
StatusItem: The status item widget.
"""
if info is None:
info = {}
self._update_bec_service_container(service_name, status, info, metrics)
item = StatusItem(
parent=self,
config={
"service_name": service_name,
"status": status.name,
"info": info,
"metrics": metrics,
},
)
return item
@Slot(str)
def update_top_item_status(self, status: BECStatus) -> None:
"""Method to update the status of the top item in the tree widget.
Gets the status from the Signal 'bec_core_state' and updates the StatusItem via the signal 'service_update'.
Args:
status (BECStatus): The state of the core services.
"""
self.bec_service_info_container[self.service_name].status = status
self.service_update.emit(self.bec_service_info_container[self.service_name].model_dump())
def _update_bec_service_container(
self, service_name: str, status: BECStatus, info: dict, metrics: dict = None
) -> None:
"""Update the bec_service_info_container with the newest status and metrics for the BEC service.
If information about the service already exists, it will create a new entry.
Args:
service_name (str): The name of the service.
service_info (StatusMessage): A class containing the service status.
service_metric (ServiceMetricMessage): A class containing the service metrics.
"""
container = self.bec_service_info_container.get(service_name, None)
if container:
container.status = status
container.info = info
container.metrics = metrics
return
service_info_item = BECServiceInfoContainer(
service_name=service_name, status=status, info=info, metrics=metrics
)
self.bec_service_info_container.update({service_name: service_info_item})
@Slot(dict, dict)
def update_service_status(self, services_info: dict, services_metric: dict) -> None:
"""Callback function services_metric from BECServiceStatusMixin.
It updates the status of all services.
Args:
services_info (dict): A dictionary containing the service status for all running BEC services.
services_metric (dict): A dictionary containing the service metrics for all running BEC services.
"""
checked = []
services_info = self.update_core_services(services_info, services_metric)
checked.extend(self.CORE_SERVICES)
for service_name, msg in sorted(services_info.items()):
checked.append(service_name)
metric_msg = services_metric.get(service_name, None)
metrics = metric_msg.metrics if metric_msg else None
if service_name in self.tree_items:
self._update_bec_service_container(
service_name=service_name, status=msg.status, info=msg.info, metrics=metrics
)
self.service_update.emit(self.bec_service_info_container[service_name].model_dump())
continue
item_widget = self._create_status_widget(
service_name=service_name, status=msg.status, info=msg.info, metrics=metrics
)
item = QTreeWidgetItem()
item.setDisabled(True)
self.service_update.connect(item_widget.update_config)
self.tree_top_item.addChild(item)
self.setItemWidget(item, 0, item_widget)
self.tree_items.update({service_name: (item, item_widget)})
self.check_redundant_tree_items(checked)
def update_core_services(self, services_info: dict, services_metric: dict) -> dict:
"""Method to process status and metrics updates of core services (stored in CORE_SERVICES).
If a core services is not connected, it should not be removed from the status widget
Args:
services_info (dict): A dictionary containing the service status of different services.
services_metric (dict): A dictionary containing the service metrics of different services.
Returns:
dict: The services_info dictionary after removing the info updates related to the CORE_SERVICES
"""
bec_core_state = "RUNNING"
for service_name in sorted(self.CORE_SERVICES):
metric_msg = services_metric.get(service_name, None)
metrics = metric_msg.metrics if metric_msg else None
if service_name not in services_info:
self.bec_service_info_container[service_name].status = "NOTCONNECTED"
bec_core_state = "ERROR"
else:
msg = services_info.pop(service_name)
self._update_bec_service_container(
service_name=service_name, status=msg.status, info=msg.info, metrics=metrics
)
bec_core_state = (
"RUNNING" if (msg.status.value > 1 and bec_core_state == "RUNNING") else "ERROR"
)
if service_name in self.tree_items:
self.service_update.emit(self.bec_service_info_container[service_name].model_dump())
continue
self.add_tree_item(service_name, msg.status, msg.info, metrics)
self.bec_core_state.emit(bec_core_state)
return services_info
def check_redundant_tree_items(self, checked: list) -> None:
"""Utility method to check and remove redundant objects from the BECStatusBox.
Args:
checked (list): A list of services that are currently running.
"""
to_be_deleted = [key for key in self.tree_items if key not in checked]
for key in to_be_deleted:
item, _ = self.tree_items.pop(key)
self.tree_top_item.removeChild(item)
def add_tree_item(
self, service_name: str, status: BECStatus, info: dict = None, metrics: dict = None
) -> None:
"""Method to add a new QTreeWidgetItem together with a StatusItem to the tree widget.
Args:
service_name (str): The name of the service.
service_status_msg (StatusMessage): The status of the service.
metrics (dict): The metrics of the service.
"""
item_widget = self._create_status_widget(
service_name=service_name, status=status, info=info, metrics=metrics
)
item = QTreeWidgetItem()
self.service_update.connect(item_widget.update_config)
self.tree_top_item.addChild(item)
self.setItemWidget(item, 0, item_widget)
self.tree_items.update({service_name: (item, item_widget)})
def init_ui_tree_widget(self) -> None:
"""Initialise the tree widget for the status box."""
self.setHeaderHidden(True)
self.setStyleSheet(
"QTreeWidget::item:!selected "
"{ "
"border: 1px solid gainsboro; "
"border-left: none; "
"border-top: none; "
"}"
"QTreeWidget::item:selected {}"
)
@Slot(QTreeWidgetItem, int)
def on_tree_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None:
"""Callback function for double clicks on individual QTreeWidgetItems in the collapsed section.
Args:
item (QTreeWidgetItem): The item that was double clicked.
column (int): The column that was double clicked.
"""
for _, (tree_item, status_widget) in self.tree_items.items():
if tree_item == item:
status_widget.show_popup()
def closeEvent(self, event):
super().cleanup()
QTreeWidget().closeEvent(event)
def main():
"""Main method to run the BECStatusBox widget."""
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
qdarktheme.setup_theme("auto")
main_window = BECStatusBox()
main_window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,171 @@
""" 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
import sys
from datetime import datetime
import qdarktheme
from bec_lib.utils.import_utils import lazy_import_from
from pydantic import Field
from qtpy.QtCore import Qt, Slot
from qtpy.QtWidgets import QDialog, QHBoxLayout, QLabel, QStyle, QVBoxLayout, QWidget
from bec_widgets.utils.bec_connector import ConnectionConfig
# TODO : Put normal imports back when Pydantic gets faster
BECStatus = lazy_import_from("bec_lib.messages", ("BECStatus",))
class IconsEnum(enum.Enum):
"""Enum class for icons in the status item widget."""
RUNNING = "SP_DialogApplyButton"
BUSY = "SP_BrowserReload"
IDLE = "SP_MessageBoxWarning"
ERROR = "SP_DialogCancelButton"
NOTCONNECTED = "SP_TitleBarContextHelpButton"
class StatusWidgetConfig(ConnectionConfig):
"""Configuration class for the status item widget."""
service_name: str
status: str
info: dict
metrics: dict | None
icon_size: tuple = Field(default=(24, 24), description="The size of the icon in the widget.")
font_size: int = Field(16, description="The font size of the text in the widget.")
class StatusItem(QWidget):
"""A widget to display the status of a service.
Args:
parent: The parent widget.
config (dict): The configuration for the service.
"""
def __init__(self, parent=None, config: dict = None):
if config is None:
config = StatusWidgetConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = StatusWidgetConfig(**config)
self.config = config
QWidget.__init__(self, parent=parent)
self.parent = parent
self.layout = None
self.config = config
self._popup_label_ref = {}
self._label = None
self._icon = None
self.init_ui()
def init_ui(self) -> None:
"""Init the UI for the status item widget."""
self.layout = QHBoxLayout()
self.layout.setContentsMargins(5, 5, 5, 5)
self.setLayout(self.layout)
self._label = QLabel()
self._icon = QLabel()
self.layout.addWidget(self._label)
self.layout.addWidget(self._icon)
self.update_ui()
@Slot(dict)
def update_config(self, config: dict) -> None:
"""Update the configuration of the status item widget.
This method is invoked from the parent widget.
The UI values are later updated based on the new configuration.
Args:
config (dict): Config updates from parent widget.
"""
if config["service_name"] != self.config.service_name:
return
self.config.status = config["status"]
self.config.info = config["info"]
self.config.metrics = config["metrics"]
self.update_ui()
def update_ui(self) -> None:
"""Update the UI of the labels, and popup dialog."""
self.set_text()
self.set_status()
self._set_popup_text()
def set_text(self) -> None:
"""Set the text of the QLabel basae on the config."""
service = self.config.service_name
status = self.config.status
if "BECClient" in service.split("/"):
service = service.split("/")[0] + "/..." + service.split("/")[1][-4:]
if status == "NOTCONNECTED":
status = "NOT CONNECTED"
text = f"{service} is {status}"
self._label.setText(text)
def set_status(self) -> None:
"""Set the status icon for the status item widget."""
icon_name = IconsEnum[self.config.status].value
icon = self.style().standardIcon(getattr(QStyle.StandardPixmap, icon_name))
self._icon.setPixmap(icon.pixmap(*self.config.icon_size))
self._icon.setAlignment(Qt.AlignmentFlag.AlignRight)
def show_popup(self) -> None:
"""Method that is invoked when the user double clicks on the StatusItem widget."""
dialog = QDialog(self)
dialog.setWindowTitle(f"{self.config.service_name} Details")
layout = QVBoxLayout()
popup_label = self._make_popup_label()
self._set_popup_text()
layout.addWidget(popup_label)
dialog.setLayout(layout)
dialog.finished.connect(self._cleanup_popup_label)
dialog.exec()
def _make_popup_label(self) -> QLabel:
"""Create a QLabel for the popup dialog.
Returns:
QLabel: The label for the popup dialog.
"""
label = QLabel()
label.setWordWrap(True)
self._popup_label_ref.update({"label": label})
return label
def _set_popup_text(self) -> None:
"""Compile the metrics text for the status item widget."""
if self._popup_label_ref.get("label") is None:
return
metrics_text = (
f"<b>SERVICE:</b> {self.config.service_name}<br><b>STATUS:</b> {self.config.status}<br>"
)
if self.config.metrics:
for key, value in self.config.metrics.items():
if key == "create_time":
value = datetime.fromtimestamp(value).strftime("%Y-%m-%d %H:%M:%S")
metrics_text += f"<b>{key.upper()}:</b> {value}<br>"
self._popup_label_ref["label"].setText(metrics_text)
def _cleanup_popup_label(self) -> None:
"""Cleanup the popup label."""
self._popup_label_ref.clear()
def main():
"""Run the status item widget."""
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
qdarktheme.setup_theme("auto")
main_window = StatusItem()
main_window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()

View File

@@ -0,0 +1 @@
from .stop_button.stop_button import StopButton

View File

@@ -0,0 +1,32 @@
from qtpy.QtWidgets import QPushButton
from bec_widgets.utils import BECConnector
class StopButton(BECConnector, QPushButton):
"""A button that stops the current scan."""
def __init__(self, parent=None, client=None, config=None, gui_id=None):
super().__init__(client=client, config=config, gui_id=gui_id)
QPushButton.__init__(self, parent=parent)
self.get_bec_shortcuts()
self.setText("Stop")
self.setStyleSheet("background-color: #cc181e; color: white")
self.clicked.connect(self.stop_scan)
def stop_scan(self):
"""Stop the scan."""
self.queue.request_scan_abortion()
self.queue.request_queue_reset()
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = StopButton()
widget.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,2 @@
from .device_combobox.device_combobox import DeviceComboBox
from .device_line_edit.device_line_edit import DeviceLineEdit

View File

@@ -0,0 +1,92 @@
from typing import TYPE_CHECKING
from qtpy.QtWidgets import QComboBox
from bec_widgets.widgets.device_inputs.device_input_base import DeviceInputBase, DeviceInputConfig
if TYPE_CHECKING:
from bec_widgets.widgets.device_inputs.device_input_base import DeviceInputConfig
class DeviceComboBox(DeviceInputBase, QComboBox):
"""
Line edit widget for device input with autocomplete for device names.
Args:
parent: Parent widget.
client: BEC client object.
config: Device input configuration.
gui_id: GUI ID.
device_filter: Device filter, name of the device class.
default: Default device name.
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
def __init__(
self,
parent=None,
client=None,
config: DeviceInputConfig = None,
gui_id: str | None = None,
device_filter: str | None = None,
default: str | None = None,
arg_name: str | None = None,
):
super().__init__(client=client, config=config, gui_id=gui_id)
QComboBox.__init__(self, parent=parent)
self.populate_combobox()
if arg_name is not None:
self.config.arg_name = arg_name
if device_filter is not None:
self.set_device_filter(device_filter)
if default is not None:
self.set_default_device(default)
def set_device_filter(self, device_filter: str):
"""
Set the device filter.
Args:
device_filter(str): Device filter, name of the device class.
"""
super().set_device_filter(device_filter)
self.populate_combobox()
def set_default_device(self, default_device: str):
"""
Set the default device.
Args:
default_device(str): Default device name.
"""
super().set_default_device(default_device)
self.setCurrentText(default_device)
def populate_combobox(self):
"""Populate the combobox with the devices."""
self.devices = self.get_device_list(self.config.device_filter)
self.clear()
self.addItems(self.devices)
def get_device(self) -> object:
"""
Get the selected device object.
Returns:
object: Device object.
"""
device_name = self.currentText()
device_obj = getattr(self.dev, device_name.lower(), None)
if device_obj is None:
raise ValueError(f"Device {device_name} is not found.")
return device_obj
def cleanup(self):
"""Cleanup the widget."""
super().cleanup()
def closeEvent(self, event):
super().cleanup()
QComboBox().closeEvent(event)

View File

@@ -0,0 +1,4 @@
{
"files": ["device_combobox.py", "launch_device_combobox.py",
]
}

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
from bec_widgets.widgets.device_inputs import DeviceComboBox
DOM_XML = """
<ui language='c++'>
<widget class='DeviceComboBox' name='device_combobox'>
</widget>
</ui>
"""
class DeviceComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = DeviceComboBox(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return QIcon()
def includeFile(self):
return "device_combobox"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "DeviceComboBox"
def toolTip(self):
return "Device ComboBox Example for BEC Widgets"
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,11 @@
from bec_widgets.widgets.device_inputs import DeviceComboBox
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
w = DeviceComboBox()
w.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,17 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.device_inputs.device_combobox.device_combobox_plugin import (
DeviceComboBoxPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(DeviceComboBoxPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,123 @@
from __future__ import annotations
from bec_widgets.utils import BECConnector, ConnectionConfig
class DeviceInputConfig(ConnectionConfig):
device_filter: str | list[str] | None = None
default: str | None = None
arg_name: str | None = None
class DeviceInputBase(BECConnector):
"""
Mixin class for device input widgets. This class provides methods to get the device list and device object based
on the current text of the widget.
"""
def __init__(self, client=None, config=None, gui_id=None):
if config is None:
config = DeviceInputConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = DeviceInputConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
self.get_bec_shortcuts()
self._devices = []
@property
def devices(self) -> list[str]:
"""
Get the list of devices.
Returns:
list[str]: List of devices.
"""
return self._devices
@devices.setter
def devices(self, value: list[str]):
"""
Set the list of devices.
Args:
value: List of devices.
"""
self._devices = value
def set_device_filter(self, device_filter: str | list[str]):
"""
Set the device filter.
Args:
device_filter(str): Device filter, name of the device class.
"""
self.validate_device_filter(device_filter)
self.config.device_filter = device_filter
def set_default_device(self, default_device: str):
"""
Set the default device.
Args:
default_device(str): Default device name.
"""
self.validate_device(default_device)
self.config.default = default_device
def get_device_list(self, filter: str | list[str] | None = None) -> list[str]:
"""
Get the list of device names based on the filter of current BEC client.
Args:
filter(str|None): Class name filter to apply on the device list.
Returns:
devices(list[str]): List of device names.
"""
all_devices = self.dev.enabled_devices
if filter is not None:
self.validate_device_filter(filter)
if isinstance(filter, str):
filter = [filter]
devices = [device.name for device in all_devices if device.__class__.__name__ in filter]
else:
devices = [device.name for device in all_devices]
return devices
def get_available_filters(self):
"""
Get the available device classes which can be used as filters.
"""
all_devices = self.dev.enabled_devices
filters = {device.__class__.__name__ for device in all_devices}
return filters
def validate_device_filter(self, filter: str | list[str]) -> None:
"""
Validate the device filter if the class name is present in the current BEC instance.
Args:
filter(str|list[str]): Class name to use as a device filter.
"""
if isinstance(filter, str):
filter = [filter]
available_filters = self.get_available_filters()
for f in filter:
if f not in available_filters:
raise ValueError(f"Device filter {f} is not valid.")
def validate_device(self, device: str) -> None:
"""
Validate the device if it is present in current BEC instance.
Args:
device(str): Device to validate.
"""
if device not in self.get_device_list(self.config.device_filter):
raise ValueError(f"Device {device} is not valid.")
def cleanup(self):
super().cleanup()

View File

@@ -0,0 +1,104 @@
from typing import TYPE_CHECKING
from qtpy.QtCore import QSize
from qtpy.QtWidgets import QCompleter, QLineEdit, QSizePolicy
from bec_widgets.widgets.device_inputs.device_input_base import DeviceInputBase, DeviceInputConfig
if TYPE_CHECKING:
from bec_widgets.widgets.device_inputs.device_input_base import DeviceInputConfig
class DeviceLineEdit(DeviceInputBase, QLineEdit):
"""
Line edit widget for device input with autocomplete for device names.
Args:
parent: Parent widget.
client: BEC client object.
config: Device input configuration.
gui_id: GUI ID.
device_filter: Device filter, name of the device class.
default: Default device name.
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
"""
def __init__(
self,
parent=None,
client=None,
config: DeviceInputConfig = None,
gui_id: str | None = None,
device_filter: str | list[str] | None = None,
default: str | None = None,
arg_name: str | None = None,
):
QLineEdit.__init__(self, parent=parent)
DeviceInputBase.__init__(self, client=client, config=config, gui_id=gui_id)
self.completer = QCompleter(self)
self.setCompleter(self.completer)
self.populate_completer()
if arg_name is not None:
self.config.arg_name = arg_name
self.arg_name = arg_name
if device_filter is not None:
self.set_device_filter(device_filter)
if default is not None:
self.set_default_device(default)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.setMinimumSize(QSize(100, 0))
def set_device_filter(self, device_filter: str | list[str]):
"""
Set the device filter.
Args:
device_filter (str | list[str]): Device filter, name of the device class.
"""
super().set_device_filter(device_filter)
self.populate_completer()
def set_default_device(self, default_device: str):
"""
Set the default device.
Args:
default_device (str): Default device name.
"""
super().set_default_device(default_device)
self.setText(default_device)
def populate_completer(self):
"""Populate the completer with the devices."""
self.devices = self.get_device_list(self.config.device_filter)
self.completer.setModel(self.create_completer_model(self.devices))
def create_completer_model(self, devices: list[str]):
"""Create a model for the completer."""
from qtpy.QtCore import QStringListModel
return QStringListModel(devices, self)
def get_device(self) -> object:
"""
Get the selected device object.
Returns:
object: Device object.
"""
device_name = self.text()
device_obj = getattr(self.dev, device_name.lower(), None)
if device_obj is None:
raise ValueError(f"Device {device_name} is not found.")
return device_obj
def cleanup(self):
"""Cleanup the widget."""
super().cleanup()
def closeEvent(self, event):
super().cleanup()
QLineEdit().closeEvent(event)

View File

@@ -0,0 +1,4 @@
{
"files": ["device_line_edit.py", "launch_device_line_edit.py",
]
}

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtGui import QIcon
from bec_widgets.widgets.device_inputs import DeviceLineEdit
DOM_XML = """
<ui language='c++'>
<widget class='DeviceLineEdit' name='device_line_edit'>
</widget>
</ui>
"""
class DeviceLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = DeviceLineEdit(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return QIcon()
def includeFile(self):
return "device_line_edit"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "DeviceLineEdit"
def toolTip(self):
return "Device LineEdit Example for BEC Widgets with autocomplete."
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,11 @@
from bec_widgets.widgets.device_inputs import DeviceLineEdit
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
w = DeviceLineEdit()
w.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,17 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.device_inputs.device_line_edit.device_line_edit_plugin import (
DeviceLineEditPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(DeviceLineEditPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -5,13 +5,13 @@ from typing import TYPE_CHECKING, Any, Literal, Optional
from pydantic import Field
from pyqtgraph.dockarea import Dock
from bec_widgets.cli.rpc_wigdet_handler import RPCWidgetHandler
from bec_widgets.cli.rpc_wigdet_handler import widget_handler
from bec_widgets.utils import BECConnector, ConnectionConfig, GridLayoutManager
if TYPE_CHECKING:
from qtpy.QtWidgets import QWidget
from bec_widgets.widgets import BECDockArea
from bec_widgets.widgets.dock import BECDockArea
class DockConfig(ConnectionConfig):
@@ -149,7 +149,7 @@ class BECDock(BECConnector, Dock):
Returns:
list: The list of eligible widgets.
"""
return list(RPCWidgetHandler.widget_classes.keys())
return list(widget_handler.widget_classes.keys())
def add_widget(
self,
@@ -178,7 +178,7 @@ class BECDock(BECConnector, Dock):
self.layout_manager.shift_widgets(shift, start_row=row)
if isinstance(widget, str):
widget = RPCWidgetHandler.create_widget(widget)
widget = widget_handler.create_widget(widget)
else:
widget = widget

View File

@@ -711,6 +711,12 @@ class BECFigure(BECConnector, pg.GraphicsLayoutWidget):
qdarktheme.setup_theme(theme)
self.setBackground("k" if theme == "dark" else "w")
self.config.theme = theme
for plot in self.widget_list:
plot.set_x_label(plot.plot_item.getAxis("bottom").label.toPlainText())
plot.set_y_label(plot.plot_item.getAxis("left").label.toPlainText())
if plot.plot_item.titleLabel.text:
plot.set_title(plot.plot_item.titleLabel.text)
plot.set_legend_label_size()
def _remove_by_coordinates(self, row: int, col: int) -> None:
"""

View File

@@ -5,6 +5,8 @@ from typing import Literal, Optional
import numpy as np
import pyqtgraph as pg
from pydantic import BaseModel, Field
from qtpy import QT_VERSION
from qtpy.QtGui import QFont, QFontDatabase, QFontInfo
from qtpy.QtWidgets import QWidget
from bec_widgets.utils import BECConnector, ConnectionConfig
@@ -12,8 +14,14 @@ from bec_widgets.utils import BECConnector, ConnectionConfig
class AxisConfig(BaseModel):
title: Optional[str] = Field(None, description="The title of the axes.")
title_size: Optional[int] = Field(None, description="The font size of the title.")
x_label: Optional[str] = Field(None, description="The label for the x-axis.")
x_label_size: Optional[int] = Field(None, description="The font size of the x-axis label.")
y_label: Optional[str] = Field(None, description="The label for the y-axis.")
y_label_size: Optional[int] = Field(None, description="The font size of the y-axis label.")
legend_label_size: Optional[int] = Field(
None, description="The font size of the legend labels."
)
x_scale: Literal["linear", "log"] = Field("linear", description="The scale of the x-axis.")
y_scale: Literal["linear", "log"] = Field("linear", description="The scale of the y-axis.")
x_lim: Optional[tuple] = Field(None, description="The limits of the x-axis.")
@@ -50,6 +58,7 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
"set_grid",
"lock_aspect_ratio",
"remove",
"set_legend_label_size",
]
def __init__(
@@ -85,6 +94,7 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
- y_scale: Literal["linear", "log"]
- x_lim: tuple
- y_lim: tuple
- legend_label_size: int
"""
# Mapping of keywords to setter methods
method_map = {
@@ -95,6 +105,7 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
"y_scale": self.set_y_scale,
"x_lim": self.set_x_lim,
"y_lim": self.set_y_lim,
"legend_label_size": self.set_legend_label_size,
}
for key, value in kwargs.items():
if key in method_map:
@@ -116,34 +127,79 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
self.set(**{k: v for k, v in config_mappings.items() if v is not None})
def set_title(self, title: str):
def set_legend_label_size(self, size: int = None):
"""
Set the font size of the legend.
Args:
size(int): Font size of the legend.
"""
if not self.plot_item.legend:
return
if self.config.axis.legend_label_size or size:
if size:
self.config.axis.legend_label_size = size
scale = (
size / 9
) # 9 is the default font size of the legend, so we always scale it against 9
self.plot_item.legend.setScale(scale)
def get_text_color(self):
return "#FFF" if self.figure.config.theme == "dark" else "#000"
def set_title(self, title: str, size: int = None):
"""
Set the title of the plot widget.
Args:
title(str): Title of the plot widget.
size(int): Font size of the title.
"""
self.plot_item.setTitle(title)
if self.config.axis.title_size or size:
if size:
self.config.axis.title_size = size
style = {"color": self.get_text_color(), "size": f"{self.config.axis.title_size}pt"}
else:
style = {}
self.plot_item.setTitle(title, **style)
self.config.axis.title = title
def set_x_label(self, label: str):
def set_x_label(self, label: str, size: int = None):
"""
Set the label of the x-axis.
Args:
label(str): Label of the x-axis.
size(int): Font size of the label.
"""
self.plot_item.setLabel("bottom", label)
if self.config.axis.x_label_size or size:
if size:
self.config.axis.x_label_size = size
style = {
"color": self.get_text_color(),
"font-size": f"{self.config.axis.x_label_size}pt",
}
else:
style = {}
self.plot_item.setLabel("bottom", label, **style)
self.config.axis.x_label = label
def set_y_label(self, label: str):
def set_y_label(self, label: str, size: int = None):
"""
Set the label of the y-axis.
Args:
label(str): Label of the y-axis.
size(int): Font size of the label.
"""
self.plot_item.setLabel("left", label)
if self.config.axis.y_label_size or size:
if size:
self.config.axis.y_label_size = size
color = self.get_text_color()
style = {"color": color, "font-size": f"{self.config.axis.y_label_size}pt"}
else:
style = {}
self.plot_item.setLabel("left", label, **style)
self.config.axis.y_label = label
def set_x_scale(self, scale: Literal["linear", "log"] = "linear"):

View File

@@ -54,6 +54,7 @@ class BECWaveform(BECPlotBase):
"set_grid",
"lock_aspect_ratio",
"remove",
"set_legend_label_size",
]
scan_signal_update = pyqtSignal()
@@ -401,6 +402,7 @@ class BECWaveform(BECPlotBase):
self.config.curves[name] = curve.config
if data is not None:
curve.setData(data[0], data[1])
self.set_legend_label_size()
return curve
def _validate_signal_entries(

View File

@@ -1,53 +1,37 @@
import qdarktheme
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QDoubleSpinBox,
QFrame,
QGridLayout,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QLayout,
QLineEdit,
QPushButton,
QSpinBox,
QTableWidget,
QTableWidgetItem,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.utils import BECConnector
from bec_widgets.widgets.buttons.stop_button.stop_button import StopButton
from bec_widgets.widgets.scan_control.scan_group_box import ScanGroupBox
class ScanArgType:
DEVICE = "device"
FLOAT = "float"
INT = "int"
BOOL = "bool"
STR = "str"
class ScanControl(BECConnector, QWidget):
class ScanControl(QWidget):
WIDGET_HANDLER = {
ScanArgType.DEVICE: QLineEdit,
ScanArgType.FLOAT: QDoubleSpinBox,
ScanArgType.INT: QSpinBox,
ScanArgType.BOOL: QCheckBox,
ScanArgType.STR: QLineEdit,
}
def __init__(self, parent=None, client=None, allowed_scans=None):
super().__init__(parent)
def __init__(
self, parent=None, client=None, gui_id: str | None = None, allowed_scans: list | None = None
):
super().__init__(client=client, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
# Client from BEC + shortcuts to device manager and scans
self.client = BECDispatcher().client if client is None else client
self.dev = self.client.device_manager.devices
self.scans = self.client.scans
self.get_bec_shortcuts()
# Main layout
self.layout = QVBoxLayout(self)
self.arg_box = None
self.kwarg_boxes = []
self.expert_mode = False # TODO implement in the future versions
# Scan list - allowed scans for the GUI
self.allowed_scans = allowed_scans
@@ -56,389 +40,173 @@ class ScanControl(QWidget):
self._init_UI()
def _init_UI(self):
self.verticalLayout = QVBoxLayout(self)
"""
Initializes the UI of the scan control widget. Create the top box for scan selection and populate scans to main combobox.
"""
# Scan selection group box
self.scan_selection_group = QGroupBox("Scan Selection", self)
self.scan_selection_layout = QVBoxLayout(self.scan_selection_group)
self.comboBox_scan_selection = QComboBox(self.scan_selection_group)
self.button_run_scan = QPushButton("Run Scan", self.scan_selection_group)
self.scan_selection_layout.addWidget(self.comboBox_scan_selection)
self.scan_selection_layout.addWidget(self.button_run_scan)
self.verticalLayout.addWidget(self.scan_selection_group)
# Scan control group box
self.scan_control_group = QGroupBox("Scan Control", self)
self.scan_control_layout = QVBoxLayout(self.scan_control_group)
self.verticalLayout.addWidget(self.scan_control_group)
# Kwargs layout - just placeholder
self.kwargs_layout = QGridLayout()
self.scan_control_layout.addLayout(self.kwargs_layout)
# 1st Separator
self.add_horizontal_separator(self.scan_control_layout)
# Buttons
self.button_layout = QHBoxLayout()
self.pushButton_add_bundle = QPushButton("Add Bundle", self.scan_control_group)
self.pushButton_add_bundle.clicked.connect(self.add_bundle)
self.pushButton_remove_bundle = QPushButton("Remove Bundle", self.scan_control_group)
self.pushButton_remove_bundle.clicked.connect(self.remove_bundle)
self.button_layout.addWidget(self.pushButton_add_bundle)
self.button_layout.addWidget(self.pushButton_remove_bundle)
self.scan_control_layout.addLayout(self.button_layout)
# 2nd Separator
self.add_horizontal_separator(self.scan_control_layout)
# Initialize the QTableWidget for args
self.args_table = QTableWidget()
self.args_table.verticalHeader().setSectionResizeMode(QHeaderView.Fixed)
self.scan_control_layout.addWidget(self.args_table)
self.scan_selection_group = self.create_scan_selection_group()
self.scan_selection_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.layout.addWidget(self.scan_selection_group)
# Connect signals
self.comboBox_scan_selection.currentIndexChanged.connect(self.on_scan_selected)
self.button_run_scan.clicked.connect(self.run_scan)
self.button_add_bundle.clicked.connect(self.add_arg_bundle)
self.button_remove_bundle.clicked.connect(self.remove_arg_bundle)
# Initialize scan selection
self.populate_scans()
def add_horizontal_separator(self, layout) -> None:
def create_scan_selection_group(self) -> QGroupBox:
"""
Adds a horizontal separator to the given layout
Creates the scan selection group box with combobox to select the scan and start/stop button.
Args:
layout: Layout to add the separator to
Returns:
QGroupBox: Group box containing the scan selection widgets.
"""
separator = QFrame(self.scan_control_group)
separator.setFrameShape(QFrame.HLine)
separator.setFrameShadow(QFrame.Sunken)
layout.addWidget(separator)
scan_selection_group = QGroupBox("Scan Selection", self)
self.scan_selection_layout = QGridLayout(scan_selection_group)
self.comboBox_scan_selection = QComboBox(scan_selection_group)
# Run button
self.button_run_scan = QPushButton("Start", scan_selection_group)
self.button_run_scan.setStyleSheet("background-color: #559900; color: white")
# Stop button
self.button_stop_scan = StopButton(parent=scan_selection_group)
# Add bundle button
self.button_add_bundle = QPushButton("Add Bundle", scan_selection_group)
# Remove bundle button
self.button_remove_bundle = QPushButton("Remove Bundle", scan_selection_group)
self.scan_selection_layout.addWidget(self.comboBox_scan_selection, 0, 0, 1, 2)
self.scan_selection_layout.addWidget(self.button_run_scan, 1, 0)
self.scan_selection_layout.addWidget(self.button_stop_scan, 1, 1)
self.scan_selection_layout.addWidget(self.button_add_bundle, 2, 0)
self.scan_selection_layout.addWidget(self.button_remove_bundle, 2, 1)
return scan_selection_group
def populate_scans(self):
"""Populates the scan selection combo box with available scans"""
self.available_scans = self.client.producer.get(MessageEndpoints.available_scans()).resource
"""Populates the scan selection combo box with available scans from BEC session."""
self.available_scans = self.client.connector.get(
MessageEndpoints.available_scans()
).resource
if self.allowed_scans is None:
allowed_scans = self.available_scans.keys()
supported_scans = ["ScanBase", "SyncFlyScanBase", "AsyncFlyScanBase"]
allowed_scans = [
scan_name
for scan_name, scan_info in self.available_scans.items()
if scan_info["base_class"] in supported_scans and len(scan_info["gui_config"]) > 0
]
else:
allowed_scans = self.allowed_scans
# TODO check parent class is ScanBase -> filter out the scans not relevant for GUI
self.comboBox_scan_selection.addItems(allowed_scans)
def on_scan_selected(self):
"""Callback for scan selection combo box"""
self.reset_layout()
selected_scan_name = self.comboBox_scan_selection.currentText()
selected_scan_info = self.available_scans.get(selected_scan_name, {})
print(selected_scan_info) # TODO remove when widget will be more mature
# Generate kwargs input
self.generate_kwargs_input_fields(selected_scan_info)
gui_config = selected_scan_info.get("gui_config", {})
self.arg_group = gui_config.get("arg_group", None)
self.kwarg_groups = gui_config.get("kwarg_groups", None)
# Args section
self.generate_args_input_fields(selected_scan_info)
if self.arg_box is None:
self.button_add_bundle.setEnabled(False)
self.button_remove_bundle.setEnabled(False)
def add_labels_to_layout(self, labels: list, grid_layout: QGridLayout) -> None:
if len(self.arg_group["arg_inputs"]) > 0:
self.button_add_bundle.setEnabled(True)
self.button_remove_bundle.setEnabled(True)
self.add_arg_group(self.arg_group)
if len(self.kwarg_groups) > 0:
self.add_kwargs_boxes(self.kwarg_groups)
self.update()
self.adjustSize()
def add_kwargs_boxes(self, groups: list):
"""
Adds labels to the given grid layout as a separate row.
Adds the given gui_groups to the scan control layout.
Args:
labels (list): List of label names to add.
grid_layout (QGridLayout): The grid layout to which labels will be added.
groups(list): List of dictionaries containing the gui_group information.
"""
row_index = grid_layout.rowCount() # Get the next available row
for column_index, label_name in enumerate(labels):
label = QLabel(label_name.capitalize(), self.scan_control_group)
# Add the label to the grid layout at the calculated row and current column
grid_layout.addWidget(label, row_index, column_index)
for group in groups:
box = ScanGroupBox(box_type="kwargs", config=group)
box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.layout.addWidget(box)
self.kwarg_boxes.append(box)
def add_labels_to_table(
self, labels: list, table: QTableWidget
) -> None: # TODO could be moved to BECTable
def add_arg_group(self, group: dict):
"""
Adds labels to the given table widget as a header row.
Adds the given gui_groups to the scan control layout.
Args:
labels(list): List of label names to add.
table(QTableWidget): The table widget to which labels will be added.
"""
table.setColumnCount(len(labels))
table.setHorizontalHeaderLabels(labels)
self.arg_box = ScanGroupBox(box_type="args", config=group)
self.arg_box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.layout.addWidget(self.arg_box)
def generate_args_input_fields(self, scan_info: dict) -> None:
"""
Generates input fields for args.
def add_arg_bundle(self):
self.arg_box.add_widget_bundle()
Args:
scan_info(dict): Scan signature dictionary from BEC.
"""
def remove_arg_bundle(self):
self.arg_box.remove_widget_bundle()
# Setup args table limits
self.set_args_table_limits(self.args_table, scan_info)
def reset_layout(self):
"""Clears the scan control layout from GuiGroups and ArgGroups boxes."""
if self.arg_box is not None:
self.layout.removeWidget(self.arg_box)
self.arg_box.deleteLater()
self.arg_box = None
if self.kwarg_boxes != []:
self.remove_kwarg_boxes()
# Get arg_input from selected scan
self.arg_input = scan_info.get("arg_input", {})
# Generate labels for table
self.add_labels_to_table(list(self.arg_input.keys()), self.args_table)
# add minimum number of args rows
if self.arg_size_min is not None:
for i in range(self.arg_size_min):
self.add_bundle()
def generate_kwargs_input_fields(self, scan_info: dict) -> None:
"""
Generates input fields for kwargs
Args:
scan_info(dict): Scan signature dictionary from BEC.
"""
# Create a new kwarg layout to replace the old one - this is necessary because otherwise row count is not reseted
self.clear_and_delete_layout(self.kwargs_layout)
self.kwargs_layout = self.create_new_grid_layout() # Create new grid layout
self.scan_control_layout.insertLayout(0, self.kwargs_layout)
# Get signature
signature = scan_info.get("signature", [])
# Extract kwargs from the converted signature
kwargs = [param["name"] for param in signature if param["kind"] == "KEYWORD_ONLY"]
# Add labels
self.add_labels_to_layout(kwargs, self.kwargs_layout)
# Add widgets
widgets = self.generate_widgets_from_signature(kwargs, signature)
self.add_widgets_row_to_layout(self.kwargs_layout, widgets)
def generate_widgets_from_signature(self, items: list, signature: dict = None) -> list:
"""
Generates widgets from the given list of items.
Args:
items(list): List of items to create widgets for.
signature(dict, optional): Scan signature dictionary from BEC.
Returns:
list: List of widgets created from the given items.
"""
widgets = [] # Initialize an empty list to hold the widgets
for item in items:
if signature:
# If a signature is provided, extract type and name from it
kwarg_info = next((info for info in signature if info["name"] == item), None)
if kwarg_info:
item_type = kwarg_info.get("annotation", "_empty")
item_name = item
else:
# If no signature is provided, assume the item is a tuple of (name, type)
item_name, item_type = item
widget_class = self.WIDGET_HANDLER.get(item_type, None)
if widget_class is None:
print(f"Unsupported annotation '{item_type}' for parameter '{item_name}'")
continue
# Instantiate the widget and set some properties if necessary
widget = widget_class()
# set high default range for spin boxes #TODO can be linked to motor/device limits from BEC
if isinstance(widget, (QSpinBox, QDoubleSpinBox)):
widget.setRange(-9999, 9999)
widget.setValue(0)
# Add the widget to the list
widgets.append(widget)
return widgets
def set_args_table_limits(self, table: QTableWidget, scan_info: dict) -> None:
# Get bundle info
arg_bundle_size = scan_info.get("arg_bundle_size", {})
self.arg_size_min = arg_bundle_size.get("min", 1)
self.arg_size_max = arg_bundle_size.get("max", None)
# Clear the previous input fields
table.setRowCount(0) # Wipe table
def add_widgets_row_to_layout(
self, grid_layout: QGridLayout, widgets: list, row_index: int = None
) -> None:
"""
Adds a row of widgets to the given grid layout.
Args:
grid_layout (QGridLayout): The grid layout to which widgets will be added.
items (list): List of parameter names to create widgets for.
row_index (int): The row index where the widgets should be added.
"""
# If row_index is not specified, add to the next available row
if row_index is None:
row_index = grid_layout.rowCount()
for column_index, widget in enumerate(widgets):
# Add the widget to the grid layout at the specified row and column
grid_layout.addWidget(widget, row_index, column_index)
def add_widgets_row_to_table(
self, table_widget: QTableWidget, widgets: list, row_index: int = None
) -> None:
"""
Adds a row of widgets to the given QTableWidget.
Args:
table_widget (QTableWidget): The table widget to which widgets will be added.
widgets (list): List of widgets to add to the table.
row_index (int): The row index where the widgets should be added. If None, add to the end.
"""
# If row_index is not specified, add to the end of the table
if row_index is None or row_index > table_widget.rowCount():
row_index = table_widget.rowCount()
if self.arg_size_max is not None: # ensure the max args size is not exceeded
if row_index >= self.arg_size_max:
return
table_widget.insertRow(row_index)
for column_index, widget in enumerate(widgets):
# If the widget is a subclass of QWidget, use setCellWidget
if issubclass(type(widget), QWidget):
table_widget.setCellWidget(row_index, column_index, widget)
else:
# Otherwise, assume it's a string or some other value that should be displayed as text
item = QTableWidgetItem(str(widget))
table_widget.setItem(row_index, column_index, item)
# Optionally, adjust the row height based on the content #TODO decide if needed
table_widget.setRowHeight(
row_index,
max(widget.sizeHint().height() for widget in widgets if isinstance(widget, QWidget)),
)
def remove_last_row_from_table(self, table_widget: QTableWidget) -> None:
"""
Removes the last row from the given QTableWidget until only one row is left.
Args:
table_widget (QTableWidget): The table widget from which the last row will be removed.
"""
row_count = table_widget.rowCount()
if (
row_count > self.arg_size_min
): # Check to ensure there is a minimum number of rows remaining
table_widget.removeRow(row_count - 1)
def create_new_grid_layout(self):
new_layout = QGridLayout()
# TODO maybe setup other layouts properties here?
return new_layout
def clear_and_delete_layout(self, layout: QLayout):
"""
Clears and deletes the given layout and all its child widgets.
Args:
layout(QLayout): Layout to clear and delete
"""
if layout is not None:
while layout.count():
item = layout.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()
else:
sub_layout = item.layout()
if sub_layout:
self.clear_and_delete_layout(sub_layout)
layout.deleteLater()
def add_bundle(self) -> None:
"""Adds a new bundle to the scan control layout"""
# Get widgets used for particular scan and save them to be able to use for adding bundles
args_widgets = self.generate_widgets_from_signature(
self.arg_input.items()
) # TODO decide if make sense to put widget list into method parameters
# Add first widgets row to the table
self.add_widgets_row_to_table(self.args_table, args_widgets)
def remove_bundle(self) -> None:
"""Removes the last bundle from the scan control layout"""
self.remove_last_row_from_table(self.args_table)
def extract_kwargs_from_grid_row(self, grid_layout: QGridLayout, row: int) -> dict:
kwargs = {}
for column in range(grid_layout.columnCount()):
label_item = grid_layout.itemAtPosition(row, column)
if label_item is not None:
label_widget = label_item.widget()
if isinstance(label_widget, QLabel):
key = label_widget.text()
# The corresponding value widget is in the next row
value_item = grid_layout.itemAtPosition(row + 1, column)
if value_item is not None:
value_widget = value_item.widget()
# Use WidgetIO.get_value to extract the value
value = WidgetIO.get_value(value_widget)
kwargs[key] = value
return kwargs
def extract_args_from_table(self, table: QTableWidget) -> list:
"""
Extracts the arguments from the given table widget.
Args:
table(QTableWidget): Table widget from which to extract the arguments
"""
args = []
for row in range(table.rowCount()):
row_args = []
for column in range(table.columnCount()):
widget = table.cellWidget(row, column)
if widget:
if isinstance(widget, QLineEdit): # special case for QLineEdit for Devices
value = widget.text().lower()
if value in self.dev:
value = getattr(self.dev, value)
else:
raise ValueError(f"The device '{value}' is not recognized.")
else:
value = WidgetIO.get_value(widget)
row_args.append(value)
args.extend(row_args)
return args
def remove_kwarg_boxes(self):
for box in self.kwarg_boxes:
self.layout.removeWidget(box)
box.deleteLater()
self.kwarg_boxes = []
def run_scan(self):
# Extract kwargs for the scan
kwargs = {
k.lower(): v
for k, v in self.extract_kwargs_from_grid_row(self.kwargs_layout, 1).items()
}
# Extract args from the table
args = self.extract_args_from_table(self.args_table)
# Convert args to lowercase if they are strings
args = [arg.lower() if isinstance(arg, str) else arg for arg in args]
# Execute the scan
args = []
kwargs = {}
if self.arg_box is not None:
args = self.arg_box.get_parameters()
for box in self.kwarg_boxes:
box_kwargs = box.get_parameters()
kwargs.update(box_kwargs)
scan_function = getattr(self.scans, self.comboBox_scan_selection.currentText())
if callable(scan_function):
scan_function(*args, **kwargs)
def cleanup(self):
self.button_stop_scan.cleanup()
if self.arg_box:
for widget in self.arg_box.widgets:
if hasattr(widget, "cleanup"):
widget.cleanup()
for kwarg_box in self.kwarg_boxes:
for widget in kwarg_box.widgets:
if hasattr(widget, "cleanup"):
widget.cleanup()
super().cleanup()
def closeEvent(self, event):
self.cleanup()
QWidget().closeEvent(event)
# Application example
if __name__ == "__main__": # pragma: no cover
# BECclient global variables
client = BECDispatcher().client
client.start()
app = QApplication([])
scan_control = ScanControl(client=client) # allowed_scans=["line_scan", "grid_scan"])
scan_control = ScanControl()
qdarktheme.setup_theme("auto")
window = scan_control
window.show()
app.exec()

View File

@@ -0,0 +1,223 @@
from typing import Literal
from qtpy.QtWidgets import (
QCheckBox,
QComboBox,
QDoubleSpinBox,
QGridLayout,
QGroupBox,
QLabel,
QLineEdit,
QSpinBox,
)
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.device_inputs import DeviceLineEdit
class ScanArgType:
DEVICE = "device"
FLOAT = "float"
INT = "int"
BOOL = "bool"
STR = "str"
DEVICEBASE = "DeviceBase"
LITERALS = "dict"
class ScanSpinBox(QSpinBox):
def __init__(
self, parent=None, arg_name: str = None, default: int | None = None, *args, **kwargs
):
super().__init__(parent=parent, *args, **kwargs)
self.arg_name = arg_name
self.setRange(-9999, 9999)
if default is not None:
self.setValue(default)
class ScanDoubleSpinBox(QDoubleSpinBox):
def __init__(
self, parent=None, arg_name: str = None, default: float | None = None, *args, **kwargs
):
super().__init__(parent=parent, *args, **kwargs)
self.arg_name = arg_name
self.setRange(-9999, 9999)
if default is not None:
self.setValue(default)
class ScanLineEdit(QLineEdit):
def __init__(
self, parent=None, arg_name: str = None, default: str | None = None, *args, **kwargs
):
super().__init__(parent=parent, *args, **kwargs)
self.arg_name = arg_name
if default is not None:
self.setText(default)
class ScanCheckBox(QCheckBox):
def __init__(
self, parent=None, arg_name: str = None, default: bool | None = None, *args, **kwargs
):
super().__init__(parent=parent, *args, **kwargs)
self.arg_name = arg_name
if default is not None:
self.setChecked(default)
class ScanGroupBox(QGroupBox):
WIDGET_HANDLER = {
ScanArgType.DEVICE: DeviceLineEdit,
ScanArgType.DEVICEBASE: DeviceLineEdit,
ScanArgType.FLOAT: ScanDoubleSpinBox,
ScanArgType.INT: ScanSpinBox,
ScanArgType.BOOL: ScanCheckBox,
ScanArgType.STR: ScanLineEdit,
ScanArgType.LITERALS: QComboBox, # TODO figure out combobox logic
}
def __init__(
self,
parent=None,
box_type=Literal["args", "kwargs"],
config: dict | None = None,
*args,
**kwargs,
):
super().__init__(parent=parent, *args, **kwargs)
self.config = config
self.box_type = box_type
self.layout = QGridLayout(self)
self.labels = []
self.widgets = []
self.init_box(self.config)
def init_box(self, config: dict):
box_name = config.get("name", "ScanGroupBox")
self.inputs = config.get("inputs", {})
self.setTitle(box_name)
# Labels
self.add_input_labels(self.inputs, 0)
# Widgets
if self.box_type == "args":
min_bundle = self.config.get("min", 1)
for i in range(1, min_bundle + 1):
self.add_input_widgets(self.inputs, i)
else:
self.add_input_widgets(self.inputs, 1)
def add_input_labels(self, group_inputs: dict, row: int) -> None:
"""
Adds the given arg_group from arg_bundle to the scan control layout. The input labels are always added to the first row.
Args:
group(dict): Dictionary containing the arg_group information.
"""
for column_index, item in enumerate(group_inputs):
arg_name = item.get("name", None)
display_name = item.get("display_name", arg_name)
label = QLabel(text=display_name)
self.layout.addWidget(label, row, column_index)
self.labels.append(label)
def add_input_widgets(self, group_inputs: dict, row) -> None:
"""
Adds the given arg_group from arg_bundle to the scan control layout.
Args:
group_inputs(dict): Dictionary containing the arg_group information.
row(int): The row to add the widgets to.
"""
for column_index, item in enumerate(group_inputs):
arg_name = item.get("name", None)
default = item.get("default", None)
widget = self.WIDGET_HANDLER.get(item["type"], None)
if widget is None:
print(f"Unsupported annotation '{item['type']}' for parameter '{item['name']}'")
continue
if default == "_empty":
default = None
widget_to_add = widget(arg_name=arg_name, default=default)
tooltip = item.get("tooltip", None)
if tooltip is not None:
widget_to_add.setToolTip(item["tooltip"])
self.layout.addWidget(widget_to_add, row, column_index)
self.widgets.append(widget_to_add)
def add_widget_bundle(self):
"""
Adds a new row of widgets to the scan control layout. Only usable for arg_groups.
"""
if self.box_type != "args":
return
arg_max = self.config.get("max", None)
row = self.layout.rowCount()
if arg_max is not None and row >= arg_max:
return
self.add_input_widgets(self.inputs, row)
def remove_widget_bundle(self):
"""
Removes the last row of widgets from the scan control layout. Only usable for arg_groups.
"""
if self.box_type != "args":
return
arg_min = self.config.get("min", None)
row = self.count_arg_rows()
if arg_min is not None and row <= arg_min:
return
for widget in self.widgets[-len(self.inputs) :]:
widget.deleteLater()
self.widgets = self.widgets[: -len(self.inputs)]
def get_parameters(self):
"""
Returns the parameters from the widgets in the scan control layout formated to run scan from BEC.
"""
if self.box_type == "args":
return self._get_arg_parameterts()
elif self.box_type == "kwargs":
return self._get_kwarg_parameters()
def _get_arg_parameterts(self):
args = []
for i in range(1, self.layout.rowCount()):
for j in range(self.layout.columnCount()):
widget = self.layout.itemAtPosition(i, j).widget()
if isinstance(widget, DeviceLineEdit):
value = widget.get_device()
else:
value = WidgetIO.get_value(widget)
args.append(value)
return args
def _get_kwarg_parameters(self):
kwargs = {}
for i in range(self.layout.columnCount()):
widget = self.layout.itemAtPosition(1, i).widget()
if isinstance(widget, DeviceLineEdit):
value = widget.get_device()
else:
value = WidgetIO.get_value(widget)
kwargs[widget.arg_name] = value
return kwargs
def count_arg_rows(self):
widget_rows = 0
for row in range(self.layout.rowCount()):
for col in range(self.layout.columnCount()):
item = self.layout.itemAtPosition(row, col)
if item is not None:
widget = item.widget()
if widget is not None:
if isinstance(widget, DeviceLineEdit):
widget_rows += 1
return widget_rows

View File

View File

@@ -0,0 +1,127 @@
import re
from pydantic import Field, field_validator
from qtpy.QtWidgets import QTextEdit
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.colors import Colors
class TextBoxConfig(ConnectionConfig):
theme: str = Field("dark", description="The theme of the figure widget.")
font_color: str = Field("#FFF", description="The font color of the text")
background_color: str = Field("#000", description="The background color of the widget.")
font_size: int = Field(16, description="The font size of the text in the widget.")
text: str = Field("", description="The text to display in the widget.")
@classmethod
@field_validator("theme")
def validate_theme(cls, v):
"""Validate the theme of the figure widget."""
if v not in ["dark", "light"]:
raise ValueError("Theme must be either 'dark' or 'light'")
return v
_validate_font_color = field_validator("font_color")(Colors.validate_color)
_validate_background_color = field_validator("background_color")(Colors.validate_color)
class TextBox(BECConnector, QTextEdit):
USER_ACCESS = ["set_color", "set_text", "set_font_size"]
def __init__(self, text: str = "", parent=None, client=None, config=None, gui_id=None):
if config is None:
config = TextBoxConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = TextBoxConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
QTextEdit.__init__(self, parent=parent)
self.config = config
self.setReadOnly(True)
self.setGeometry(self.rect())
self.set_color(self.config.background_color, self.config.font_color)
if not text:
text = "<h1>Welcome to the BEC Widget TextBox</h1><p>A widget that allows user to display text in plain and HTML format.</p><p>This is an example of displaying HTML text.</p>"
self.set_text(text)
def change_theme(self) -> None:
"""
Change the theme of the figure widget.
"""
if self.config.theme == "dark":
theme = "light"
font_color = "#000"
background_color = "#FFF"
else:
theme = "dark"
font_color = "#FFF"
background_color = "#000"
self.config.theme = theme
self.set_color(background_color, font_color)
def set_color(self, background_color: str, font_color: str) -> None:
"""Set the background color of the widget.
Args:
background_color (str): The color to set the background in HEX.
font_color (str): The color to set the font in HEX.
"""
self.config.background_color = background_color
self.config.font_color = font_color
self._update_stylesheet()
def set_font_size(self, size: int) -> None:
"""Set the font size of the text in the widget.
Args:
size (int): The font size to set.
"""
self.config.font_size = size
self._update_stylesheet()
def _update_stylesheet(self):
"""Update the stylesheet of the widget."""
self.setStyleSheet(
f"background-color: {self.config.background_color}; color: {self.config.font_color}; font-size: {self.config.font_size}px"
)
def set_text(self, text: str) -> None:
"""Set the text of the widget.
Args:
text (str): The text to set.
"""
if self.is_html(text):
self.setHtml(text)
else:
self.setPlainText(text)
self.config.text = text
def is_html(self, text: str) -> bool:
"""Check if the text contains HTML tags.
Args:
text (str): The text to check.
Returns:
bool: True if the text contains HTML tags, False otherwise.
"""
return bool(re.search(r"<[a-zA-Z/][^>]*>", text))
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = TextBox()
widget.show()
sys.exit(app.exec())

View File

View File

@@ -0,0 +1,86 @@
import os
import select
import shlex
import signal
import subprocess
import sys
from bec_widgets.widgets.website.website import WebsiteWidget
class VSCodeEditor(WebsiteWidget):
"""
A widget to display the VSCode editor.
"""
token = "bec"
host = "127.0.0.1"
port = 7000
USER_ACCESS = []
def __init__(self, parent=None, config=None, client=None, gui_id=None):
self.process = None
self._url = f"http://{self.host}:{self.port}?tkn={self.token}"
super().__init__(parent=parent, config=config, client=client, gui_id=gui_id)
self.start_server()
def start_server(self):
"""
Start the server.
This method starts the server for the VSCode editor in a subprocess.
"""
cmd = shlex.split(
f"code serve-web --port {self.port} --connection-token={self.token} --accept-server-license-terms"
)
self.process = subprocess.Popen(
cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, preexec_fn=os.setsid
)
os.set_blocking(self.process.stdout.fileno(), False)
while self.process.poll() is None:
readylist, _, _ = select.select([self.process.stdout], [], [], 1)
if self.process.stdout in readylist:
output = self.process.stdout.read(1024)
if output and f"available at {self._url}" in output:
break
self.set_url(self._url)
def closeEvent(self, event):
"""
Hook for the close event to terminate the server.
"""
self.cleanup_vscode()
super().closeEvent(event)
def cleanup_vscode(self):
"""
Cleanup the VSCode editor.
"""
if not self.process:
return
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
self.process.wait()
def cleanup(self):
"""
Cleanup the widget. This method is called from the dock area when the widget is removed.
"""
self.cleanup_vscode()
return super().cleanup()
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = VSCodeEditor()
widget.show()
app.exec_()
widget.bec_dispatcher.disconnect_all()
widget.client.shutdown()

View File

@@ -1,10 +1,19 @@
from qtpy.QtCore import QUrl
from qtpy.QtCore import QUrl, qInstallMessageHandler
from qtpy.QtWebEngineWidgets import QWebEngineView
from qtpy.QtWidgets import QApplication
from bec_widgets.utils import BECConnector
def suppress_qt_messages(type_, context, msg):
if context.category in ["js", "default"]:
return
print(msg)
qInstallMessageHandler(suppress_qt_messages)
class WebsiteWidget(BECConnector, QWebEngineView):
"""
A simple widget to display a website

View File

@@ -1,18 +1,46 @@
(developer)=
# Development
# Developer
To contribute to the development of BEC Widgets, start by setting up the development environment:
Welcome to the BEC Widgets developer guide! This section is intended for developers who want to contribute to the development of BEC Widgets.
1. **Clone the Repository**:
```bash
git clone https://gitlab.psi.ch/bec/bec_widgets
cd bec_widgets
```
2. **Install in Editable Mode**:
```{toctree}
---
maxdepth: 2
hidden: true
---
Installing the package in editable mode allows you to make changes to the code and test them in real-time.
```bash
pip install -e .[dev,pyqt6]
getting_started/getting_started.md
widgets/widgets.md
api_reference/api_reference.md
```
***
````{grid} 2
:gutter: 5
```{grid-item-card}
:link: developer.getting_started
:link-type: ref
:img-top: /assets/rocket_launch_48dp.svg
:text-align: center
## Getting Started
Learn how to install BEC Widgets and get started with the framework.
```
```{grid-item-card}
:link: developer.widgets
:link-type: ref
:img-top: /assets/apps_48dp.svg
:text-align: center
## Widgets
Learn about the building blocks of larger applications: widgets.
```
````

View File

@@ -0,0 +1,27 @@
(developer.development)=
# Development
If you like to contribute to the development of BEC Widgets, you can follow the steps below to set up your development environment.
BEC Widgets works in conjunction with [BEC](https://bec.readthedocs.io/en/latest/).
Therefore, we recommend that you install BEC first following the [developer instructions](https://bec.readthedocs.io/en/latest/developer/getting_started/install_developer_env.html) and include BEC Widgets.
If you already have a BEC environment set up, you can install BEC Widgets in editable mode into your BEC Python environment.
**Prerequisites**
1. **Python Version:** BEC Widgets requires Python version 3.10 or higher. Verify your Python version to ensure compatibility.
2. **BEC Installation:** BEC Widgets works in conjunction with BEC. While BEC is a dependency and will be installed automatically, you can find more information about BEC and its installation process in the [BEC documentation](https://beamline-experiment-control.readthedocs.io/en/latest/).
**Clone the Repository**:
```bash
git clone https://gitlab.psi.ch/bec/bec_widgets
cd bec_widgets
```
**Install in Editable Mode**:
Please install the package in editable mode into your BEC Python environemnt.
```bash
pip install -e '.[dev,pyqt6]'
```
This installs the package together with [PyQT6](https://www.riverbankcomputing.com/static/Docs/PyQt6/introduction.html).

View File

@@ -0,0 +1,12 @@
(developer.getting_started)=
# Getting Started
This section provides valuable information for developers who want to contribute to the development of BEC Widgets. The guide will help you set up the development environment, understand the modular development concept of BEC Widgets, and contribute to the project.
```{toctree}
---
maxdepth: 2
hidden: false
---
development/
```

View File

@@ -0,0 +1,11 @@
(developer.widgets)=
# Widgets
This section provides an introduction to the building blocks of BEC Widgets: widgets. Widgets are the basic components of the graphical user interface (GUI) and are used to create larger applications. We will cover key topics such as how to develop new widgets or how to customise existing widgets. For details on the already available widgets and their usage, please refer to user section about [widgets](#user.widgets)
```{toctree}
---
maxdepth: 2
hidden: false
---
```

View File

@@ -7,5 +7,6 @@ sphinx-copybutton
myst-parser
sphinx-design
PyQt6
PyQt6-WebEngine
bec-widgets
tomli

View File

@@ -1,8 +1,118 @@
(user.customisation)=
# Customisation
BEC Widgets are designed to be used with QtDesigner to quicly design GUI.
## Leveraging BEC Widgets in custom GUI applications
BEC Widgets can be used to compose a complete Qt graphical application, along with
other QWidgets. The only requirement is to connect to BEC servers in order to get
data, or to interact with BEC components. This role is devoted to the BECDispatcher,
a singleton object which has to be instantiated **after the QApplication is created**.
A typical BEC Widgets custom application "main" entry point should follow the template
below:
```
import argparse
import sys
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from qtpy.QtWidgets import QApplication
# optional command line arguments processing
parser = argparse.ArgumentParser(description="...")
parser.add_argument( ...)
...
args = parser.parse_args()
# creation of the Qt application
app = QApplication([])
# creation of BEC Dispatcher
# /!\ important: after the QApplication has been instantiated
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
client.start()
# (optional) processing of command line args,
# creation of a main window depending on the command line arguments (or not)
if args.xxx == "...":
window = ...
# display of the main window and start of Qt event loop
window.show()
sys.exit(app.exec())
```
The main "window" object presents the layout of widgets to the user and allows
users to interact. BEC Widgets must be placed in the window:
```
from qtpy.QWidgets import QMainWindow
from bec_widgets.widgets.figure import BECFigure
window = QMainWindow()
bec_figure = BECFigure(gui_id="my_gui_app_id")
window.setCentralWidget(bec_figure)
# prepare to plot samx motor vs bpm4i value
bec_figure.plot(x_name="samx", y_name="bpm4i")
```
In the example just above, the resulting application will show a plot of samx
positions on the horizontal axis, and beam intensity on the vertical axis
(when the next scan will be started).
It is important to ensure proper cleanup of the resources is done when application
quits:
```
def final_cleanup():
bec_figure.clear_all()
bec_figure.client.shutdown()
window.aboutToQuit.connect(final_cleanup)
```
Final example:
```
import sys
from qtpy.QtWidgets import QMainWindow, QApplication
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.utils.bec_dispatcher import BECDispatcher
# creation of the Qt application
app = QApplication([])
# creation of BEC Dispatcher
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
client.start()
# creation of main window
window = QMainWindow()
# inserting BEC Widgets
bec_figure = BECFigure(parent=window, gui_id="my_gui_app_id")
window.setCentralWidget(bec_figure)
bec_figure.plot(x_name="samx", y_name="bpm4i")
# ensuring proper cleanup
def final_cleanup():
bec_figure.clear_all()
bec_figure.client.shutdown()
app.aboutToQuit.connect(final_cleanup)
# execution
window.show()
sys.exit(app.exec())
```
## Writing applications using Qt Designer
BEC Widgets are designed to be used with QtDesigner to quickly design GUI.
## Example of promoting widgets in Qt Designer

View File

@@ -9,17 +9,17 @@ Before installing BEC Widgets, please ensure the following requirements are met:
**Standard Installation**
To install BEC Widgets using the pip package manager, execute the following command in your terminal for getting the default PyQT6 version in your python environment:
To install BEC Widgets using the pip package manager, execute the following command in your terminal for getting the default PyQT6 version into your python environment for BEC:
```bash
pip install bec_widgets[pyqt6]
pip install 'bec_widgets[pyqt6]'
```
In case you want to use PyQt5, you can install it by using the following command:
```bash
pip install bec_widgets[pyqt5]
pip install 'bec_widgets[pyqt5]'
```
**Troubleshooting**

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -0,0 +1,30 @@
(user.widgets.bec_status_box)=
# BEC Status Box
**Purpose:**
The [BECStatusBox](/api_reference/_autosummary/bec_widgets.cli.client.BECStatusBox) is a widget that allows you to monitor the status/health of the all running BEC processes. The widget generates the view automatically and updates the status of the processes in real-time. The top level indicates the overall state of the BEC core services (DeviceServer, ScanServer, SciHub, ScanBundler and FileWriter), but you can also see the status of each individual process by opening the collapsed view. In the collapsed view, you can double click on each process to get a popup window with live updates of the metrics for each process in real-time.
**Key Features:**
- monitor the state of individual BEC services.
- automatically track BEC services, i.e. additional clients connecting.
- live-updates of the metrics for each process.
**Example of Use:**
![BECStatus](./bec_status_box.gif)
**Code example:**
The following code snipped demonstrates how to create a `BECStatusBox` widget using BEC Widgets within BEC.
```python
bec_status_box = gui.add_dock().add_widget("BECStatusBox")
```

View File

@@ -0,0 +1,37 @@
(user.widgets.buttons)=
# Buttons Widgets
This section consolidates various custom buttons used within the BEC GUIs, facilitating the integration of these
controls into different layouts.
## Stop Button
**Purpose:**
The `Stop Button` provides a user interface control to immediately halt the execution of the current operation in the
BEC Client. It is designed for easy integration into any BEC GUI layout.
**Key Features:**
- **Immediate Termination:** Halts the execution of the current script or process.
- **Queue Management:** Clears any pending operations in the scan queue, ensuring the system is ready for new tasks.
**Code example:**
Integrating the `StopButton` into a BEC GUI layout is straightforward. The following example demonstrates how to embed
a `StopButton` within a GUI layout:
```python
from qtpy.QtWidgets import QWidget, QVBoxLayout
from bec_widgets.widgets.buttons import StopButton
class MyGui(QWidget):
def __init__(self):
super().__init__()
self.setLayout(QVBoxLayout(self)) # Initialize the layout for the widget
# Create and add the StopButton to the layout
self.stop_button = StopButton()
self.layout().addWidget(self.stop_button)
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,35 @@
(user.widgets.scan_control)=
# Scan Control
**Purpose:**
The `ScanControl` widget is designed to generate a graphical user interface (GUI) to control various scan operations
based on the scan's signature and `gui_config`. The widget is used to control the scan operations, such as starting,
stopping, and pausing the scan. The widget also provides a graphical representation of the scan progress and the scan
status. The widget is designed to be used in conjunction with the `ScanServer` and `ScanBundler` services from the BEC
core services.
By default the widget supports only the scans which have defined `gui_config` and are inhereted from these scan classes:
- [ScanBase](https://beamline-experiment-control.readthedocs.io/en/latest/api_reference/_autosummary/bec_server.scan_server.scans.ScanBase.html)
- [SyncFlyScanBase](https://beamline-experiment-control.readthedocs.io/en/latest/api_reference/_autosummary/bec_server.scan_server.scans.SyncFlyScanBase.html)
- [AsyncFlyScanBase](https://beamline-experiment-control.readthedocs.io/en/latest/api_reference/_autosummary/bec_server.scan_server.scans.AsyncFlyScanBase.html)
**Key Features:**
- Automatically generates a control interface based on scan signatures and `gui_config`.
- Supports adding and removing argument bundles dynamically.
- Provides a visual representation of scan parameters grouped by functionality.
- Integrates start and stop controls for executing and halting scans.
**Example of Use:**
**Code example:**
The following code snipped demonstrates how to create a `ScanControl` widget using BEC Widgets within `BECIPythonClient`
![ScanControl](./scan_control.gif)
```python
scan_control = gui.add_dock().add_widget("ScanControl")
```

View File

@@ -0,0 +1,33 @@
(user.widgets.text_box)=
# [Text Box Widget](/api_reference/_autosummary/bec_widgets.cli.client.TextBox)
**Purpose:**
The Text Box Widget is a widget that allows you to display text within the BEC GUI. The widget can be used to display plain text or HTML text.
**Key Features:**
- set the text to display.
- automatically detects if the text is plain text or HTML text.
- set background color and font color.
**Code example:**
The following code snipped demonstrates how to create a `TextBox` widget using BEC Widgets within BEC.
```python
text_box = gui.add_dock().add_widget("TextBox")
# set the text to display
text_box.set_text("Hello, World!")
# set the background color and font color
text_box.set_color(backgroud_color="#FFF", font_color="#000")
# set the text to display as HTML
text_box.set_text("<h1>Welcome to BEC Widgets</h1><p>This is an example of displaying <strong>HTML</strong> text.</p>")
```

View File

@@ -11,6 +11,10 @@ hidden: false
bec_figure/
spiral_progress_bar/
website/
buttons/
text_box/
bec_status_box/
```

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "0.60.0"
version = "0.72.0"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [
@@ -48,6 +48,8 @@ Homepage = "https://gitlab.psi.ch/bec/bec_widgets"
[project.scripts]
bw-generate-cli = "bec_widgets.cli.generate_cli:main"
bec-gui-server = "bec_widgets.cli.server:main"
bec-designer = "bec_widgets.utils.bec_designer:main"
[tool.hatch.build.targets.wheel]
include = ["*"]

View File

@@ -8,7 +8,8 @@ from bec_lib.endpoints import MessageEndpoints
from bec_widgets.cli.client_utils import _start_plot_process
from bec_widgets.cli.rpc_register import RPCRegister
from bec_widgets.utils import BECDispatcher
from bec_widgets.widgets import BECDockArea, BECFigure
from bec_widgets.widgets.dock import BECDockArea
from bec_widgets.widgets.figure import BECFigure
# make threads check in autouse, **will be executed at the end**; better than
@@ -28,9 +29,7 @@ def gui_id():
@contextmanager
def plot_server(gui_id, klass, client_lib):
dispatcher = BECDispatcher(client=client_lib) # Has to init singleton with fixture client
process, output_thread = _start_plot_process(
gui_id, klass, client_lib._client._service_config.redis
)
process, _ = _start_plot_process(gui_id, klass, client_lib._client._service_config.config_path)
try:
while client_lib._client.connector.get(MessageEndpoints.gui_heartbeat(gui_id)) is None:
time.sleep(0.3)
@@ -38,7 +37,6 @@ def plot_server(gui_id, klass, client_lib):
finally:
process.terminate()
process.wait()
output_thread.join()
dispatcher.disconnect_all()
dispatcher.reset_singleton()

View File

@@ -253,8 +253,14 @@ def test_auto_update(bec_client_lib, rpc_server_dock):
plt_data = widgets[0].get_all_data()
# check plotted data
assert plt_data["bpm4i-bpm4i"]["x"] == last_scan_data["samx"]["samx"].val
assert plt_data["bpm4i-bpm4i"]["y"] == last_scan_data["bpm4i"]["bpm4i"].val
assert (
plt_data[f"Scan {status.scan.scan_number} - bpm4i"]["x"]
== last_scan_data["samx"]["samx"].val
)
assert (
plt_data[f"Scan {status.scan.scan_number} - bpm4i"]["y"]
== last_scan_data["bpm4i"]["bpm4i"].val
)
status = scans.grid_scan(
dev.samx, -10, 10, 5, dev.samy, -5, 5, 5, exp_time=0.05, relative=False
@@ -268,5 +274,11 @@ def test_auto_update(bec_client_lib, rpc_server_dock):
last_scan_data = queue.scan_storage.storage[-1].data
# check plotted data
assert plt_data[f"Scan {status.scan.scan_number}"]["x"] == last_scan_data["samx"]["samx"].val
assert plt_data[f"Scan {status.scan.scan_number}"]["y"] == last_scan_data["samy"]["samy"].val
assert (
plt_data[f"Scan {status.scan.scan_number} - {dock.selected_device}"]["x"]
== last_scan_data["samx"]["samx"].val
)
assert (
plt_data[f"Scan {status.scan.scan_number} - {dock.selected_device}"]["y"]
== last_scan_data["samy"]["samy"].val
)

View File

@@ -0,0 +1,71 @@
import time
import pytest
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.scan_control import ScanControl
@pytest.fixture(scope="function")
def scan_control(qtbot, bec_client_lib): # , mock_dev):
widget = ScanControl(client=bec_client_lib)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_scan_control_populate_scans_e2e(scan_control):
expected_scans = [
"grid_scan",
"fermat_scan",
"round_scan",
"cont_line_scan",
"cont_line_fly_scan",
"round_scan_fly",
"round_roi_scan",
"time_scan",
"monitor_scan",
"acquire",
"line_scan",
]
items = [
scan_control.comboBox_scan_selection.itemText(i)
for i in range(scan_control.comboBox_scan_selection.count())
]
assert scan_control.comboBox_scan_selection.count() == len(expected_scans)
assert sorted(items) == sorted(expected_scans)
def test_run_line_scan_with_parameters_e2e(scan_control, bec_client_lib, qtbot):
client = bec_client_lib
queue = client.queue
scan_name = "line_scan"
kwargs = {"exp_time": 0.01, "steps": 10, "relative": True, "burst_at_each_point": 1}
args = {"device": "samx", "start": -5, "stop": 5}
scan_control.comboBox_scan_selection.setCurrentText(scan_name)
# Set kwargs in the UI
for kwarg_box in scan_control.kwarg_boxes:
for widget in kwarg_box.widgets:
for key, value in kwargs.items():
if widget.arg_name == key:
WidgetIO.set_value(widget, value)
break
# Set args in the UI
for widget in scan_control.arg_box.widgets:
for key, value in args.items():
if widget.arg_name == key:
WidgetIO.set_value(widget, value)
break
# Run the scan
scan_control.button_run_scan.click()
time.sleep(2)
last_scan = queue.scan_storage.storage[-1]
assert last_scan.status_message.info["scan_name"] == scan_name
assert last_scan.status_message.info["exp_time"] == kwargs["exp_time"]
assert last_scan.status_message.info["scan_motors"] == [args["device"]]
assert last_scan.status_message.info["num_points"] == kwargs["steps"]

View File

@@ -47,6 +47,7 @@ class FakePositioner(FakeDevice):
super().__init__(name, enabled)
self.limits = limits if limits is not None else [0, 0]
self.read_value = read_value
self.name = name
def set_read_value(self, value):
self.read_value = value

View File

@@ -1,5 +1,9 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import time
import pytest
from qtpy.QtCore import Slot
from qtpy.QtWidgets import QApplication
from bec_widgets.utils import BECConnector, ConnectionConfig
@@ -55,3 +59,22 @@ def test_bec_connector_update_client(bec_connector, mocked_client):
def test_bec_connector_get_config(bec_connector):
assert bec_connector.get_config(dict_output=False) == bec_connector.config
assert bec_connector.get_config() == bec_connector.config.model_dump()
def test_bec_connector_submit_task(bec_connector):
def test_func():
time.sleep(2)
print("done")
completed = False
@Slot()
def complete_func():
nonlocal completed
completed = True
bec_connector.submit_task(test_func, on_complete=complete_func)
assert not completed
while not completed:
QApplication.processEvents()
time.sleep(0.1)

View File

@@ -2,7 +2,7 @@
import pytest
from bec_widgets.widgets import BECDock, BECDockArea
from bec_widgets.widgets.dock import BECDock, BECDockArea
from .client_mocks import mocked_client

View File

@@ -3,7 +3,7 @@
import numpy as np
import pytest
from bec_widgets.widgets import BECFigure
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.figure.plots.image.image import BECImageShow
from bec_widgets.widgets.figure.plots.motor_map.motor_map import BECMotorMap
from bec_widgets.widgets.figure.plots.waveform.waveform import BECWaveform

View File

@@ -0,0 +1,152 @@
# import re
# from unittest import mock
#
# import pytest
# from bec_lib.messages import BECStatus, ServiceMetricMessage, StatusMessage
# from qtpy.QtCore import QMetaMethod
#
# from bec_widgets.widgets.bec_status_box.bec_status_box import BECServiceInfoContainer, BECStatusBox
#
# from .client_mocks import mocked_client
#
#
# @pytest.fixture
# def status_box(qtbot, mocked_client):
# with mock.patch(
# "bec_widgets.widgets.bec_status_box.bec_status_box.BECServiceStatusMixin"
# ) as mock_service_status_mixin:
# widget = BECStatusBox(client=mocked_client)
# qtbot.addWidget(widget)
# qtbot.waitExposed(widget)
# yield widget
#
#
# def test_status_box_init(qtbot, mocked_client):
# with mock.patch(
# "bec_widgets.widgets.bec_status_box.bec_status_box.BECServiceStatusMixin"
# ) as mock_service_status_mixin:
# name = "my test"
# widget = BECStatusBox(parent=None, service_name=name, client=mocked_client)
# qtbot.addWidget(widget)
# qtbot.waitExposed(widget)
# assert widget.headerItem().DontShowIndicator.value == 1
# assert widget.children()[0].children()[0].config.service_name == name
#
#
# def test_update_top_item(qtbot, mocked_client):
# with (
# mock.patch(
# "bec_widgets.widgets.bec_status_box.bec_status_box.BECServiceStatusMixin"
# ) as mock_service_status_mixin,
# mock.patch(
# "bec_widgets.widgets.bec_status_box.status_item.StatusItem.update_config"
# ) as mock_update,
# ):
# name = "my test"
# widget = BECStatusBox(parent=None, service_name=name, client=mocked_client)
# qtbot.addWidget(widget)
# qtbot.waitExposed(widget)
# widget.update_top_item_status(status="RUNNING")
# assert widget.bec_service_info_container[name].status == "RUNNING"
# assert mock_update.call_args == mock.call(widget.bec_service_info_container[name].dict())
#
#
# def test_create_status_widget(status_box):
# name = "test_service"
# status = BECStatus.IDLE
# info = {"test": "test"}
# metrics = {"metric": "test_metric"}
# item = status_box._create_status_widget(name, status, info, metrics)
# assert item.config.service_name == name
# assert item.config.status == status.name
# assert item.config.info == info
# assert item.config.metrics == metrics
#
#
# def test_bec_service_container(status_box):
# name = "test_service"
# status = BECStatus.IDLE
# info = {"test": "test"}
# metrics = {"metric": "test_metric"}
# expected_return = BECServiceInfoContainer(
# service_name=name, status=status, info=info, metrics=metrics
# )
# assert status_box.service_name in status_box.bec_service_info_container
# assert len(status_box.bec_service_info_container) == 1
# status_box._update_bec_service_container(name, status, info, metrics)
# assert len(status_box.bec_service_info_container) == 2
# assert status_box.bec_service_info_container[name] == expected_return
#
#
# def test_add_tree_item(status_box):
# name = "test_service"
# status = BECStatus.IDLE
# info = {"test": "test"}
# metrics = {"metric": "test_metric"}
# assert len(status_box.children()[0].children()) == 1
# status_box.add_tree_item(name, status, info, metrics)
# assert len(status_box.children()[0].children()) == 2
# assert name in status_box.tree_items
#
#
# def test_update_service_status(status_box):
# """Also checks check redundant tree items"""
# name = "test_service"
# status = BECStatus.IDLE
# info = {"test": "test"}
# metrics = {"metric": "test_metric"}
# status_box.add_tree_item(name, status, info, {})
# not_connected_name = "invalid_service"
# status_box.add_tree_item(not_connected_name, status, info, metrics)
#
# services_status = {name: StatusMessage(name=name, status=status, info=info)}
# services_metrics = {name: ServiceMetricMessage(name=name, metrics=metrics)}
#
# with mock.patch.object(status_box, "update_core_services", return_value=services_status):
# assert not_connected_name in status_box.tree_items
# status_box.update_service_status(services_status, services_metrics)
# assert status_box.tree_items[name][1].config.metrics == metrics
# assert not_connected_name not in status_box.tree_items
#
#
# def test_update_core_services(qtbot, mocked_client):
# with (
# mock.patch(
# "bec_widgets.widgets.bec_status_box.bec_status_box.BECServiceStatusMixin"
# ) as mock_service_status_mixin,
# mock.patch(
# "bec_widgets.widgets.bec_status_box.bec_status_box.BECStatusBox.update_top_item_status"
# ) as mock_update,
# ):
# name = "my test"
# status_box = BECStatusBox(parent=None, service_name=name, client=mocked_client)
# qtbot.addWidget(status_box)
# qtbot.waitExposed(status_box)
# status_box.CORE_SERVICES = ["test_service"]
# name = "test_service"
# status = BECStatus.RUNNING
# info = {"test": "test"}
# metrics = {"metric": "test_metric"}
# services_status = {name: StatusMessage(name=name, status=status, info=info)}
# services_metrics = {name: ServiceMetricMessage(name=name, metrics=metrics)}
#
# status_box.update_core_services(services_status, services_metrics)
# assert mock_update.call_args == mock.call(status.name)
#
# status = BECStatus.IDLE
# services_status = {name: StatusMessage(name=name, status=status, info=info)}
# services_metrics = {name: ServiceMetricMessage(name=name, metrics=metrics)}
# status_box.update_core_services(services_status, services_metrics)
# assert mock_update.call_args == mock.call("ERROR")
#
#
# def test_double_click_item(status_box):
# name = "test_service"
# status = BECStatus.IDLE
# info = {"test": "test"}
# metrics = {"MyData": "This should be shown nicely"}
# status_box.add_tree_item(name, status, info, metrics)
# item, status_item = status_box.tree_items[name]
# with mock.patch.object(status_item, "show_popup") as mock_show_popup:
# status_box.itemDoubleClicked.emit(item, 0)
# assert mock_show_popup.call_count == 1

View File

@@ -0,0 +1,66 @@
import pytest
from bec_widgets.widgets.device_inputs.device_input_base import DeviceInputBase
from .client_mocks import mocked_client
@pytest.fixture
def device_input_base(mocked_client):
widget = DeviceInputBase(client=mocked_client)
yield widget
def test_device_input_base_init(device_input_base):
assert device_input_base is not None
assert device_input_base.client is not None
assert isinstance(device_input_base, DeviceInputBase)
assert device_input_base.config.widget_class == "DeviceInputBase"
assert device_input_base.config.device_filter is None
assert device_input_base.config.default is None
assert device_input_base.devices == []
def test_device_input_base_init_with_config(mocked_client):
config = {
"widget_class": "DeviceInputBase",
"gui_id": "test_gui_id",
"device_filter": "FakePositioner",
"default": "samx",
}
widget = DeviceInputBase(client=mocked_client, config=config)
assert widget.config.gui_id == "test_gui_id"
assert widget.config.device_filter == "FakePositioner"
assert widget.config.default == "samx"
def test_device_input_base_set_device_filter(device_input_base):
device_input_base.set_device_filter("FakePositioner")
assert device_input_base.config.device_filter == "FakePositioner"
def test_device_input_base_set_device_filter_error(device_input_base):
with pytest.raises(ValueError) as excinfo:
device_input_base.set_device_filter("NonExistingClass")
assert "Device filter NonExistingClass is not in the device list." in str(excinfo.value)
def test_device_input_base_set_default_device(device_input_base):
device_input_base.set_default_device("samx")
assert device_input_base.config.default == "samx"
def test_device_input_base_set_default_device_error(device_input_base):
with pytest.raises(ValueError) as excinfo:
device_input_base.set_default_device("NonExistingDevice")
assert "Default device NonExistingDevice is not in the device list." in str(excinfo.value)
def test_device_input_base_get_device_list(device_input_base):
devices = device_input_base.get_device_list("FakePositioner")
assert devices == ["samx", "samy", "aptrx", "aptry"]
def test_device_input_base_get_filters(device_input_base):
filters = device_input_base.get_available_filters()
assert filters == {"FakePositioner", "FakeDevice"}

View File

@@ -0,0 +1,176 @@
import pytest
from bec_widgets.widgets.device_inputs.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.device_inputs.device_line_edit.device_line_edit import DeviceLineEdit
from .client_mocks import mocked_client
@pytest.fixture
def device_input_combobox(qtbot, mocked_client):
widget = DeviceComboBox(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.close()
@pytest.fixture
def device_input_combobox_with_config(qtbot, mocked_client):
config = {
"widget_class": "DeviceComboBox",
"gui_id": "test_gui_id",
"device_filter": "FakePositioner",
"default": "samx",
"arg_name": "test_arg_name",
}
widget = DeviceComboBox(client=mocked_client, config=config)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.close()
@pytest.fixture
def device_input_combobox_with_kwargs(qtbot, mocked_client):
widget = DeviceComboBox(
client=mocked_client,
gui_id="test_gui_id",
device_filter="FakePositioner",
default="samx",
arg_name="test_arg_name",
)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.close()
def test_device_input_combobox_init(device_input_combobox):
assert device_input_combobox is not None
assert device_input_combobox.client is not None
assert isinstance(device_input_combobox, DeviceComboBox)
assert device_input_combobox.config.widget_class == "DeviceComboBox"
assert device_input_combobox.config.device_filter is None
assert device_input_combobox.config.default is None
assert device_input_combobox.devices == [
"samx",
"samy",
"aptrx",
"aptry",
"gauss_bpm",
"gauss_adc1",
"gauss_adc2",
"gauss_adc3",
"bpm4i",
"bpm3a",
"bpm3i",
"eiger",
]
def test_device_input_combobox_init_with_config(device_input_combobox_with_config):
assert device_input_combobox_with_config.config.gui_id == "test_gui_id"
assert device_input_combobox_with_config.config.device_filter == "FakePositioner"
assert device_input_combobox_with_config.config.default == "samx"
assert device_input_combobox_with_config.config.arg_name == "test_arg_name"
def test_device_input_combobox_init_with_kwargs(device_input_combobox_with_kwargs):
assert device_input_combobox_with_kwargs.config.gui_id == "test_gui_id"
assert device_input_combobox_with_kwargs.config.device_filter == "FakePositioner"
assert device_input_combobox_with_kwargs.config.default == "samx"
assert device_input_combobox_with_kwargs.config.arg_name == "test_arg_name"
def test_get_device_from_input_combobox_init(device_input_combobox):
device_input_combobox.setCurrentIndex(0)
device_text = device_input_combobox.currentText()
current_device = device_input_combobox.get_device()
assert current_device.name == device_text
@pytest.fixture
def device_input_line_edit(qtbot, mocked_client):
widget = DeviceLineEdit(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.close()
@pytest.fixture
def device_input_line_edit_with_config(qtbot, mocked_client):
config = {
"widget_class": "DeviceLineEdit",
"gui_id": "test_gui_id",
"device_filter": "FakePositioner",
"default": "samx",
"arg_name": "test_arg_name",
}
widget = DeviceLineEdit(client=mocked_client, config=config)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.close()
@pytest.fixture
def device_input_line_edit_with_kwargs(qtbot, mocked_client):
widget = DeviceLineEdit(
client=mocked_client,
gui_id="test_gui_id",
device_filter="FakePositioner",
default="samx",
arg_name="test_arg_name",
)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.close()
def test_device_input_line_edit_init(device_input_line_edit):
assert device_input_line_edit is not None
assert device_input_line_edit.client is not None
assert isinstance(device_input_line_edit, DeviceLineEdit)
assert device_input_line_edit.config.widget_class == "DeviceLineEdit"
assert device_input_line_edit.config.device_filter is None
assert device_input_line_edit.config.default is None
assert device_input_line_edit.devices == [
"samx",
"samy",
"aptrx",
"aptry",
"gauss_bpm",
"gauss_adc1",
"gauss_adc2",
"gauss_adc3",
"bpm4i",
"bpm3a",
"bpm3i",
"eiger",
]
def test_device_input_line_edit_init_with_config(device_input_line_edit_with_config):
assert device_input_line_edit_with_config.config.gui_id == "test_gui_id"
assert device_input_line_edit_with_config.config.device_filter == "FakePositioner"
assert device_input_line_edit_with_config.config.default == "samx"
assert device_input_line_edit_with_config.config.arg_name == "test_arg_name"
def test_device_input_line_edit_init_with_kwargs(device_input_line_edit_with_kwargs):
assert device_input_line_edit_with_kwargs.config.gui_id == "test_gui_id"
assert device_input_line_edit_with_kwargs.config.device_filter == "FakePositioner"
assert device_input_line_edit_with_kwargs.config.default == "samx"
assert device_input_line_edit_with_kwargs.config.arg_name == "test_arg_name"
def test_get_device_from_input_line_edit_init(device_input_line_edit):
device_input_line_edit.setText("samx")
device_text = device_input_line_edit.text()
current_device = device_input_line_edit.get_device()
assert current_device.name == device_text

View File

@@ -97,17 +97,3 @@ def test_client_generator_with_black_formatting():
generated_output_formatted = isort.code(generated_output_formatted)
assert expected_output_formatted == generated_output_formatted
def test_client_generator_classes():
generator = ClientGenerator()
out = generator.get_rpc_classes("bec_widgets")
assert list(out.keys()) == ["connector_classes", "top_level_classes"]
connector_cls_names = [cls.__name__ for cls in out["connector_classes"]]
top_level_cls_names = [cls.__name__ for cls in out["top_level_classes"]]
assert "BECFigure" in connector_cls_names
assert "BECWaveform" in connector_cls_names
assert "BECDockArea" in top_level_cls_names
assert "BECFigure" in top_level_cls_names
assert "BECWaveform" not in top_level_cls_names

View File

@@ -1,5 +1,8 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
from unittest import mock
import pytest
from qtpy.QtGui import QFontInfo
from .client_mocks import mocked_client
from .test_bec_figure import bec_figure
@@ -37,6 +40,30 @@ def test_plot_base_axes_by_separate_methods(bec_figure):
assert plot_base.plot_item.ctrl.logXCheck.isChecked() == True
assert plot_base.plot_item.ctrl.logYCheck.isChecked() == True
# Check the font size by mocking the set functions
# I struggled retrieving it from the QFont object directly
# thus I mocked the set functions to check internally the functionality
with (
mock.patch.object(plot_base.plot_item, "setLabel") as mock_set_label,
mock.patch.object(plot_base.plot_item, "setTitle") as mock_set_title,
):
plot_base.set_x_label("Test x Label", 20)
plot_base.set_y_label("Test y Label", 16)
assert mock_set_label.call_count == 2
assert plot_base.config.axis.x_label_size == 20
assert plot_base.config.axis.y_label_size == 16
col = plot_base.get_text_color()
calls = []
style = {"color": col, "font-size": "20pt"}
calls.append(mock.call("bottom", "Test x Label", **style))
style = {"color": col, "font-size": "16pt"}
calls.append(mock.call("left", "Test y Label", **style))
assert mock_set_label.call_args_list == calls
plot_base.set_title("Test Title", 16)
style = {"color": col, "size": "16pt"}
call = mock.call("Test Title", **style)
assert mock_set_title.call_args == call
def test_plot_base_axes_added_by_kwargs(bec_figure):
plot_base = bec_figure.add_widget(widget_type="PlotBase", widget_id="test_plot")

View File

@@ -0,0 +1,14 @@
from bec_widgets.utils.plugin_utils import get_rpc_classes
def test_client_generator_classes():
out = get_rpc_classes("bec_widgets")
assert list(out.keys()) == ["connector_classes", "top_level_classes"]
connector_cls_names = [cls.__name__ for cls in out["connector_classes"]]
top_level_cls_names = [cls.__name__ for cls in out["top_level_classes"]]
assert "BECFigure" in connector_cls_names
assert "BECWaveform" in connector_cls_names
assert "BECDockArea" in top_level_cls_names
assert "BECFigure" in top_level_cls_names
assert "BECWaveform" not in top_level_cls_names

View File

@@ -0,0 +1,7 @@
from bec_widgets.cli.rpc_wigdet_handler import RPCWidgetHandler
def test_rpc_widget_handler():
handler = RPCWidgetHandler()
assert "BECFigure" in handler.widget_classes
assert "SpiralProgressBar" in handler.widget_classes

View File

@@ -2,131 +2,288 @@
from unittest.mock import MagicMock
import pytest
from qtpy.QtWidgets import QLineEdit
from bec_lib.messages import AvailableResourceMessage
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets import ScanControl
from tests.unit_tests.test_msgs.available_scans_message import available_scans_message
from bec_widgets.widgets.scan_control import ScanControl
from .client_mocks import mocked_client
class FakePositioner:
"""Fake minimal positioner class for testing."""
def __init__(self, name, enabled=True):
self.name = name
self.enabled = enabled
def __contains__(self, item):
return item == self.name
def get_mocked_device(device_name):
"""Helper function to mock the devices"""
if device_name == "samx":
return FakePositioner(name="samx", enabled=True)
@pytest.fixture(scope="function")
def mocked_client():
# Create a MagicMock object
client = MagicMock()
# Mock the producer.get method to return the packed message
client.producer.get.return_value = available_scans_message
# # Mock the device_manager.devices attribute to return a mock object for samx
client.device_manager.devices = MagicMock()
client.device_manager.devices.__contains__.side_effect = lambda x: x == "samx"
client.device_manager.devices.samx = get_mocked_device("samx")
return client
available_scans_message = AvailableResourceMessage(
resource={
"line_scan": {
"class": "LineScan",
"base_class": "ScanBase",
"arg_input": {"device": "device", "start": "float", "stop": "float"},
"gui_config": {
"scan_class_name": "LineScan",
"arg_group": {
"name": "Scan Arguments",
"bundle": 3,
"arg_inputs": {"device": "device", "start": "float", "stop": "float"},
"inputs": [
{
"arg": True,
"name": "device",
"type": "device",
"display_name": "Device",
"tooltip": None,
"default": None,
"expert": False,
},
{
"arg": True,
"name": "start",
"type": "float",
"display_name": "Start",
"tooltip": None,
"default": None,
"expert": False,
},
{
"arg": True,
"name": "stop",
"type": "float",
"display_name": "Stop",
"tooltip": None,
"default": None,
"expert": False,
},
],
"min": 1,
"max": None,
},
"kwarg_groups": [
{
"name": "Movement Parameters",
"inputs": [
{
"arg": False,
"name": "steps",
"type": "int",
"display_name": "Steps",
"tooltip": "Number of steps",
"default": None,
"expert": False,
},
{
"arg": False,
"name": "relative",
"type": "bool",
"display_name": "Relative",
"tooltip": "If True, the start and end positions are relative to the current position",
"default": False,
"expert": False,
},
],
},
{
"name": "Acquisition Parameters",
"inputs": [
{
"arg": False,
"name": "exp_time",
"type": "float",
"display_name": "Exp Time",
"tooltip": "Exposure time in s",
"default": 0,
"expert": False,
},
{
"arg": False,
"name": "burst_at_each_point",
"type": "int",
"display_name": "Burst At Each Point",
"tooltip": "Number of acquisition per point",
"default": 1,
"expert": False,
},
],
},
],
},
"required_kwargs": ["steps", "relative"],
"arg_bundle_size": {"bundle": 3, "min": 1, "max": None},
},
"grid_scan": {
"class": "Scan",
"base_class": "ScanBase",
"arg_input": {"device": "device", "start": "float", "stop": "float", "steps": "int"},
"gui_config": {
"scan_class_name": "Scan",
"arg_group": {
"name": "Scan Arguments",
"bundle": 4,
"arg_inputs": {
"device": "device",
"start": "float",
"stop": "float",
"steps": "int",
},
"inputs": [
{
"arg": True,
"name": "device",
"type": "device",
"display_name": "Device",
"tooltip": None,
"default": None,
"expert": False,
},
{
"arg": True,
"name": "start",
"type": "float",
"display_name": "Start",
"tooltip": None,
"default": None,
"expert": False,
},
{
"arg": True,
"name": "stop",
"type": "float",
"display_name": "Stop",
"tooltip": None,
"default": None,
"expert": False,
},
{
"arg": True,
"name": "steps",
"type": "int",
"display_name": "Steps",
"tooltip": None,
"default": None,
"expert": False,
},
],
"min": 2,
"max": None,
},
"kwarg_groups": [
{
"name": "Scan Parameters",
"inputs": [
{
"arg": False,
"name": "exp_time",
"type": "float",
"display_name": "Exp Time",
"tooltip": "Exposure time in seconds",
"default": 0,
"expert": False,
},
{
"arg": False,
"name": "settling_time",
"type": "float",
"display_name": "Settling Time",
"tooltip": "Settling time in seconds",
"default": 0,
"expert": False,
},
{
"arg": False,
"name": "burst_at_each_point",
"type": "int",
"display_name": "Burst At Each Point",
"tooltip": "Number of exposures at each point",
"default": 1,
"expert": False,
},
{
"arg": False,
"name": "relative",
"type": "bool",
"display_name": "Relative",
"tooltip": "If True, the motors will be moved relative to their current position",
"default": False,
"expert": False,
},
],
}
],
},
"required_kwargs": ["relative"],
"arg_bundle_size": {"bundle": 4, "min": 2, "max": None},
},
"not_supported_scan_class": {"base_class": "NotSupportedScanClass"},
}
)
@pytest.fixture(scope="function")
def scan_control(qtbot, mocked_client): # , mock_dev):
mocked_client.connector.set("scans/available_scans", available_scans_message)
widget = ScanControl(client=mocked_client)
# widget.dev.samx = MagicMock()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_populate_scans(scan_control, mocked_client):
# The comboBox should be populated with all scan from the message right after initialization
expected_scans = available_scans_message.resource.keys()
assert scan_control.comboBox_scan_selection.count() == len(expected_scans)
for scan in expected_scans: # Each scan should be in the comboBox
assert scan_control.comboBox_scan_selection.findText(scan) != -1
@pytest.mark.parametrize(
"scan_name", ["line_scan", "grid_scan"]
) # TODO now only for line_scan and grid_scan, later for all loaded scans
def test_on_scan_selected(scan_control, scan_name):
# Expected scan info from the message signature
expected_scan_info = available_scans_message.resource[scan_name]
# Select a scan from the comboBox
scan_control.comboBox_scan_selection.setCurrentText(scan_name)
# Check labels and widgets in args table
for index, (arg_key, arg_value) in enumerate(expected_scan_info["arg_input"].items()):
label = scan_control.args_table.horizontalHeaderItem(index)
assert label.text().lower() == arg_key # labes
for row in range(expected_scan_info["arg_bundle_size"]["min"]):
widget = scan_control.args_table.cellWidget(row, index)
assert widget is not None # Confirm that a widget exists
expected_widget_type = scan_control.WIDGET_HANDLER.get(arg_value, None)
assert isinstance(widget, expected_widget_type) # Confirm the widget type matches
# kwargs
kwargs_from_signature = [
param for param in expected_scan_info["signature"] if param["kind"] == "KEYWORD_ONLY"
expected_scans = ["line_scan", "grid_scan"]
items = [
scan_control.comboBox_scan_selection.itemText(i)
for i in range(scan_control.comboBox_scan_selection.count())
]
# Check labels and widgets in kwargs grid layout
for index, kwarg_info in enumerate(kwargs_from_signature):
label_widget = scan_control.kwargs_layout.itemAtPosition(1, index).widget()
assert label_widget.text() == kwarg_info["name"].capitalize()
widget = scan_control.kwargs_layout.itemAtPosition(2, index).widget()
expected_widget_type = scan_control.WIDGET_HANDLER.get(kwarg_info["annotation"], QLineEdit)
assert isinstance(widget, expected_widget_type)
assert scan_control.comboBox_scan_selection.count() == 2
assert sorted(items) == sorted(expected_scans)
@pytest.mark.parametrize("scan_name", ["line_scan", "grid_scan"])
def test_add_remove_bundle(scan_control, scan_name):
# Expected scan info from the message signature
def test_on_scan_selected(scan_control, scan_name):
expected_scan_info = available_scans_message.resource[scan_name]
scan_control.comboBox_scan_selection.setCurrentText(scan_name)
# Select a scan from the comboBox
# Check arg_box labels and widgets
for index, (arg_key, arg_value) in enumerate(expected_scan_info["arg_input"].items()):
label = scan_control.arg_box.layout.itemAtPosition(0, index).widget()
assert label.text().lower() == arg_key
for row in range(1, expected_scan_info["arg_bundle_size"]["min"] + 1):
widget = scan_control.arg_box.layout.itemAtPosition(row, index).widget()
assert widget is not None # Confirm that a widget exists
expected_widget_type = scan_control.arg_box.WIDGET_HANDLER.get(arg_value, None)
assert isinstance(widget, expected_widget_type) # Confirm the widget type matches
# Check kwargs boxes
kwargs_group = [param for param in expected_scan_info["gui_config"]["kwarg_groups"]]
print(kwargs_group)
for kwarg_box, kwarg_group in zip(scan_control.kwarg_boxes, kwargs_group):
assert kwarg_box.title() == kwarg_group["name"]
for index, kwarg_info in enumerate(kwarg_group["inputs"]):
label = kwarg_box.layout.itemAtPosition(0, index).widget()
assert label.text() == kwarg_info["display_name"]
widget = kwarg_box.layout.itemAtPosition(1, index).widget()
expected_widget_type = kwarg_box.WIDGET_HANDLER.get(kwarg_info["type"], None)
assert isinstance(widget, expected_widget_type)
@pytest.mark.parametrize("scan_name", ["line_scan", "grid_scan"])
def test_add_remove_bundle(scan_control, scan_name, qtbot):
expected_scan_info = available_scans_message.resource[scan_name]
scan_control.comboBox_scan_selection.setCurrentText(scan_name)
# Initial number of args row
initial_num_of_rows = scan_control.args_table.rowCount()
initial_num_of_rows = scan_control.arg_box.count_arg_rows()
# Check initial row count of args table
assert scan_control.args_table.rowCount() == expected_scan_info["arg_bundle_size"]["min"]
assert initial_num_of_rows == expected_scan_info["arg_bundle_size"]["min"]
# Try to remove default number of args row
scan_control.pushButton_remove_bundle.click()
assert scan_control.args_table.rowCount() == expected_scan_info["arg_bundle_size"]["min"]
scan_control.button_add_bundle.click()
scan_control.button_add_bundle.click()
# Try to add two bundles
scan_control.pushButton_add_bundle.click()
scan_control.pushButton_add_bundle.click()
# check the case where no max number of args are defined
# TODO do check also for the case where max number of args are defined
if expected_scan_info["arg_bundle_size"]["max"] is None:
assert scan_control.args_table.rowCount() == initial_num_of_rows + 2
assert scan_control.arg_box.count_arg_rows() == initial_num_of_rows + 2
# Remove one bundle
scan_control.pushButton_remove_bundle.click()
scan_control.button_remove_bundle.click()
qtbot.wait(200)
# check the case where no max number of args are defined
if expected_scan_info["arg_bundle_size"]["max"] is None:
assert scan_control.args_table.rowCount() == initial_num_of_rows + 1
assert scan_control.arg_box.count_arg_rows() == initial_num_of_rows + 1
def test_run_line_scan_with_parameters(scan_control, mocked_client):
@@ -134,32 +291,21 @@ def test_run_line_scan_with_parameters(scan_control, mocked_client):
kwargs = {"exp_time": 0.1, "steps": 10, "relative": True, "burst_at_each_point": 1}
args = {"device": "samx", "start": -5, "stop": 5}
# Select a scan from the comboBox
scan_control.comboBox_scan_selection.setCurrentText(scan_name)
# Set kwargs in the UI
for label_index in range(
scan_control.kwargs_layout.rowCount() + 1
): # from some reason rowCount() returns 1 less than the actual number of rows
label_item = scan_control.kwargs_layout.itemAtPosition(1, label_index)
if label_item:
label_widget = label_item.widget()
kwarg_key = WidgetIO.get_value(label_widget).lower()
if kwarg_key in kwargs:
widget_item = scan_control.kwargs_layout.itemAtPosition(2, label_index)
if widget_item:
widget = widget_item.widget()
WidgetIO.set_value(widget, kwargs[kwarg_key])
for kwarg_box in scan_control.kwarg_boxes:
for widget in kwarg_box.widgets:
for key, value in kwargs.items():
if widget.arg_name == key:
WidgetIO.set_value(widget, value)
break
# Set args in the UI
for col_index in range(scan_control.args_table.columnCount()):
header_item = scan_control.args_table.horizontalHeaderItem(col_index)
if header_item:
arg_key = header_item.text().lower()
if arg_key in args:
for row_index in range(scan_control.args_table.rowCount()):
widget = scan_control.args_table.cellWidget(row_index, col_index)
WidgetIO.set_value(widget, args[arg_key])
for widget in scan_control.arg_box.widgets:
for key, value in args.items():
if widget.arg_name == key:
WidgetIO.set_value(widget, value)
break
# Mock the scan function
mocked_scan_function = MagicMock()
@@ -172,13 +318,7 @@ def test_run_line_scan_with_parameters(scan_control, mocked_client):
called_args, called_kwargs = mocked_scan_function.call_args
# Check if the scan function was called correctly
expected_device = (
mocked_client.device_manager.devices.samx
) # This is the FakePositioner instance
expected_device = mocked_client.device_manager.devices.samx
expected_args_list = [expected_device, args["start"], args["stop"]]
assert called_args == tuple(
expected_args_list
), "The positional arguments passed to the scan function do not match expected values."
assert (
called_kwargs == kwargs
), "The keyword arguments passed to the scan function do not match expected values."
assert called_args == tuple(expected_args_list)
assert called_kwargs == kwargs

View File

@@ -0,0 +1,160 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import pytest
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.scan_control.scan_group_box import ScanGroupBox
def test_kwarg_box(qtbot):
group_input = {
"name": "Kwarg Test",
"inputs": [
# Test float
{
"arg": False,
"name": "exp_time",
"type": "float",
"display_name": "Exp Time",
"tooltip": "Exposure time in seconds",
"default": 0,
"expert": False,
},
# Test int
{
"arg": False,
"name": "num_points",
"type": "int",
"display_name": "Num Points",
"tooltip": "Number of points",
"default": 1,
"expert": False,
},
# Test bool
{
"arg": False,
"name": "relative",
"type": "bool",
"display_name": "Relative",
"tooltip": "If True, the motors will be moved relative to their current position",
"default": False,
"expert": False,
},
# Test str
{
"arg": False,
"name": "scan_type",
"type": "str",
"display_name": "Scan Type",
"tooltip": "Type of scan",
"default": "line",
"expert": False,
},
],
}
kwarg_box = ScanGroupBox(box_type="kwargs", config=group_input)
assert kwarg_box is not None
assert kwarg_box.box_type == "kwargs"
assert kwarg_box.config == group_input
assert kwarg_box.title() == "Kwarg Test"
# Labels
assert kwarg_box.layout.itemAtPosition(0, 0).widget().text() == "Exp Time"
assert kwarg_box.layout.itemAtPosition(0, 1).widget().text() == "Num Points"
assert kwarg_box.layout.itemAtPosition(0, 2).widget().text() == "Relative"
assert kwarg_box.layout.itemAtPosition(0, 3).widget().text() == "Scan Type"
# Widget 0
assert kwarg_box.widgets[0].__class__.__name__ == "ScanDoubleSpinBox"
assert kwarg_box.widgets[0].arg_name == "exp_time"
assert WidgetIO.get_value(kwarg_box.widgets[0]) == 0
assert kwarg_box.widgets[0].toolTip() == "Exposure time in seconds"
# Widget 1
assert kwarg_box.widgets[1].__class__.__name__ == "ScanSpinBox"
assert kwarg_box.widgets[1].arg_name == "num_points"
assert WidgetIO.get_value(kwarg_box.widgets[1]) == 1
assert kwarg_box.widgets[1].toolTip() == "Number of points"
# Widget 2
assert kwarg_box.widgets[2].__class__.__name__ == "ScanCheckBox"
assert kwarg_box.widgets[2].arg_name == "relative"
assert WidgetIO.get_value(kwarg_box.widgets[2]) == False
assert (
kwarg_box.widgets[2].toolTip()
== "If True, the motors will be moved relative to their current position"
)
# Widget 3
assert kwarg_box.widgets[3].__class__.__name__ == "ScanLineEdit"
assert kwarg_box.widgets[3].arg_name == "scan_type"
assert WidgetIO.get_value(kwarg_box.widgets[3]) == "line"
assert kwarg_box.widgets[3].toolTip() == "Type of scan"
parameters = kwarg_box.get_parameters()
assert parameters == {"exp_time": 0, "num_points": 1, "relative": False, "scan_type": "line"}
def test_arg_box(qtbot):
group_input = {
"name": "Arg Test",
"inputs": [
# Test device
{
"arg": True,
"name": "device",
"type": "str",
"display_name": "Device",
"tooltip": "Device to scan",
"default": "samx",
"expert": False,
},
# Test float
{
"arg": True,
"name": "start",
"type": "float",
"display_name": "Start",
"tooltip": "Start position",
"default": 0,
"expert": False,
},
# Test int
{
"arg": True,
"name": "stop",
"type": "int",
"display_name": "Stop",
"tooltip": "Stop position",
"default": 1,
"expert": False,
},
],
}
arg_box = ScanGroupBox(box_type="args", config=group_input)
assert arg_box is not None
assert arg_box.box_type == "args"
assert arg_box.config == group_input
assert arg_box.title() == "Arg Test"
# Labels
assert arg_box.layout.itemAtPosition(0, 0).widget().text() == "Device"
assert arg_box.layout.itemAtPosition(0, 1).widget().text() == "Start"
assert arg_box.layout.itemAtPosition(0, 2).widget().text() == "Stop"
# Widget 0
assert arg_box.widgets[0].__class__.__name__ == "ScanLineEdit"
assert arg_box.widgets[0].arg_name == "device"
assert WidgetIO.get_value(arg_box.widgets[0]) == "samx"
assert arg_box.widgets[0].toolTip() == "Device to scan"
# Widget 1
assert arg_box.widgets[1].__class__.__name__ == "ScanDoubleSpinBox"
assert arg_box.widgets[1].arg_name == "start"
assert WidgetIO.get_value(arg_box.widgets[1]) == 0
assert arg_box.widgets[1].toolTip() == "Start position"
# Widget 2
assert arg_box.widgets[2].__class__.__name__ == "ScanSpinBox"
assert arg_box.widgets[2].arg_name

View File

@@ -5,7 +5,7 @@ from bec_lib.endpoints import MessageEndpoints
from pydantic import ValidationError
from bec_widgets.utils import Colors
from bec_widgets.widgets import SpiralProgressBar
from bec_widgets.widgets.spiral_progress_bar import SpiralProgressBar
from bec_widgets.widgets.spiral_progress_bar.ring import RingConfig, RingConnections
from bec_widgets.widgets.spiral_progress_bar.spiral_progress_bar import SpiralProgressBarConfig

View File

@@ -0,0 +1,25 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
import pytest
from bec_widgets.widgets.buttons import StopButton
from .client_mocks import mocked_client
@pytest.fixture
def stop_button(qtbot, mocked_client):
widget = StopButton(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.close()
def test_stop_button(stop_button):
assert stop_button.text() == "Stop"
assert stop_button.styleSheet() == "background-color: #cc181e; color: white"
stop_button.click()
assert stop_button.queue.request_scan_abortion.called
assert stop_button.queue.request_queue_reset.called
stop_button.close()

View File

@@ -0,0 +1,55 @@
import re
from unittest import mock
import pytest
from bec_widgets.widgets.text_box.text_box import TextBox
from .client_mocks import mocked_client
@pytest.fixture
def text_box_widget(qtbot, mocked_client):
widget = TextBox(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
widget.close()
def test_textbox_widget(text_box_widget):
"""Test the TextBox widget."""
text = "Hello World!"
text_box_widget.set_text(text)
assert text_box_widget.toPlainText() == text
text_box_widget.set_color("#FFDDC1", "#123456")
text_box_widget.set_font_size(20)
assert (
text_box_widget.styleSheet() == "background-color: #FFDDC1; color: #123456; font-size: 20px"
)
text_box_widget.set_color("white", "blue")
text_box_widget.set_font_size(14)
assert text_box_widget.styleSheet() == "background-color: white; color: blue; font-size: 14px"
text = "<h1>Welcome to PyQt6</h1><p>This is an example of displaying <strong>HTML</strong> text.</p>"
with mock.patch.object(text_box_widget, "setHtml") as mocked_set_html:
text_box_widget.set_text(text)
assert mocked_set_html.call_count == 1
assert mocked_set_html.call_args == mock.call(text)
def test_textbox_change_theme(text_box_widget):
"""Test change theme functionaility"""
# Default is dark theme
text_box_widget.change_theme()
assert text_box_widget.config.theme == "light"
assert (
text_box_widget.styleSheet()
== f"background-color: #FFF; color: #000; font-size: {text_box_widget.config.font_size}px"
)
text_box_widget.change_theme()
assert text_box_widget.config.theme == "dark"
assert (
text_box_widget.styleSheet()
== f"background-color: #000; color: #FFF; font-size: {text_box_widget.config.font_size}px"
)

View File

@@ -0,0 +1,61 @@
import os
import shlex
import subprocess
from unittest import mock
import pytest
from bec_widgets.widgets.vscode.vscode import VSCodeEditor
from .client_mocks import mocked_client
@pytest.fixture
def vscode_widget(qtbot, mocked_client):
with mock.patch("bec_widgets.widgets.vscode.vscode.subprocess.Popen") as mock_popen:
widget = VSCodeEditor(client=mocked_client)
yield widget
def test_vscode_widget(qtbot, vscode_widget):
assert vscode_widget.process is not None
assert vscode_widget._url == "http://127.0.0.1:7000?tkn=bec"
def test_start_server(qtbot, mocked_client):
with mock.patch("bec_widgets.widgets.vscode.vscode.subprocess.Popen") as mock_popen:
mock_process = mock.Mock()
mock_process.stdout.fileno.return_value = 1
mock_process.poll.return_value = None
mock_process.stdout.read.return_value = (
f"available at http://{VSCodeEditor.host}:{VSCodeEditor.port}?tkn={VSCodeEditor.token}"
)
mock_popen.return_value = mock_process
widget = VSCodeEditor(client=mocked_client)
mock_popen.assert_called_once_with(
shlex.split(
f"code serve-web --port {widget.port} --connection-token={widget.token} --accept-server-license-terms"
),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
preexec_fn=os.setsid,
)
def test_close_event(qtbot, vscode_widget):
with mock.patch("bec_widgets.widgets.vscode.vscode.os.killpg") as mock_killpg:
with mock.patch("bec_widgets.widgets.vscode.vscode.os.getpgid") as mock_getpgid:
with mock.patch(
"bec_widgets.widgets.website.website.WebsiteWidget.closeEvent"
) as mock_close_event:
mock_getpgid.return_value = 123
vscode_widget.process = mock.Mock()
vscode_widget.process.pid = 123
vscode_widget.closeEvent(None)
mock_killpg.assert_called_once_with(123, 15)
vscode_widget.process.wait.assert_called_once()
mock_close_event.assert_called_once()

View File

@@ -1,5 +1,5 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
from unittest.mock import MagicMock
from unittest import mock
import numpy as np
import pytest
@@ -56,8 +56,12 @@ def test_create_waveform1D_by_config(bec_figure):
"col": 0,
"axis": {
"title": "Widget 1",
"title_size": None,
"x_label": None,
"x_label_size": None,
"y_label": None,
"y_label_size": None,
"legend_label_size": None,
"x_scale": "linear",
"y_scale": "linear",
"x_lim": (1, 10),
@@ -193,6 +197,18 @@ def test_add_curve(bec_figure):
assert c1.config.source == "scan_segment"
def test_change_legend_font_size(bec_figure):
plot = bec_figure.add_plot()
w1 = plot.add_curve_scan(x_name="samx", y_name="bpm4i")
my_func = plot.plot_item.legend
with mock.patch.object(my_func, "setScale") as mock_set_scale:
plot.set_legend_label_size(18)
assert plot.config.axis.legend_label_size == 18
assert mock_set_scale.call_count == 1
assert mock_set_scale.call_args == mock.call(2)
def test_remove_curve(bec_figure):
w1 = bec_figure.add_plot()
@@ -406,10 +422,10 @@ def test_scan_update(bec_figure, qtbot):
"scan_id": 1,
}
# Mock scan_storage.find_scan_by_ID
mock_scan_data_waveform = MagicMock()
mock_scan_data_waveform = mock.MagicMock()
mock_scan_data_waveform.data = {
device_name: {
entry: MagicMock(val=[msg_waveform["data"][device_name][entry]["value"]])
entry: mock.MagicMock(val=[msg_waveform["data"][device_name][entry]["value"]])
for entry in msg_waveform["data"][device_name]
}
for device_name in msg_waveform["data"]
@@ -430,12 +446,12 @@ def test_scan_history_with_val_access(bec_figure, qtbot):
c1 = w1.add_curve_scan(x_name="samx", y_name="bpm4i")
mock_scan_data = {
"samx": {"samx": MagicMock(val=np.array([1, 2, 3]))}, # Use MagicMock for .val
"bpm4i": {"bpm4i": MagicMock(val=np.array([4, 5, 6]))}, # Use MagicMock for .val
"samx": {"samx": mock.MagicMock(val=np.array([1, 2, 3]))}, # Use mock.MagicMock for .val
"bpm4i": {"bpm4i": mock.MagicMock(val=np.array([4, 5, 6]))}, # Use mock.MagicMock for .val
}
mock_scan_storage = MagicMock()
mock_scan_storage.find_scan_by_ID.return_value = MagicMock(data=mock_scan_data)
mock_scan_storage = mock.MagicMock()
mock_scan_storage.find_scan_by_ID.return_value = mock.MagicMock(data=mock_scan_data)
w1.queue.scan_storage = mock_scan_storage
fake_scan_id = "fake_scan_id"
@@ -464,10 +480,10 @@ def test_scatter_2d_update(bec_figure, qtbot):
}
msg_metadata = {"scan_name": "line_scan"}
mock_scan_data = MagicMock()
mock_scan_data = mock.MagicMock()
mock_scan_data.data = {
device_name: {
entry: MagicMock(val=msg["data"][device_name][entry]["value"])
entry: mock.MagicMock(val=msg["data"][device_name][entry]["value"])
for entry in msg["data"][device_name]
}
for device_name in msg["data"]