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

Compare commits

..

20 Commits

Author SHA1 Message Date
wakonig_k 2ae0a40616 ci: update github-script action to v9 2026-04-20 16:50:11 +02:00
wakonig_k 6af1683c23 wip 2026-04-20 16:48:01 +02:00
wakonig_k 5271db1ca6 wip 2026-04-20 16:36:47 +02:00
wakonig_k 92d4519853 ci: move to external action 2026-04-20 16:25:51 +02:00
wakonig_k 42439097e9 ci: add benchmark workflow 2026-04-17 16:06:02 +02:00
semantic-release 846b6e6968 3.5.0
Automatically generated by python-semantic-release
2026-04-14 15:29:09 +00:00
perl_d f562c61e3c fix: connect signals the correct way around 2026-04-14 17:28:19 +02:00
wyzula_j bda5d38965 refactor: code cleanup 2026-04-14 17:28:19 +02:00
wyzula_j 9b0ec9dd79 fix(bec_console): persistent bec session 2026-04-14 17:28:19 +02:00
perl_d 1754e759f0 fix: create new bec shell if deleted 2026-04-14 17:28:19 +02:00
perl_d 308e84d0e1 tests: update tests 2026-04-14 17:28:19 +02:00
perl_d fa2ef83bb9 fix: formatting in plugin template 2026-04-14 17:28:19 +02:00
perl_d 02cb393bb0 feat: add qtermwidget plugin and replace web term 2026-04-14 17:28:19 +02:00
semantic-release 1d3e0214fd 3.4.4
Automatically generated by python-semantic-release
2026-04-14 07:33:15 +00:00
perl_d 37747babda fix: check for duplicate subscriptions in GUIClient 2026-04-14 09:32:17 +02:00
perl_d 32f5d486d3 fix: make gui client registry callback non static 2026-04-14 09:32:17 +02:00
perl_d 0ff1fdc815 fix: remove staticmethod subscription 2026-04-14 09:32:17 +02:00
perl_d c7de320ca5 fix: check duplicate stream sub 2026-04-14 09:32:17 +02:00
semantic-release 5b23dce3d0 3.4.3
Automatically generated by python-semantic-release
2026-04-13 09:20:13 +00:00
wakonig_k 5e84d3bec6 fix: Set OPHYD_CONTROL_LAYER to dummy for tests 2026-04-13 11:19:22 +02:00
23 changed files with 661 additions and 150 deletions
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail
bec_core_branch="${BEC_CORE_BRANCH:-main}"
ophyd_devices_branch="${OPHYD_DEVICES_BRANCH:-main}"
plugin_repo_branch="${PLUGIN_REPO_BRANCH:-main}"
python_version="${PYTHON_VERSION:-3.11}"
if command -v conda >/dev/null 2>&1; then
conda_base="$(conda info --base)"
source "$conda_base/etc/profile.d/conda.sh"
fi
echo "Using branch ${bec_core_branch} of BEC CORE"
git clone --branch "$bec_core_branch" https://github.com/bec-project/bec.git
echo "Using branch ${ophyd_devices_branch} of OPHYD_DEVICES"
git clone --branch "$ophyd_devices_branch" https://github.com/bec-project/ophyd_devices.git
echo "Using branch ${plugin_repo_branch} of bec_testing_plugin"
git clone --branch "$plugin_repo_branch" https://github.com/bec-project/bec_testing_plugin.git
conda create -q -n test-environment "python=${python_version}"
conda activate test-environment
cd bec
source ./bin/install_bec_dev.sh
cd ..
python -m pip install -e ./ophyd_devices -e .[dev,pyside6] -e ./bec_testing_plugin
benchmark_tmp_dir="$(mktemp -d)"
export BEC_SERVICE_CONFIG="$benchmark_tmp_dir/services_config.yaml"
# Start Redis
redis-server --daemonize yes --port 6379
# Wait for Redis to be ready
timeout 30 bash -c 'until redis-cli ping > /dev/null 2>&1; do sleep 0.1; done' || {
echo "Redis failed to start" >&2
exit 1
}
# Start BEC server
bec-server start --config "$BEC_SERVICE_CONFIG"
# Wait for BEC server to be ready
sleep 5
# Export BEC client configuration
export BEC_CONFIG='{"redis": {"host": "localhost", "port": 6379}}'
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Start BEC services for benchmark workflows and keep them alive."""
from __future__ import annotations
import argparse
from pathlib import Path
from bec_ipython_client import BECIPythonClient
from bec_lib.redis_connector import RedisConnector
from bec_lib.service_config import ServiceConfig
def _load_demo_config(services_config: Path) -> None:
bec = BECIPythonClient(ServiceConfig(services_config), RedisConnector, forced=True)
bec.start()
try:
bec.config.load_demo_config()
finally:
bec.shutdown()
bec._client._reset_singleton()
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--services-config", required=True, type=Path)
args = parser.parse_args()
_load_demo_config(args.services_config)
if __name__ == "__main__":
main()
+47
View File
@@ -0,0 +1,47 @@
name: BW Benchmarks
on: [ workflow_call ]
permissions:
contents: read
env:
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
jobs:
benchmark:
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
defaults:
run:
shell: bash -el {0}
steps:
- name: Checkout BEC Widgets
uses: actions/checkout@v4
with:
repository: bec-project/bec_widgets
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Set up Conda
uses: conda-incubator/setup-miniconda@v3
with:
auto-update-conda: true
auto-activate-base: true
python-version: "3.11"
- name: Run, compare, and publish benchmarks
uses: bec-project/benchmark_action@main
with:
mode: all
attempts: "3"
system-packages: libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1 hyperfine
redis-server
setup-scripts: .github/scripts/setup_benchmark_env.sh
benchmark-pytest-dirs: tests/unit_tests/benchmarks
threshold-percent: "10"
+2 -2
View File
@@ -12,7 +12,7 @@ jobs:
outputs:
branch-pr: ${{ steps.script.outputs.result }}
steps:
- uses: actions/github-script@v7
- uses: actions/github-script@v9
id: script
if: github.event_name == 'push' && github.event.ref_type != 'tag'
with:
@@ -25,4 +25,4 @@ jobs:
if (prs.data.length) {
console.log(`::notice ::Skipping CI on branch push as it is already run in PR #${prs.data[0]["number"]}`)
return prs.data[0]["number"]
}
}
+17 -7
View File
@@ -1,19 +1,19 @@
name: Full CI
on:
on:
push:
pull_request:
workflow_dispatch:
inputs:
BEC_WIDGETS_BRANCH:
description: 'Branch of BEC Widgets to install'
description: "Branch of BEC Widgets to install"
required: false
type: string
BEC_CORE_BRANCH:
description: 'Branch of BEC Core to install'
description: "Branch of BEC Core to install"
required: false
type: string
OPHYD_DEVICES_BRANCH:
description: 'Branch of Ophyd Devices to install'
description: "Branch of Ophyd Devices to install"
required: false
type: string
@@ -23,6 +23,7 @@ concurrency:
permissions:
pull-requests: write
contents: read
jobs:
check_pr_status:
@@ -33,6 +34,15 @@ jobs:
if: needs.check_pr_status.outputs.branch-pr == ''
uses: ./.github/workflows/formatter.yml
benchmark:
needs: [check_pr_status]
if: needs.check_pr_status.outputs.branch-pr == ''
permissions:
contents: write
issues: write
pull-requests: write
uses: ./.github/workflows/benchmark.yml
unit-test:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
@@ -69,9 +79,9 @@ jobs:
uses: ./.github/workflows/child_repos.yml
with:
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main'}}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main'}}
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
plugin_repos:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
@@ -81,4 +91,4 @@ jobs:
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
secrets:
GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}
GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}
+12 -13
View File
@@ -1,25 +1,25 @@
name: Run Pytest with different Python versions
on:
on:
workflow_call:
inputs:
pr_number:
description: 'Pull request number'
description: "Pull request number"
required: false
type: number
BEC_CORE_BRANCH:
description: 'Branch of BEC Core to install'
description: "Branch of BEC Core to install"
required: false
default: 'main'
default: "main"
type: string
OPHYD_DEVICES_BRANCH:
description: 'Branch of Ophyd Devices to install'
description: "Branch of Ophyd Devices to install"
required: false
default: 'main'
default: "main"
type: string
BEC_WIDGETS_BRANCH:
description: 'Branch of BEC Widgets to install'
description: "Branch of BEC Widgets to install"
required: false
default: 'main'
default: "main"
type: string
jobs:
@@ -30,15 +30,14 @@ jobs:
python-version: ["3.11", "3.12", "3.13"]
env:
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
steps:
- name: Checkout BEC Widgets
uses: actions/checkout@v4
with:
@@ -56,4 +55,4 @@ jobs:
- name: Run Pytest
run: |
pip install pytest pytest-random-order
pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
pytest -v --junitxml=report.xml --random-order --ignore=tests/unit_tests/benchmarks ./tests/unit_tests
+10 -12
View File
@@ -1,32 +1,30 @@
name: Run Pytest with Coverage
on:
on:
workflow_call:
inputs:
pr_number:
description: 'Pull request number'
description: "Pull request number"
required: false
type: number
BEC_CORE_BRANCH:
description: 'Branch of BEC Core to install'
description: "Branch of BEC Core to install"
required: false
default: 'main'
default: "main"
type: string
OPHYD_DEVICES_BRANCH:
description: 'Branch of Ophyd Devices to install'
description: "Branch of Ophyd Devices to install"
required: false
default: 'main'
default: "main"
type: string
BEC_WIDGETS_BRANCH:
description: 'Branch of BEC Widgets to install'
description: "Branch of BEC Widgets to install"
required: false
default: 'main'
default: "main"
type: string
secrets:
CODECOV_TOKEN:
required: true
permissions:
pull-requests: write
@@ -55,7 +53,7 @@ jobs:
- name: Run Pytest with Coverage
id: coverage
run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail tests/unit_tests/
run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail --ignore=tests/unit_tests/benchmarks tests/unit_tests/
- name: Upload test artifacts
uses: actions/upload-artifact@v4
@@ -69,4 +67,4 @@ jobs:
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: bec-project/bec_widgets
slug: bec-project/bec_widgets
+52
View File
@@ -1,6 +1,58 @@
# CHANGELOG
## v3.5.0 (2026-04-14)
### Bug Fixes
- Connect signals the correct way around
([`f562c61`](https://github.com/bec-project/bec_widgets/commit/f562c61e3cec3387f6821bad74403beeb3436355))
- Create new bec shell if deleted
([`1754e75`](https://github.com/bec-project/bec_widgets/commit/1754e759f0c59f2f4063f661bacd334127326947))
- Formatting in plugin template
([`fa2ef83`](https://github.com/bec-project/bec_widgets/commit/fa2ef83bb9dfeeb4c5fc7cd77168c16101c32693))
- **bec_console**: Persistent bec session
([`9b0ec9d`](https://github.com/bec-project/bec_widgets/commit/9b0ec9dd79ad1adc5d211dd703db7441da965f34))
### Features
- Add qtermwidget plugin and replace web term
([`02cb393`](https://github.com/bec-project/bec_widgets/commit/02cb393bb086165dc64917b633d5570d02e1a2a9))
### Refactoring
- Code cleanup
([`bda5d38`](https://github.com/bec-project/bec_widgets/commit/bda5d389651bb2b13734cd31159679e85b1bd583))
## v3.4.4 (2026-04-14)
### Bug Fixes
- Check duplicate stream sub
([`c7de320`](https://github.com/bec-project/bec_widgets/commit/c7de320ca564264a31b84931f553170f25659685))
- Check for duplicate subscriptions in GUIClient
([`37747ba`](https://github.com/bec-project/bec_widgets/commit/37747babda407040333c6bd04646be9a49e0ee81))
- Make gui client registry callback non static
([`32f5d48`](https://github.com/bec-project/bec_widgets/commit/32f5d486d3fc8d41df2668c58932ae982819b285))
- Remove staticmethod subscription
([`0ff1fdc`](https://github.com/bec-project/bec_widgets/commit/0ff1fdc81578eec3ffc5d4030fca7b357a0b4c2f))
## v3.4.3 (2026-04-13)
### Bug Fixes
- Set OPHYD_CONTROL_LAYER to dummy for tests
([`5e84d3b`](https://github.com/bec-project/bec_widgets/commit/5e84d3bec608ae9f2ee6dae67db2e3e1387b1f59))
## v3.4.2 (2026-04-01)
### Bug Fixes
+11 -13
View File
@@ -10,9 +10,9 @@ import threading
import time
from contextlib import contextmanager
from threading import Lock
from typing import TYPE_CHECKING, Literal, TypeAlias, cast
from typing import TYPE_CHECKING, Callable, Literal, TypeAlias, cast
from bec_lib.endpoints import MessageEndpoints
from bec_lib.endpoints import EndpointInfo, MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
from rich.console import Console
@@ -232,6 +232,11 @@ class BECGuiClient(RPCBase):
"""The launcher object."""
return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher")
def _safe_register_stream(self, endpoint: EndpointInfo, cb: Callable, **kwargs):
"""Check if already registered for registration in idempotent functions."""
if not self._client.connector.any_stream_is_registered(endpoint, cb=cb):
self._client.connector.register(endpoint, cb=cb, **kwargs)
def connect_to_gui_server(self, gui_id: str) -> None:
"""Connect to a GUI server"""
# Unregister the old callback
@@ -247,10 +252,9 @@ class BECGuiClient(RPCBase):
self._ipython_registry = {}
# Register the new callback
self._client.connector.register(
self._safe_register_stream(
MessageEndpoints.gui_registry_state(self._gui_id),
cb=self._handle_registry_update,
parent=self,
from_start=True,
)
@@ -531,20 +535,14 @@ class BECGuiClient(RPCBase):
def _start(self, wait: bool = False) -> None:
self._killed = False
self._client.connector.register(
MessageEndpoints.gui_registry_state(self._gui_id),
cb=self._handle_registry_update,
parent=self,
self._safe_register_stream(
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
)
return self._start_server(wait=wait)
@staticmethod
def _handle_registry_update(
msg: dict[str, GUIRegistryStateMessage], parent: BECGuiClient
) -> None:
def _handle_registry_update(self, msg: dict[str, GUIRegistryStateMessage]) -> None:
# This was causing a deadlock during shutdown, not sure why.
# with self._lock:
self = parent
self._server_registry = cast(dict[str, RegistryState], msg["data"].state)
self._update_dynamic_namespace(self._server_registry)
+4 -7
View File
@@ -248,9 +248,7 @@ class RPCBase:
self._rpc_response = None
self._msg_wait_event.clear()
self._client.connector.register(
MessageEndpoints.gui_instruction_response(request_id),
cb=self._on_rpc_response,
parent=self,
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
)
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
@@ -276,11 +274,10 @@ class RPCBase:
self._rpc_response = None
return self._create_widget_from_msg_result(msg_result)
@staticmethod
def _on_rpc_response(msg_obj: MessageObject, parent: RPCBase) -> None:
def _on_rpc_response(self, msg_obj: MessageObject) -> None:
msg = cast(messages.RequestResponseMessage, msg_obj.value)
parent._rpc_response = msg
parent._msg_wait_event.set()
self._rpc_response = msg
self._msg_wait_event.set()
def _create_widget_from_msg_result(self, msg_result):
if msg_result is None:
+9 -6
View File
@@ -175,12 +175,15 @@ class BECDispatcher:
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
"""
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
if qt_slot not in self._registered_slots:
self._registered_slots[qt_slot] = qt_slot
qt_slot = self._registered_slots[qt_slot]
self.client.connector.register(topics, cb=qt_slot, **kwargs)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
qt_slot.topics.update(set(topics_str))
if not self.client.connector.any_stream_is_registered(topics, qt_slot):
if qt_slot not in self._registered_slots:
self._registered_slots[qt_slot] = qt_slot
qt_slot = self._registered_slots[qt_slot]
self.client.connector.register(topics, cb=qt_slot, **kwargs)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
qt_slot.topics.update(set(topics_str))
else:
logger.warning(f"Attempted to create duplicate stream subscription for {topics=}")
def disconnect_slot(
self, slot: Callable, topics: EndpointInfo | str | list[EndpointInfo] | list[str]
@@ -30,7 +30,6 @@ from bec_widgets.widgets.containers.main_window.addons.notification_center.notif
)
from bec_widgets.widgets.containers.main_window.addons.scroll_label import ScrollLabel
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
from bec_widgets.widgets.editors.bec_console.bec_console import BecConsoleRegistry
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar
from bec_widgets.widgets.utility.widget_hierarchy_tree.widget_hierarchy_tree import (
WidgetHierarchyDialog,
@@ -54,9 +53,6 @@ class BECMainWindow(BECWidget, QMainWindow):
super().__init__(parent=parent, **kwargs)
self.app = QApplication.instance()
self._console_registry = BecConsoleRegistry(self)
if not hasattr(self.app, "console_widget_registry"):
self.app.console_widget_registry = self._console_registry
self.status_bar = self.statusBar()
self._launcher_window = None
self.setWindowTitle(window_title)
@@ -1,13 +1,13 @@
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from uuid import uuid4
from weakref import WeakValueDictionary
import shiboken6
from bec_lib.logger import bec_logger
from pydantic import BaseModel
from qtpy.QtCore import QObject, Qt
from qtpy.QtCore import Qt, Signal
from qtpy.QtGui import QMouseEvent
from qtpy.QtWidgets import (
QApplication,
@@ -39,33 +39,133 @@ class ConsoleMode(str, enum.Enum):
HIDDEN = "hidden"
class _TerminalOwnerInfo(BaseModel):
@dataclass
class _TerminalOwnerInfo:
"""Should be managed only by the BecConsoleRegistry. Consoles should ask the registry for
necessary ownership info."""
owner_console_id: str | None = None
registered_console_ids: set[str] = set()
instance: BecTerminal | QWidget
terminal_id: str
registered_console_ids: set[str] = field(default_factory=set)
instance: BecTerminal | None = None
terminal_id: str = ""
initialized: bool = False
keep_if_last_console_closed: bool = False
model_config = {"arbitrary_types_allowed": True}
persist_session: bool = False
fallback_holder: QWidget | None = None
class BecConsoleRegistry(QWidget):
class BecConsoleRegistry:
"""
A registry for the BecConsole class to manage its instances.
"""
def __init__(self, parent):
def __init__(self):
"""
Initialize the registry.
"""
super().__init__(parent=parent)
self._consoles: WeakValueDictionary[str, BecConsole] = WeakValueDictionary()
self._terminal_registry: dict[str, _TerminalOwnerInfo] = {}
@staticmethod
def _is_valid_qobject(obj: object | None) -> bool:
return obj is not None and shiboken6.isValid(obj)
def _connect_app_cleanup(self) -> None:
app = QApplication.instance()
if app is None:
return
app.aboutToQuit.connect(self.clear, Qt.ConnectionType.UniqueConnection)
@staticmethod
def _new_terminal_info(console: BecConsole) -> _TerminalOwnerInfo:
term = _BecTermClass()
return _TerminalOwnerInfo(
registered_console_ids={console.console_id},
owner_console_id=console.console_id,
instance=term,
terminal_id=console.terminal_id,
persist_session=console.persist_terminal_session,
)
@staticmethod
def _replace_terminal(info: _TerminalOwnerInfo, console: BecConsole) -> None:
info.instance = _BecTermClass()
info.initialized = False
info.owner_console_id = console.console_id
info.registered_console_ids.add(console.console_id)
info.persist_session = info.persist_session or console.persist_terminal_session
def _delete_terminal_info(self, info: _TerminalOwnerInfo) -> None:
if self._is_valid_qobject(info.instance):
info.instance.deleteLater() # type: ignore[union-attr]
info.instance = None
if self._is_valid_qobject(info.fallback_holder):
info.fallback_holder.deleteLater()
info.fallback_holder = None
def _parking_parent(
self,
info: _TerminalOwnerInfo,
console: BecConsole | None = None,
*,
avoid_console: bool = False,
) -> QWidget | None:
for console_id in info.registered_console_ids:
candidate = self._consoles.get(console_id)
if candidate is None or candidate is console:
continue
if self._is_valid_qobject(candidate):
return candidate._term_holder
if console is None or not self._is_valid_qobject(console):
return None
window = console.window()
if window is not None and window is not console and self._is_valid_qobject(window):
return window
if not avoid_console:
return console._term_holder
return None
def _fallback_holder(
self,
info: _TerminalOwnerInfo,
console: BecConsole | None = None,
*,
avoid_console: bool = False,
) -> QWidget:
if not self._is_valid_qobject(info.fallback_holder):
info.fallback_holder = QWidget(
parent=self._parking_parent(info, console, avoid_console=avoid_console)
)
info.fallback_holder.setObjectName(f"_bec_console_terminal_holder_{info.terminal_id}")
info.fallback_holder.hide()
return info.fallback_holder
def _park_terminal(
self,
info: _TerminalOwnerInfo,
console: BecConsole | None = None,
*,
avoid_console: bool = False,
) -> None:
if not self._is_valid_qobject(info.instance):
return
parent = self._parking_parent(info, console, avoid_console=avoid_console)
if parent is None and info.persist_session:
parent = self._fallback_holder(info, console, avoid_console=avoid_console)
info.instance.hide() # type: ignore[union-attr]
info.instance.setParent(parent) # type: ignore[union-attr]
def clear(self) -> None:
"""Delete every tracked terminal and holder."""
for info in list(self._terminal_registry.values()):
self._delete_terminal_info(info)
self._terminal_registry.clear()
self._consoles.clear()
def register(self, console: BecConsole):
"""
Register an instance of BecConsole. If there is already a terminal with the associated
@@ -74,49 +174,56 @@ class BecConsoleRegistry(QWidget):
Args:
console (BecConsole): The instance to register.
"""
self._connect_app_cleanup()
self._consoles[console.console_id] = console
console_id, terminal_id = console.console_id, console.terminal_id
if (term_info := self._terminal_registry.get(terminal_id)) is None or not shiboken6.isValid(
term_info.instance
):
term = _BecTermClass()
self._terminal_registry[terminal_id] = _TerminalOwnerInfo(
registered_console_ids={console_id},
owner_console_id=console_id,
instance=term,
terminal_id=terminal_id,
keep_if_last_console_closed=console.persevere_terminal,
)
if console.persevere_terminal:
term.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False)
term_info = self._terminal_registry.get(terminal_id)
if term_info is None:
self._terminal_registry[terminal_id] = self._new_terminal_info(console)
return
logger.info(f"Registered new console {console_id} for terminal {terminal_id}")
term_info.persist_session = term_info.persist_session or console.persist_terminal_session
had_registered_consoles = bool(term_info.registered_console_ids)
term_info.registered_console_ids.add(console_id)
if not self._is_valid_qobject(term_info.instance):
self._replace_terminal(term_info, console)
return
if (
term_info.owner_console_id is not None
and term_info.owner_console_id not in self._consoles
):
term_info.owner_console_id = None
if term_info.owner_console_id is None and not had_registered_consoles:
term_info.owner_console_id = console_id
logger.info(f"Registered new console {console_id} for terminal {terminal_id}")
def unregister(self, console: BecConsole):
"""
Unregister an instance of BecConsole.
Args:
instance (BecConsole): The instance to unregister.
console (BecConsole): The instance to unregister.
"""
console_id, terminal_id = console.console_id, console.terminal_id
if console_id in self._consoles:
del self._consoles[console_id]
if (term_info := self._terminal_registry.get(terminal_id)) is None:
return
detached = console._detach_terminal_widget(term_info.instance)
if console_id in term_info.registered_console_ids:
term_info.registered_console_ids.remove(console_id)
if term_info.owner_console_id == console_id:
term_info.owner_console_id = None
if not term_info.registered_console_ids:
if not term_info.keep_if_last_console_closed:
term_info.instance.deleteLater()
del self._terminal_registry[terminal_id]
else:
term_info.instance.setHidden()
term_info.instance.setParent(self)
if term_info.persist_session and self._is_valid_qobject(term_info.instance):
self._park_terminal(term_info, console, avoid_console=True)
logger.info(f"Unregistered console {console_id} for terminal {terminal_id}")
return
self._delete_terminal_info(term_info)
del self._terminal_registry[terminal_id]
elif detached:
self._park_terminal(term_info, console, avoid_console=True)
logger.info(f"Unregistered console {console_id} for terminal {terminal_id}")
@@ -127,6 +234,8 @@ class BecConsoleRegistry(QWidget):
if (info := self._terminal_registry.get(console.terminal_id)) is None:
logger.warning(f"Console {console.console_id} references an unknown terminal!")
return False
if not self._is_valid_qobject(info.instance):
return False
return info.owner_console_id == console.console_id
def take_ownership(self, console: BecConsole) -> BecTerminal | None:
@@ -141,14 +250,19 @@ class BecConsoleRegistry(QWidget):
console_id, terminal_id = console.console_id, console.terminal_id
if terminal_id not in self._terminal_registry:
logger.warning(f"Terminal {terminal_id} not found in registry")
return None
self.register(console)
instance_info = self._terminal_registry[terminal_id]
if (old_owner_console_id := instance_info.owner_console_id) is not None:
if (old_owner := self._consoles.get(old_owner_console_id)) is not None:
if not self._is_valid_qobject(instance_info.instance):
self._replace_terminal(instance_info, console)
if (old_owner_console_ide := instance_info.owner_console_id) is not None:
if (
old_owner_console_ide != console_id
and (old_owner := self._consoles.get(old_owner_console_ide)) is not None
):
old_owner.yield_ownership() # call this on the old owner to make sure it is updated
instance_info.owner_console_id = console_id
instance_info.registered_console_ids.add(console_id)
logger.info(f"Transferred ownership of terminal {terminal_id} to {console_id}")
return instance_info.instance
@@ -168,17 +282,21 @@ class BecConsoleRegistry(QWidget):
return None
instance_info = self._terminal_registry[terminal_id]
if not self._is_valid_qobject(instance_info.instance):
if instance_info.owner_console_id == console_id:
self._replace_terminal(instance_info, console)
else:
return None
if instance_info.owner_console_id == console_id:
return instance_info.instance
def yield_ownership(self, console: BecConsole):
"""
Yield ownership of a instance without destroying it. The instance remains in the
Yield ownership of an instance without destroying it. The instance remains in the
registry with no owner, available for another widget to claim.
Args:
gui_id (str): The GUI ID of the widget yielding ownership.
console (BecConsole): The console which wishes to yield ownership of its associated terminal.
"""
console_id, terminal_id = console.console_id, console.terminal_id
logger.debug(f"Console {console_id} attempted to yield ownership")
@@ -190,20 +308,40 @@ class BecConsoleRegistry(QWidget):
logger.debug(f"But it was not the owner, which was {term_info.owner_console_id}!")
return
term_info.owner_console_id = None
console._detach_terminal_widget(term_info.instance)
self._park_terminal(term_info, console)
term_info.instance.setParent(QApplication.instance().console_widget_registry)
def should_initialize(self, console: BecConsole) -> bool:
"""Return true if the console should send its startup command to the terminal."""
info = self._terminal_registry.get(console.terminal_id)
if info is None:
return False
return (
info.owner_console_id == console.console_id
and not info.initialized
and self._is_valid_qobject(info.instance)
)
def mark_initialized(self, console: BecConsole) -> None:
info = self._terminal_registry.get(console.terminal_id)
if info is not None and info.owner_console_id == console.console_id:
info.initialized = True
def owner_is_visible(self, term_id: str) -> bool:
"""
Check if the owner of a instance is currently visible.
Check if the owner of an instance is currently visible.
Args:
unique_id (str): The unique identifier for the instance.
term_id (str): The terminal ID to check.
Returns:
bool: True if the owner is visible, False otherwise.
"""
instance_info = self._terminal_registry.get(term_id)
if instance_info is None or instance_info.owner_console_id is None:
if (
instance_info is None
or instance_info.owner_console_id is None
or not self._is_valid_qobject(instance_info.instance)
):
return False
if (owner := self._consoles.get(instance_info.owner_console_id)) is None:
@@ -211,6 +349,9 @@ class BecConsoleRegistry(QWidget):
return owner.isVisible()
_bec_console_registry = BecConsoleRegistry()
class _Overlay(QWidget):
def __init__(self, console: BecConsole):
super().__init__(parent=console)
@@ -227,8 +368,12 @@ class _Overlay(QWidget):
class BecConsole(BECWidget, QWidget):
"""A console widget with access to a shared registry of terminals, such that instances can be moved around."""
_js_callback = Signal(bool)
initialized = Signal()
PLUGIN = True
ICON_NAME = "terminal"
persist_terminal_session = False
def __init__(
self,
@@ -238,7 +383,6 @@ class BecConsole(BECWidget, QWidget):
gui_id=None,
startup_cmd: str | None = None,
terminal_id: str | None = None,
persevere_terminal: bool = False,
**kwargs,
):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
@@ -248,7 +392,6 @@ class BecConsole(BECWidget, QWidget):
self.terminal_id = terminal_id or str(uuid4())
self.console_id = self.gui_id
self.term: BecTerminal | None = None # Will be set in _set_up_instance
self.persevere_terminal = persevere_terminal
self._set_up_instance()
@@ -276,13 +419,12 @@ class BecConsole(BECWidget, QWidget):
self._stacked_layout.addWidget(self._overlay)
# will create a new terminal instance if there isn't already one for this ID
QApplication.instance().console_widget_registry.register(self)
_bec_console_registry.register(self)
self._infer_mode()
if self.startup_cmd:
self.write(self.startup_cmd, True) # will have no effect if not the owner
self._ensure_startup_started()
def _infer_mode(self):
self.term = QApplication.instance().console_widget_registry.try_get_term(self)
self.term = _bec_console_registry.try_get_term(self)
if self.term:
self._set_mode(ConsoleMode.ACTIVE)
elif self.isHidden():
@@ -301,8 +443,9 @@ class BecConsole(BECWidget, QWidget):
match mode:
case ConsoleMode.ACTIVE:
if self.term:
if self.term not in (self._term_layout.children()):
if self._term_layout.indexOf(self.term) == -1: # type: ignore[arg-type]
self._term_layout.addWidget(self.term) # type: ignore # BecTerminal is QWidget
self.term.show() # type: ignore[attr-defined]
self._stacked_layout.setCurrentIndex(0)
self._mode = mode
else:
@@ -326,7 +469,6 @@ class BecConsole(BECWidget, QWidget):
def startup_cmd(self, cmd: str | None):
"""
Set the startup command for the console.
logger.info(f"{self._console_id} inferred mode active through ownerp)
"""
self._startup_cmd = cmd
@@ -341,14 +483,38 @@ class BecConsole(BECWidget, QWidget):
if self.term:
self.term.write(data, send_return)
def _ensure_startup_started(self):
if not self.startup_cmd or not _bec_console_registry.should_initialize(self):
return
self.write(self.startup_cmd, True)
_bec_console_registry.mark_initialized(self)
def _detach_terminal_widget(self, term: BecTerminal | None) -> bool:
if term is None or not BecConsoleRegistry._is_valid_qobject(term):
if self.term is term:
self.term = None
return False
is_child = self.isAncestorOf(term) # type: ignore[arg-type]
if self._term_layout.indexOf(term) != -1: # type: ignore[arg-type]
self._term_layout.removeWidget(term) # type: ignore[arg-type]
is_child = True
if is_child:
term.hide() # type: ignore[attr-defined]
term.setParent(None) # type: ignore[attr-defined]
if self.term is term:
self.term = None
return is_child
def take_terminal_ownership(self):
"""
Take ownership of a web instance from the registry. This will transfer the instance
from its current owner (if any) to this widget.
"""
# Get the instance from registry
self.term = QApplication.instance().console_widget_registry.take_ownership(self)
self.term = _bec_console_registry.take_ownership(self)
self._infer_mode()
self._ensure_startup_started()
if self._mode == ConsoleMode.ACTIVE:
logger.debug(f"Widget {self.gui_id} took ownership of instance {self.terminal_id}")
@@ -358,7 +524,7 @@ class BecConsole(BECWidget, QWidget):
available for another widget to claim. This is automatically called when the
widget becomes hidden.
"""
QApplication.instance().console_widget_registry.yield_ownership(self)
_bec_console_registry.yield_ownership(self)
self._infer_mode()
if self._mode != ConsoleMode.ACTIVE:
logger.debug(f"Widget {self.gui_id} yielded ownership of instance {self.terminal_id}")
@@ -371,13 +537,13 @@ class BecConsole(BECWidget, QWidget):
def showEvent(self, event):
"""Called when the widget is shown. Updates UI state based on ownership."""
super().showEvent(event)
if not QApplication.instance().console_widget_registry.is_owner(self):
if not QApplication.instance().console_widget_registry.owner_is_visible(self.terminal_id):
if not _bec_console_registry.is_owner(self):
if not _bec_console_registry.owner_is_visible(self.terminal_id):
self.take_terminal_ownership()
def cleanup(self):
"""Unregister this console on destruction."""
QApplication.instance().console_widget_registry.unregister(self)
_bec_console_registry.unregister(self)
super().cleanup()
@@ -389,6 +555,7 @@ class BECShell(BecConsole):
"""
ICON_NAME = "hub"
persist_terminal_session = True
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
super().__init__(
@@ -397,7 +564,6 @@ class BECShell(BecConsole):
client=client,
gui_id=gui_id,
terminal_id="bec_shell",
persevere_terminal=True,
**kwargs,
)
@@ -52,19 +52,19 @@ class BecQTerm(QWidget):
self.setLayout(self._layout)
if QTermWidget:
self._main_widget = QTermWidget(parent=self)
self.activity.connect(self._main_widget.activity)
self.bell.connect(self._main_widget.bell)
self.copy_available.connect(self._main_widget.copyAvailable)
self.current_directory_changed.connect(self._main_widget.currentDirectoryChanged)
self.finished.connect(self._main_widget.finished)
self.profile_changed.connect(self._main_widget.profileChanged)
self.received_data.connect(self._main_widget.receivedData)
self.silence.connect(self._main_widget.silence)
self.term_got_focus.connect(self._main_widget.termGetFocus)
self.term_key_pressed.connect(self._main_widget.termKeyPressed)
self.term_lost_focus.connect(self._main_widget.termLostFocus)
self.title_changed.connect(self._main_widget.titleChanged)
self.url_activated.connect(self._main_widget.urlActivated)
self._main_widget.activity.connect(self.activity)
self._main_widget.bell.connect(self.bell)
self._main_widget.copyAvailable.connect(self.copy_available)
self._main_widget.currentDirectoryChanged.connect(self.current_directory_changed)
self._main_widget.finished.connect(self.finished)
self._main_widget.profileChanged.connect(self.profile_changed)
self._main_widget.receivedData.connect(self.received_data)
self._main_widget.silence.connect(self.silence)
self._main_widget.termGetFocus.connect(self.term_got_focus)
self._main_widget.termKeyPressed.connect(self.term_key_pressed)
self._main_widget.termLostFocus.connect(self.term_lost_focus)
self._main_widget.titleChanged.connect(self.title_changed)
self._main_widget.urlActivated.connect(self.url_activated)
self._setEnvironment([f"{k}={v}" for k, v in os.environ.items()])
self._setColorScheme("Solarized")
else:
@@ -78,12 +78,6 @@ class BecQTerm(QWidget):
text += "\n"
self._sendText(text)
def deleteLater(self, /) -> None:
return super().deleteLater()
def close(self, /) -> bool:
return super().close()
# automatically forwarded to the widget only if it exists
@_forward
def _addCustomColorSchemeDir(self, custom_dir: str, /) -> None: ...
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "bec_widgets"
version = "3.4.2"
version = "3.5.0"
description = "BEC Widgets"
requires-python = ">=3.11"
classifiers = [
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# BENCHMARK_TITLE: Import bec_widgets
set -euo pipefail
python -c 'import bec_widgets; print(bec_widgets.__file__)'
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# BENCHMARK_TITLE: BEC IPython client with companion app
set -euo pipefail
bec --post-startup-file tests/benchmarks/hyperfine/utils/exit_bec_startup.py
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# BENCHMARK_TITLE: BEC IPython client without companion app
set -euo pipefail
bec --nogui --post-startup-file tests/benchmarks/hyperfine/utils/exit_bec_startup.py
@@ -0,0 +1,5 @@
import time
_ip = get_ipython()
_ip.confirm_exit = False
_ip.ask_exit()
+2 -1
View File
@@ -1,6 +1,7 @@
import pytest
from bec_widgets.cli.client import Image, MotorMap, Waveform
from bec_widgets.cli.client_utils import BECGuiClient
from bec_widgets.cli.rpc.rpc_base import RPCReference
# pylint: disable=unused-argument
@@ -122,7 +123,7 @@ def test_ring_bar(qtbot, connected_client_gui_obj):
assert gui._ipython_registry[bar._gui_id].__class__.__name__ == "RingProgressBar"
def test_rpc_gui_obj(connected_client_gui_obj, qtbot):
def test_rpc_gui_obj(connected_client_gui_obj: BECGuiClient, qtbot):
gui = connected_client_gui_obj
qtbot.waitUntil(lambda: len(gui.windows) == 1, timeout=3000)
@@ -0,0 +1,27 @@
from __future__ import annotations
import pytest
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from tests.unit_tests.client_mocks import mocked_client
@pytest.fixture
def dock_area(qtbot, mocked_client):
widget = BECDockArea(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_add_waveform_to_dock_area(benchmark, dock_area, qtbot, mocked_client):
"""Benchmark adding a Waveform widget to an existing dock area."""
def add_waveform():
dock_area.new("Waveform")
return dock_area
dock = benchmark(add_waveform)
assert dock is not None
+6 -1
View File
@@ -1,3 +1,8 @@
# Force ophyd onto its dummy control layer in tests so importing it does not
# try to create a real EPICS CA context.
import os
os.environ.setdefault("OPHYD_CONTROL_LAYER", "dummy")
import json
import time
from unittest import mock
@@ -13,7 +18,7 @@ from bec_lib.client import BECClient
from bec_lib.messages import _StoredDataInfo
from bec_qthemes import apply_theme
from bec_qthemes._theme import Theme
from ophyd._pyepics_shim import _dispatcher
from ophyd._dummy_shim import _dispatcher
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
from qtpy.QtCore import QEvent, QEventLoop
from qtpy.QtWidgets import QApplication, QMessageBox
+119 -6
View File
@@ -1,10 +1,13 @@
from unittest import mock
import pytest
from qtpy.QtCore import Qt
import shiboken6
from qtpy.QtCore import QEvent, QEventLoop, Qt
from qtpy.QtGui import QHideEvent, QShowEvent
from qtpy.QtTest import QTest
from qtpy.QtWidgets import QApplication, QWidget
import bec_widgets.widgets.editors.bec_console.bec_console as bec_console_module
from bec_widgets.widgets.editors.bec_console.bec_console import (
BecConsole,
BECShell,
@@ -15,6 +18,20 @@ from bec_widgets.widgets.editors.bec_console.bec_console import (
from .client_mocks import mocked_client
def process_deferred_deletes():
app = QApplication.instance()
app.sendPostedEvents(None, QEvent.DeferredDelete)
app.processEvents(QEventLoop.AllEvents)
@pytest.fixture(autouse=True)
def clean_bec_console_registry():
_bec_console_registry.clear()
yield
_bec_console_registry.clear()
process_deferred_deletes()
@pytest.fixture
def console_widget(qtbot):
"""Create a BecConsole widget."""
@@ -128,12 +145,108 @@ def test_is_owner(console_widget: BecConsole):
assert not _bec_console_registry.is_owner(mock_console)
def test_bec_shell_leaves_terminal_instantiated(qtbot):
widget = BECShell()
def test_closing_active_console_keeps_terminal_valid_for_remaining_console(qtbot):
widget1 = BecConsole(client=mocked_client, gui_id="close_owner", terminal_id="shared_close")
widget2 = BecConsole(client=mocked_client, gui_id="remaining", terminal_id="shared_close")
qtbot.addWidget(widget2)
widget1.take_terminal_ownership()
term = widget1.term
assert term is not None
widget1.close()
widget1.deleteLater()
process_deferred_deletes()
assert shiboken6.isValid(term)
widget2.take_terminal_ownership()
assert widget2.term is term
assert widget2._mode == ConsoleMode.ACTIVE
def test_active_console_detaches_terminal_before_destruction(qtbot):
widget1 = BecConsole(client=mocked_client, gui_id="owner", terminal_id="shared_detach")
widget2 = BecConsole(client=mocked_client, gui_id="survivor", terminal_id="shared_detach")
qtbot.addWidget(widget1)
qtbot.addWidget(widget2)
widget1.take_terminal_ownership()
term = widget1.term
assert term is not None
assert widget1.isAncestorOf(term)
widget1.close()
assert shiboken6.isValid(term)
assert not widget1.isAncestorOf(term)
assert term.parent() is widget2._term_holder
def test_bec_shell_terminal_persists_after_last_shell_unregisters(qtbot):
shell = BECShell(gui_id="bec_shell_persistent")
qtbot.addWidget(shell)
term = shell.term
assert term is not None
_bec_console_registry.unregister(shell)
info = _bec_console_registry._terminal_registry["bec_shell"]
assert info.registered_console_ids == set()
assert info.owner_console_id is None
assert info.persist_session is True
assert info.instance is term
assert shiboken6.isValid(term)
def test_new_bec_shell_claims_preserved_terminal(qtbot):
shell1 = BECShell(gui_id="bec_shell_first")
term = shell1.term
assert term is not None
shell1.close()
shell1.deleteLater()
process_deferred_deletes()
assert "bec_shell" in _bec_console_registry._terminal_registry
assert shiboken6.isValid(term)
shell2 = BECShell(gui_id="bec_shell_second")
qtbot.addWidget(shell2)
shell2.showEvent(QShowEvent())
assert shell2.term is term
assert shell2._mode == ConsoleMode.ACTIVE
def test_persistent_bec_shell_sends_startup_command_once(qtbot, monkeypatch):
class RecordingTerminal(QWidget):
writes = []
def write(self, text: str, add_newline: bool = True):
self.writes.append((text, add_newline))
monkeypatch.setattr(bec_console_module, "_BecTermClass", RecordingTerminal)
shell1 = BECShell(gui_id="bec_shell_startup_first")
shell1.close()
shell1.deleteLater()
process_deferred_deletes()
shell2 = BECShell(gui_id="bec_shell_startup_second")
qtbot.addWidget(shell2)
shell2.showEvent(QShowEvent())
assert len(RecordingTerminal.writes) == 1
assert RecordingTerminal.writes[0][0].startswith("bec ")
assert RecordingTerminal.writes[0][1] is True
def test_plain_console_terminal_removed_after_last_unregister(qtbot):
widget = BecConsole(client=mocked_client, gui_id="plain_console", terminal_id="plain_terminal")
qtbot.addWidget(widget)
assert len(_bec_console_registry._terminal_registry) != 0
assert "plain_terminal" in _bec_console_registry._terminal_registry
_bec_console_registry.unregister(widget)
assert len(_bec_console_registry._terminal_registry) != 0
assert _bec_console_registry._terminal_registry["bec_shell"].owner_console_id is None
assert "plain_terminal" not in _bec_console_registry._terminal_registry