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

Compare commits

..

1 Commits

Author SHA1 Message Date
perl_d e0695716e5 wip 2026-04-21 17:39:52 +02:00
22 changed files with 490 additions and 782 deletions
+1 -4
View File
@@ -1,7 +1,4 @@
########################## #!/usr/bin/env python3
### AI-generated file. ###
##########################
"""Aggregate and merge benchmark JSON files. """Aggregate and merge benchmark JSON files.
The workflow runs the same benchmark suite on multiple independent runners. The workflow runs the same benchmark suite on multiple independent runners.
+1 -4
View File
@@ -1,7 +1,4 @@
########################## #!/usr/bin/env python3
### AI-generated file. ###
##########################
"""Compare benchmark JSON files and write a GitHub Actions summary. """Compare benchmark JSON files and write a GitHub Actions summary.
The script supports JSON emitted by hyperfine, JSON emitted by pytest-benchmark, The script supports JSON emitted by hyperfine, JSON emitted by pytest-benchmark,
-5
View File
@@ -1,9 +1,4 @@
#!/usr/bin/env bash #!/usr/bin/env bash
##########################
### AI-generated file. ###
##########################
set -euo pipefail set -euo pipefail
mkdir -p benchmark-results mkdir -p benchmark-results
+1 -4
View File
@@ -1,7 +1,4 @@
########################## #!/usr/bin/env python3
### AI-generated file. ###
##########################
"""Run a command with BEC e2e services available.""" """Run a command with BEC e2e services available."""
from __future__ import annotations from __future__ import annotations
+6 -9
View File
@@ -1,6 +1,6 @@
name: BW Benchmarks name: BW Benchmarks
on: [ workflow_call ] on: [workflow_call]
permissions: permissions:
contents: read contents: read
@@ -10,7 +10,7 @@ env:
BENCHMARK_BASELINE_JSON: gh-pages-benchmark-data/benchmarks/latest.json BENCHMARK_BASELINE_JSON: gh-pages-benchmark-data/benchmarks/latest.json
BENCHMARK_SUMMARY: benchmark-results/summary.md BENCHMARK_SUMMARY: benchmark-results/summary.md
BENCHMARK_COMMAND: "bash .github/scripts/run_benchmarks.sh" BENCHMARK_COMMAND: "bash .github/scripts/run_benchmarks.sh"
BENCHMARK_THRESHOLD_PERCENT: 20 BENCHMARK_THRESHOLD_PERCENT: 10
BENCHMARK_HIGHER_IS_BETTER: false BENCHMARK_HIGHER_IS_BETTER: false
jobs: jobs:
@@ -25,7 +25,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
attempt: [ 1, 2, 3 ] attempt: [1, 2, 3]
env: env:
BENCHMARK_JSON: benchmark-results/current-${{ matrix.attempt }}.json BENCHMARK_JSON: benchmark-results/current-${{ matrix.attempt }}.json
@@ -84,7 +84,7 @@ jobs:
path: ${{ env.BENCHMARK_JSON }} path: ${{ env.BENCHMARK_JSON }}
benchmark: benchmark:
needs: [ benchmark_attempt ] needs: [benchmark_attempt]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
@@ -191,7 +191,7 @@ jobs:
run: exit 1 run: exit 1
publish: publish:
needs: [ benchmark ] needs: [benchmark]
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
@@ -208,10 +208,7 @@ jobs:
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: bw-benchmark-json name: bw-benchmark-json
path: benchmark-results path: .
- name: Verify aggregate benchmark artifact
run: test -s "$BENCHMARK_JSON"
- name: Prepare gh-pages for publishing - name: Prepare gh-pages for publishing
run: | run: |
-76
View File
@@ -1,82 +1,6 @@
# CHANGELOG # CHANGELOG
## v3.8.0 (2026-05-01)
### Bug Fixes
- **dock_area**: Change to show_dialo=False for CLI profile baseline restore
([`0b1f0b4`](https://github.com/bec-project/bec_widgets/commit/0b1f0b4c262ff31469b7114b9f00bf0a7b85e8f2))
- **dock_area**: Cli call load_profile has restore_baseline kwarg
([`cc82597`](https://github.com/bec-project/bec_widgets/commit/cc825972c202cd9ded32f8b2d1ce5f822c2ebdba))
### Features
- **dock_area**: Add CLI restore current profile from baseline with optional confirmation dialog
([`17865a2`](https://github.com/bec-project/bec_widgets/commit/17865a2c338a4a1f944659dde4ec05c25a8dd963))
## v3.7.3 (2026-05-01)
### Bug Fixes
- **dock_area**: Profile names changed, default->baseline, user->runtime
([`dd32caf`](https://github.com/bec-project/bec_widgets/commit/dd32caf6e815fd1922b6aae84d00decad9dbf869))
### Testing
- **dock_area**: Remove low-value tests
([`717d74b`](https://github.com/bec-project/bec_widgets/commit/717d74b19e8c6960209190c47ba32732ffaa0094))
## v3.7.2 (2026-04-29)
### Bug Fixes
- **dock-area**: Avoid switching profile when saving new profile
([`73b44cf`](https://github.com/bec-project/bec_widgets/commit/73b44cffb219347cacb609f3b93068eda6701b42))
- **workspace-actions**: Use try/finally and restore previous blocked state in refresh_profiles
([`30ef255`](https://github.com/bec-project/bec_widgets/commit/30ef25533af9df5a9bd9e69808dc49fdf22f4318))
Agent-Logs-Url:
https://github.com/bec-project/bec_widgets/sessions/004cb4bc-5015-485e-a803-1e63876b7024
Co-authored-by: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com>
### Build System
- Add pytest-benchmark dependency
([`551d38d`](https://github.com/bec-project/bec_widgets/commit/551d38d90111361e64bfcf10a1409be71dd298bc))
### Chores
- Update header comments in script files to indicate AI generation
([`3f1aa80`](https://github.com/bec-project/bec_widgets/commit/3f1aa80756368454c3631cb6cf9d29db28af177a))
### Continuous Integration
- Add benchmark workflow
([`999b7a2`](https://github.com/bec-project/bec_widgets/commit/999b7a2321f2f222c04b056a2db4280f66de9c48))
- Fix benchmark upload
([`19b5c8f`](https://github.com/bec-project/bec_widgets/commit/19b5c8f724dbdb7421957c290f9213bf072392df))
- Increase threshold to 20 percent
([`409c9e5`](https://github.com/bec-project/bec_widgets/commit/409c9e5bfacdfc003f1bc8c9944f01798bd3818e))
### Testing
- Fix assertions after updating ophyd devices templates
([`a614d66`](https://github.com/bec-project/bec_widgets/commit/a614d662d6da716a49afb4ed3a3903f108210386))
Co-authored-by: Copilot <copilot@github.com>
- Remove references to "scan_motors" in tests
([`5056ef8`](https://github.com/bec-project/bec_widgets/commit/5056ef8946d03a20e802709d3fe81c84c195fe41))
## v3.7.1 (2026-04-21) ## v3.7.1 (2026-04-21)
### Bug Fixes ### Bug Fixes
+14 -46
View File
@@ -340,10 +340,10 @@ class BECDockArea(RPCBase):
Save the current workspace profile. Save the current workspace profile.
On first save of a given name: On first save of a given name:
- writes a baseline copy to profiles/baseline/<name>.ini with created_at - writes a default copy to states/default/<name>.ini with tag=default and created_at
- writes a runtime copy to profiles/runtime/<name>.ini with created_at - writes a user copy to states/user/<name>.ini with tag=user and created_at
On subsequent saves: On subsequent saves of user-owned profiles:
- updates both the baseline and runtime copies so restore uses the latest snapshot. - updates both the default and user copies so restore uses the latest snapshot.
Read-only bundled profiles cannot be overwritten. Read-only bundled profiles cannot be overwritten.
Args: Args:
@@ -358,31 +358,15 @@ class BECDockArea(RPCBase):
@rpc_timeout(None) @rpc_timeout(None)
@rpc_call @rpc_call
def load_profile(self, name: "str | None" = None, restore_baseline: "bool" = False): def load_profile(self, name: "str | None" = None):
""" """
Load a workspace profile. Load a workspace profile.
Before switching, persist the current profile to the runtime copy. Before switching, persist the current profile to the user copy.
Prefer loading the runtime copy; fall back to the baseline copy. When Prefer loading the user copy; fall back to the default copy.
``restore_baseline`` is True, first overwrite the runtime copy with the
baseline profile and then load it.
Args: Args:
name (str | None): The name of the profile to load. If None, prompts the user. name (str | None): The name of the profile to load. If None, prompts the user.
restore_baseline (bool): If True, restore the runtime copy from the
baseline before loading. Defaults to False.
"""
@rpc_timeout(None)
@rpc_call
def restore_baseline_profile(self, name: "str | None" = None, show_dialog: "bool" = False):
"""
Overwrite the runtime copy of *name* with the baseline.
If *name* is None, target the currently active profile.
Args:
name (str | None): The name of the profile to restore. If None, uses the current profile.
show_dialog (bool): If True, ask for confirmation before restoring.
""" """
@rpc_call @rpc_call
@@ -1364,10 +1348,10 @@ class DockAreaView(RPCBase):
Save the current workspace profile. Save the current workspace profile.
On first save of a given name: On first save of a given name:
- writes a baseline copy to profiles/baseline/<name>.ini with created_at - writes a default copy to states/default/<name>.ini with tag=default and created_at
- writes a runtime copy to profiles/runtime/<name>.ini with created_at - writes a user copy to states/user/<name>.ini with tag=user and created_at
On subsequent saves: On subsequent saves of user-owned profiles:
- updates both the baseline and runtime copies so restore uses the latest snapshot. - updates both the default and user copies so restore uses the latest snapshot.
Read-only bundled profiles cannot be overwritten. Read-only bundled profiles cannot be overwritten.
Args: Args:
@@ -1382,31 +1366,15 @@ class DockAreaView(RPCBase):
@rpc_timeout(None) @rpc_timeout(None)
@rpc_call @rpc_call
def load_profile(self, name: "str | None" = None, restore_baseline: "bool" = False): def load_profile(self, name: "str | None" = None):
""" """
Load a workspace profile. Load a workspace profile.
Before switching, persist the current profile to the runtime copy. Before switching, persist the current profile to the user copy.
Prefer loading the runtime copy; fall back to the baseline copy. When Prefer loading the user copy; fall back to the default copy.
``restore_baseline`` is True, first overwrite the runtime copy with the
baseline profile and then load it.
Args: Args:
name (str | None): The name of the profile to load. If None, prompts the user. name (str | None): The name of the profile to load. If None, prompts the user.
restore_baseline (bool): If True, restore the runtime copy from the
baseline before loading. Defaults to False.
"""
@rpc_timeout(None)
@rpc_call
def restore_baseline_profile(self, name: "str | None" = None, show_dialog: "bool" = False):
"""
Overwrite the runtime copy of *name* with the baseline.
If *name* is None, target the currently active profile.
Args:
name (str | None): The name of the profile to restore. If None, uses the current profile.
show_dialog (bool): If True, ask for confirmation before restoring.
""" """
@rpc_call @rpc_call
+30 -11
View File
@@ -26,7 +26,9 @@ if TYPE_CHECKING: # pragma: no cover
import bec_widgets.cli.client as client import bec_widgets.cli.client as client
else: else:
GUIRegistryStateMessage = lazy_import_from("bec_lib.messages", "GUIRegistryStateMessage") GUIRegistryStateMessage = lazy_import_from(
"bec_lib.messages", "GUIRegistryStateMessage"
)
client = lazy_import("bec_widgets.cli.client") client = lazy_import("bec_widgets.cli.client")
@@ -199,7 +201,9 @@ class AvailableWidgetsNamespace:
for attr_name, _ in self.__dict__.items(): for attr_name, _ in self.__dict__.items():
docs = getattr(client, attr_name).__doc__ docs = getattr(client, attr_name).__doc__
docs = docs if docs else "No description available" docs = docs if docs else "No description available"
table.add_row(attr_name, docs if len(docs.strip()) > 0 else "No description available") table.add_row(
attr_name, docs if len(docs.strip()) > 0 else "No description available"
)
console.print(table) console.print(table)
return "" return ""
@@ -230,7 +234,9 @@ class BECGuiClient(RPCBase):
@property @property
def launcher(self) -> RPCBase: def launcher(self) -> RPCBase:
"""The launcher object.""" """The launcher object."""
return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher") return RPCBase(
gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher"
)
def _safe_register_stream(self, endpoint: EndpointInfo, cb: Callable, **kwargs): def _safe_register_stream(self, endpoint: EndpointInfo, cb: Callable, **kwargs):
"""Check if already registered for registration in idempotent functions.""" """Check if already registered for registration in idempotent functions."""
@@ -241,7 +247,8 @@ class BECGuiClient(RPCBase):
"""Connect to a GUI server""" """Connect to a GUI server"""
# Unregister the old callback # Unregister the old callback
self._client.connector.unregister( self._client.connector.unregister(
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update MessageEndpoints.gui_registry_state(self._gui_id),
cb=self._handle_registry_update,
) )
self._gui_id = gui_id self._gui_id = gui_id
@@ -401,7 +408,9 @@ class BECGuiClient(RPCBase):
and "has no attribute 'system.launch_dock_area'" not in error and "has no attribute 'system.launch_dock_area'" not in error
): ):
raise raise
logger.debug("Server does not support system.launch_dock_area; using launcher RPC") logger.debug(
"Server does not support system.launch_dock_area; using launcher RPC"
)
return self.launcher._run_rpc( return self.launcher._run_rpc(
"launch", "launch",
@@ -462,7 +471,8 @@ class BECGuiClient(RPCBase):
# Unregister the registry state # Unregister the registry state
self._client.connector.unregister( self._client.connector.unregister(
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update MessageEndpoints.gui_registry_state(self._gui_id),
cb=self._handle_registry_update,
) )
# Remove all reference from top level # Remove all reference from top level
self._top_level.clear() self._top_level.clear()
@@ -525,9 +535,15 @@ class BECGuiClient(RPCBase):
finally: finally:
threading.current_thread().cancel() # type: ignore threading.current_thread().cancel() # type: ignore
self._gui_started_timer = RepeatTimer( def check_gui_started_and_continue():
0.5, lambda: self._gui_is_alive() and gui_started_callback(self._gui_post_startup) if self._gui_is_alive():
) gui_started_callback(self._gui_post_startup)
elif self._process.poll():
logger.error(
f"GUI process failed to start with: {self._process.communicate()}"
)
self._gui_started_timer = RepeatTimer(0.5, check_gui_started_and_continue)
self._gui_started_timer.start() self._gui_started_timer.start()
if wait: if wait:
@@ -536,7 +552,8 @@ class BECGuiClient(RPCBase):
def _start(self, wait: bool = False) -> None: def _start(self, wait: bool = False) -> None:
self._killed = False self._killed = False
self._safe_register_stream( self._safe_register_stream(
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update MessageEndpoints.gui_registry_state(self._gui_id),
cb=self._handle_registry_update,
) )
return self._start_server(wait=wait) return self._start_server(wait=wait)
@@ -603,7 +620,9 @@ class BECGuiClient(RPCBase):
self._ipython_registry.pop(gui_id) self._ipython_registry.pop(gui_id)
removed_widgets = [ removed_widgets = [
widget.object_name for widget in self._top_level.values() if widget._is_deleted() widget.object_name
for widget in self._top_level.values()
if widget._is_deleted()
] ]
for widget_name in removed_widgets: for widget_name in removed_widgets:
@@ -35,25 +35,25 @@ from bec_widgets.utils.widget_state_manager import WidgetStateManager
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.containers.dock_area.profile_utils import ( from bec_widgets.widgets.containers.dock_area.profile_utils import (
SETTINGS_KEYS, SETTINGS_KEYS,
baseline_profile_candidates, default_profile_candidates,
delete_profile_files, delete_profile_files,
get_last_profile, get_last_profile,
is_profile_read_only, is_profile_read_only,
is_quick_select, is_quick_select,
list_profiles, list_profiles,
list_quick_profiles, list_quick_profiles,
load_baseline_profile_screenshot, load_default_profile_screenshot,
load_runtime_profile_screenshot, load_user_profile_screenshot,
now_iso_utc, now_iso_utc,
open_baseline_settings, open_default_settings,
open_runtime_settings, open_user_settings,
profile_origin, profile_origin,
profile_origin_display, profile_origin_display,
read_manifest, read_manifest,
restore_runtime_from_baseline, restore_user_from_default,
runtime_profile_candidates,
set_last_profile, set_last_profile,
set_quick_select, set_quick_select,
user_profile_candidates,
write_manifest, write_manifest,
) )
from bec_widgets.widgets.containers.dock_area.settings.dialogs import ( from bec_widgets.widgets.containers.dock_area.settings.dialogs import (
@@ -108,7 +108,6 @@ class BECDockArea(DockAreaWidget):
"list_profiles", "list_profiles",
"save_profile", "save_profile",
"load_profile", "load_profile",
"restore_baseline_profile",
"delete_profile", "delete_profile",
] ]
@@ -236,8 +235,11 @@ class BECDockArea(DockAreaWidget):
def _load_initial_profile(self, name: str) -> None: def _load_initial_profile(self, name: str) -> None:
"""Load the initial profile.""" """Load the initial profile."""
self.load_profile(name) self.load_profile(name)
combo = self.toolbar.components.get_action("workspace_combo").widget
combo.blockSignals(True)
if not self._empty_profile_active: if not self._empty_profile_active:
self._set_workspace_combo_text_silent(name) combo.setCurrentText(name)
combo.blockSignals(False)
def _start_empty_workspace(self) -> None: def _start_empty_workspace(self) -> None:
""" """
@@ -589,13 +591,13 @@ class BECDockArea(DockAreaWidget):
@property @property
def profile_namespace(self) -> str | None: def profile_namespace(self) -> str | None:
"""Namespace used to scope runtime/baseline profile files for this dock area.""" """Namespace used to scope user/default profile files for this dock area."""
return self._resolve_profile_namespace() return self._resolve_profile_namespace()
def _profile_exists(self, name: str, namespace: str | None) -> bool: def _profile_exists(self, name: str, namespace: str | None) -> bool:
return any( return any(
os.path.exists(path) for path in runtime_profile_candidates(name, namespace) os.path.exists(path) for path in user_profile_candidates(name, namespace)
) or any(os.path.exists(path) for path in baseline_profile_candidates(name, namespace)) ) or any(os.path.exists(path) for path in default_profile_candidates(name, namespace))
def _write_snapshot_to_settings(self, settings, save_preview: bool = True) -> None: def _write_snapshot_to_settings(self, settings, save_preview: bool = True) -> None:
""" """
@@ -621,34 +623,35 @@ class BECDockArea(DockAreaWidget):
name: str, name: str,
namespace: str | None, namespace: str | None,
*, *,
write_baseline: bool = True, write_default: bool = True,
write_runtime: bool = True, write_user: bool = True,
save_preview: bool = True, save_preview: bool = True,
) -> None: ) -> None:
""" """
Write profile settings to baseline and/or runtime settings files. Write profile settings to default and/or user settings files.
Args: Args:
name: The profile name. name: The profile name.
namespace: The profile namespace. namespace: The profile namespace.
write_baseline: Whether to write to the baseline settings file. write_default: Whether to write to the default settings file.
write_runtime: Whether to write to the runtime settings file. write_user: Whether to write to the user settings file.
save_preview: Whether to save a screenshot preview. save_preview: Whether to save a screenshot preview.
""" """
if write_default:
ds = open_default_settings(name, namespace=namespace)
self._write_snapshot_to_settings(ds, save_preview=save_preview)
if not ds.value(SETTINGS_KEYS["created_at"], ""):
ds.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
if not ds.value(SETTINGS_KEYS["is_quick_select"], None):
ds.setValue(SETTINGS_KEYS["is_quick_select"], True)
def _write_settings(open_settings) -> None: if write_user:
settings = open_settings(name, namespace=namespace) us = open_user_settings(name, namespace=namespace)
self._write_snapshot_to_settings(settings, save_preview=save_preview) self._write_snapshot_to_settings(us, save_preview=save_preview)
if not settings.value(SETTINGS_KEYS["created_at"], ""): if not us.value(SETTINGS_KEYS["created_at"], ""):
settings.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) us.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
if not settings.value(SETTINGS_KEYS["is_quick_select"], None): if not us.value(SETTINGS_KEYS["is_quick_select"], None):
settings.setValue(SETTINGS_KEYS["is_quick_select"], True) us.setValue(SETTINGS_KEYS["is_quick_select"], True)
if write_baseline:
_write_settings(open_baseline_settings)
if write_runtime:
_write_settings(open_runtime_settings)
def _finalize_profile_change(self, name: str, namespace: str | None) -> None: def _finalize_profile_change(self, name: str, namespace: str | None) -> None:
""" """
@@ -666,14 +669,6 @@ class BECDockArea(DockAreaWidget):
combo = self.toolbar.components.get_action("workspace_combo").widget combo = self.toolbar.components.get_action("workspace_combo").widget
combo.refresh_profiles(active_profile=name) combo.refresh_profiles(active_profile=name)
def _set_workspace_combo_text_silent(self, text: str) -> None:
combo = self.toolbar.components.get_action("workspace_combo").widget
was_blocked = combo.blockSignals(True)
try:
combo.setCurrentText(text)
finally:
combo.blockSignals(was_blocked)
def _enter_empty_profile_state(self) -> None: def _enter_empty_profile_state(self) -> None:
""" """
Switch to the transient empty workspace state. Switch to the transient empty workspace state.
@@ -710,10 +705,10 @@ class BECDockArea(DockAreaWidget):
Save the current workspace profile. Save the current workspace profile.
On first save of a given name: On first save of a given name:
- writes a baseline copy to profiles/baseline/<name>.ini with created_at - writes a default copy to states/default/<name>.ini with tag=default and created_at
- writes a runtime copy to profiles/runtime/<name>.ini with created_at - writes a user copy to states/user/<name>.ini with tag=user and created_at
On subsequent saves: On subsequent saves of user-owned profiles:
- updates both the baseline and runtime copies so restore uses the latest snapshot. - updates both the default and user copies so restore uses the latest snapshot.
Read-only bundled profiles cannot be overwritten. Read-only bundled profiles cannot be overwritten.
Args: Args:
@@ -777,7 +772,7 @@ class BECDockArea(DockAreaWidget):
overwrite_existing = origin == "settings" overwrite_existing = origin == "settings"
origin_before_save = profile_origin(name, namespace=namespace) origin_before_save = profile_origin(name, namespace=namespace)
overwrite_baseline = overwrite_existing and origin_before_save == "settings" overwrite_default = overwrite_existing and origin_before_save == "settings"
# Display saving placeholder in toolbar # Display saving placeholder in toolbar
workspace_combo = self.toolbar.components.get_action("workspace_combo").widget workspace_combo = self.toolbar.components.get_action("workspace_combo").widget
@@ -786,12 +781,12 @@ class BECDockArea(DockAreaWidget):
workspace_combo.setCurrentIndex(0) workspace_combo.setCurrentIndex(0)
workspace_combo.blockSignals(False) workspace_combo.blockSignals(False)
# Write to baseline and/or runtime settings # Write to default and/or user settings
should_write_baseline = overwrite_baseline or not any( should_write_default = overwrite_default or not any(
os.path.exists(path) for path in baseline_profile_candidates(name, namespace) os.path.exists(path) for path in default_profile_candidates(name, namespace)
) )
self._write_profile_settings( self._write_profile_settings(
name, namespace, write_baseline=should_write_baseline, write_runtime=True name, namespace, write_default=should_write_default, write_user=True
) )
set_quick_select(name, quickselect, namespace=namespace) set_quick_select(name, quickselect, namespace=namespace)
@@ -801,6 +796,7 @@ class BECDockArea(DockAreaWidget):
self._pending_autosave_skip = (current_profile, name) self._pending_autosave_skip = (current_profile, name)
else: else:
self._pending_autosave_skip = None self._pending_autosave_skip = None
workspace_combo.setCurrentText(name)
self._finalize_profile_change(name, namespace) self._finalize_profile_change(name, namespace)
@SafeSlot() @SafeSlot()
@@ -820,21 +816,16 @@ class BECDockArea(DockAreaWidget):
@SafeSlot() @SafeSlot()
@SafeSlot(str) @SafeSlot(str)
@SafeSlot(str, bool)
@rpc_timeout(None) @rpc_timeout(None)
def load_profile(self, name: str | None = None, restore_baseline: bool = False): def load_profile(self, name: str | None = None):
""" """
Load a workspace profile. Load a workspace profile.
Before switching, persist the current profile to the runtime copy. Before switching, persist the current profile to the user copy.
Prefer loading the runtime copy; fall back to the baseline copy. When Prefer loading the user copy; fall back to the default copy.
``restore_baseline`` is True, first overwrite the runtime copy with the
baseline profile and then load it.
Args: Args:
name (str | None): The name of the profile to load. If None, prompts the user. name (str | None): The name of the profile to load. If None, prompts the user.
restore_baseline (bool): If True, restore the runtime copy from the
baseline before loading. Defaults to False.
""" """
if name == "": if name == "":
return return
@@ -853,17 +844,14 @@ class BECDockArea(DockAreaWidget):
if skip_pair and skip_pair == (prev_name, name): if skip_pair and skip_pair == (prev_name, name):
self._pending_autosave_skip = None self._pending_autosave_skip = None
else: else:
us_prev = open_runtime_settings(prev_name, namespace=namespace) us_prev = open_user_settings(prev_name, namespace=namespace)
self._write_snapshot_to_settings(us_prev, save_preview=True) self._write_snapshot_to_settings(us_prev, save_preview=True)
if restore_baseline:
restore_runtime_from_baseline(name, namespace=namespace)
settings = None settings = None
if any(os.path.exists(path) for path in runtime_profile_candidates(name, namespace)): if any(os.path.exists(path) for path in user_profile_candidates(name, namespace)):
settings = open_runtime_settings(name, namespace=namespace) settings = open_user_settings(name, namespace=namespace)
elif any(os.path.exists(path) for path in baseline_profile_candidates(name, namespace)): elif any(os.path.exists(path) for path in default_profile_candidates(name, namespace)):
settings = open_baseline_settings(name, namespace=namespace) settings = open_default_settings(name, namespace=namespace)
if settings is None: if settings is None:
logger.warning(f"Profile '{name}' not found in namespace '{namespace}'. Creating new.") logger.warning(f"Profile '{name}' not found in namespace '{namespace}'. Creating new.")
self.delete_all() self.delete_all()
@@ -905,36 +893,32 @@ class BECDockArea(DockAreaWidget):
@SafeSlot() @SafeSlot()
@SafeSlot(str) @SafeSlot(str)
@SafeSlot(str, bool) def restore_user_profile_from_default(self, name: str | None = None):
@rpc_timeout(None)
def restore_baseline_profile(self, name: str | None = None, show_dialog: bool = False):
""" """
Overwrite the runtime copy of *name* with the baseline. Overwrite the user copy of *name* with the default baseline.
If *name* is None, target the currently active profile. If *name* is None, target the currently active profile.
Args: Args:
name (str | None): The name of the profile to restore. If None, uses the current profile. name (str | None): The name of the profile to restore. If None, uses the current profile.
show_dialog (bool): If True, ask for confirmation before restoring.
""" """
target = name or getattr(self, "_current_profile_name", None) target = name or getattr(self, "_current_profile_name", None)
if not target: if not target:
return return
namespace = self.profile_namespace namespace = self.profile_namespace
if show_dialog: current_pixmap = None
current_pixmap = None if self.isVisible():
if self.isVisible(): current_pixmap = QPixmap()
current_pixmap = QPixmap() ba = bytes(self.screenshot_bytes())
ba = bytes(self.screenshot_bytes()) current_pixmap.loadFromData(ba)
current_pixmap.loadFromData(ba) if current_pixmap is None or current_pixmap.isNull():
if current_pixmap is None or current_pixmap.isNull(): current_pixmap = load_user_profile_screenshot(target, namespace=namespace)
current_pixmap = load_runtime_profile_screenshot(target, namespace=namespace) default_pixmap = load_default_profile_screenshot(target, namespace=namespace)
baseline_pixmap = load_baseline_profile_screenshot(target, namespace=namespace)
if not RestoreProfileDialog.confirm(self, current_pixmap, baseline_pixmap): if not RestoreProfileDialog.confirm(self, current_pixmap, default_pixmap):
return return
restore_runtime_from_baseline(target, namespace=namespace) restore_user_from_default(target, namespace=namespace)
self.delete_all() self.delete_all()
self.load_profile(target) self.load_profile(target)
@@ -1069,7 +1053,7 @@ class BECDockArea(DockAreaWidget):
manage_action = self.toolbar.components.get_action("manage_workspaces").action manage_action = self.toolbar.components.get_action("manage_workspaces").action
if self.manage_dialog is None or not self.manage_dialog.isVisible(): if self.manage_dialog is None or not self.manage_dialog.isVisible():
self.manage_widget = WorkSpaceManager( self.manage_widget = WorkSpaceManager(
self, target_widget=self, active_profile=self._current_profile_name self, target_widget=self, default_profile=self._current_profile_name
) )
self.manage_dialog = QDialog(modal=False) self.manage_dialog = QDialog(modal=False)
@@ -1168,7 +1152,7 @@ class BECDockArea(DockAreaWidget):
return return
namespace = self.profile_namespace namespace = self.profile_namespace
settings = open_runtime_settings(name, namespace=namespace) settings = open_user_settings(name, namespace=namespace)
self._write_snapshot_to_settings(settings) self._write_snapshot_to_settings(settings)
set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id())
self._exit_snapshot_written = True self._exit_snapshot_written = True
@@ -2,13 +2,9 @@
Utilities for managing BECDockArea profiles stored in INI files. Utilities for managing BECDockArea profiles stored in INI files.
Policy: Policy:
- All created/modified profiles are stored under the BEC settings root: - All created/modified profiles are stored under the BEC settings root: <base_path>/profiles/{default,user}
<base_path>/profiles/{baseline,runtime} - Bundled read-only defaults are discovered in BW core states/default and plugin bec_widgets/profiles but never written to.
- Bundled read-only baselines are discovered in BW core profiles and plugin - Lookup order when reading: user → settings default → app or plugin bundled default.
bec_widgets/profiles but never written to.
- Lookup order when reading: runtime → settings baseline → app or plugin bundled baseline.
- Legacy settings paths profiles/{default,user} are read through a thin segment
alias layer and copied to the canonical location on first access.
""" """
from __future__ import annotations from __future__ import annotations
@@ -36,12 +32,6 @@ logger = bec_logger.logger
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
ProfileOrigin = Literal["module", "plugin", "settings", "unknown"] ProfileOrigin = Literal["module", "plugin", "settings", "unknown"]
ProfileSegment = Literal["baseline", "runtime"]
_PROFILE_SEGMENT_ALIASES: dict[ProfileSegment, tuple[str, str]] = {
"baseline": ("baseline", "default"),
"runtime": ("runtime", "user"),
}
def module_profiles_dir() -> str: def module_profiles_dir() -> str:
@@ -140,7 +130,7 @@ def _profiles_dir(segment: str, namespace: str | None) -> str:
Build (and ensure) the directory that holds profiles for a namespace segment. Build (and ensure) the directory that holds profiles for a namespace segment.
Args: Args:
segment (str): Profile segment directory name. segment (str): Either ``"user"`` or ``"default"``.
namespace (str | None): Optional namespace label to scope profiles. namespace (str | None): Optional namespace label to scope profiles.
Returns: Returns:
@@ -153,175 +143,157 @@ def _profiles_dir(segment: str, namespace: str | None) -> str:
return path return path
def _candidate_namespaces(namespace: str | None) -> list[str | None]: def _user_path_candidates(name: str, namespace: str | None) -> list[str]:
"""
Generate candidate user-profile paths honoring namespace fallbacks.
Args:
name (str): Profile name without extension.
namespace (str | None): Optional namespace label.
Returns:
list[str]: Ordered list of candidate user profile paths (.ini files).
"""
ns = slugify.slugify(namespace, separator="_") if namespace else None ns = slugify.slugify(namespace, separator="_") if namespace else None
primary = os.path.join(_profiles_dir("user", ns), f"{name}.ini")
if not ns: if not ns:
return [None] return [primary]
return [ns, None] legacy = os.path.join(_profiles_dir("user", None), f"{name}.ini")
return [primary, legacy] if legacy != primary else [primary]
def _segment_profile_path(segment_name: str, name: str, namespace: str | None) -> str: def _default_path_candidates(name: str, namespace: str | None) -> list[str]:
return os.path.join(_profiles_dir(segment_name, namespace), f"{name}.ini")
def _canonical_profile_path(segment: ProfileSegment, name: str, namespace: str | None) -> str:
return _segment_profile_path(_PROFILE_SEGMENT_ALIASES[segment][0], name, namespace)
def _segment_path_candidates(
segment: ProfileSegment,
name: str,
namespace: str | None,
*,
include_legacy: bool = True,
migrate_legacy: bool = True,
) -> list[str]:
""" """
Generate profile candidates for a canonical segment. Generate candidate default-profile paths honoring namespace fallbacks.
Canonical baseline/runtime files are always preferred. Namespace fallback Args:
files and legacy default/user files are copied to the primary canonical path name (str): Profile name without extension.
when the primary file does not exist. namespace (str | None): Optional namespace label.
Returns:
list[str]: Ordered list of candidate default profile paths (.ini files).
""" """
canonical = [ ns = slugify.slugify(namespace, separator="_") if namespace else None
_segment_profile_path(_PROFILE_SEGMENT_ALIASES[segment][0], name, ns) primary = os.path.join(_profiles_dir("default", ns), f"{name}.ini")
for ns in _candidate_namespaces(namespace) if not ns:
] return [primary]
legacy = [] legacy = os.path.join(_profiles_dir("default", None), f"{name}.ini")
if include_legacy: return [primary, legacy] if legacy != primary else [primary]
legacy = [
_segment_profile_path(_PROFILE_SEGMENT_ALIASES[segment][1], name, ns)
for ns in _candidate_namespaces(namespace)
]
primary_canonical = canonical[0]
if migrate_legacy and not os.path.exists(primary_canonical):
canonical_src = next((path for path in canonical[1:] if os.path.exists(path)), None)
if canonical_src:
os.makedirs(os.path.dirname(primary_canonical), exist_ok=True)
shutil.copy2(canonical_src, primary_canonical)
elif include_legacy:
legacy_src = next((path for path in legacy if os.path.exists(path)), None)
if legacy_src:
os.makedirs(os.path.dirname(primary_canonical), exist_ok=True)
shutil.copy2(legacy_src, primary_canonical)
return list(dict.fromkeys(canonical + legacy))
def baseline_profiles_dir(namespace: str | None = None) -> str: def default_profiles_dir(namespace: str | None = None) -> str:
""" """
Return the directory that stores baseline profiles for the namespace. Return the directory that stores default profiles for the namespace.
Args: Args:
namespace (str | None, optional): Namespace label. Defaults to ``None``. namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns: Returns:
str: Absolute path to the baseline profile directory. str: Absolute path to the default profile directory.
""" """
return _profiles_dir("baseline", namespace) return _profiles_dir("default", namespace)
def runtime_profiles_dir(namespace: str | None = None) -> str: def user_profiles_dir(namespace: str | None = None) -> str:
""" """
Return the directory that stores runtime profiles for the namespace. Return the directory that stores user profiles for the namespace.
Args: Args:
namespace (str | None, optional): Namespace label. Defaults to ``None``. namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns: Returns:
str: Absolute path to the runtime profile directory. str: Absolute path to the user profile directory.
""" """
return _profiles_dir("runtime", namespace) return _profiles_dir("user", namespace)
def baseline_profile_path(name: str, namespace: str | None = None) -> str: def default_profile_path(name: str, namespace: str | None = None) -> str:
""" """
Compute the canonical baseline profile path for a profile name. Compute the canonical default profile path for a profile name.
Args: Args:
name (str): Profile name without extension. name (str): Profile name without extension.
namespace (str | None, optional): Namespace label. Defaults to ``None``. namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns: Returns:
str: Absolute path to the baseline profile file (.ini). str: Absolute path to the default profile file (.ini).
""" """
return _canonical_profile_path("baseline", name, namespace) return _default_path_candidates(name, namespace)[0]
def runtime_profile_path(name: str, namespace: str | None = None) -> str: def user_profile_path(name: str, namespace: str | None = None) -> str:
""" """
Compute the canonical runtime profile path for a profile name. Compute the canonical user profile path for a profile name.
Args: Args:
name (str): Profile name without extension. name (str): Profile name without extension.
namespace (str | None, optional): Namespace label. Defaults to ``None``. namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns: Returns:
str: Absolute path to the runtime profile file (.ini). str: Absolute path to the user profile file (.ini).
""" """
return _canonical_profile_path("runtime", name, namespace) return _user_path_candidates(name, namespace)[0]
def runtime_profile_candidates(name: str, namespace: str | None = None) -> list[str]: def user_profile_candidates(name: str, namespace: str | None = None) -> list[str]:
""" """
List all runtime profile path candidates for a profile name. List all user profile path candidates for a profile name.
Args: Args:
name (str): Profile name without extension. name (str): Profile name without extension.
namespace (str | None, optional): Namespace label. Defaults to ``None``. namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns: Returns:
list[str]: De-duplicated list of candidate runtime profile paths. list[str]: De-duplicated list of candidate user profile paths.
""" """
return _segment_path_candidates("runtime", name, namespace) return list(dict.fromkeys(_user_path_candidates(name, namespace)))
def baseline_profile_candidates(name: str, namespace: str | None = None) -> list[str]: def default_profile_candidates(name: str, namespace: str | None = None) -> list[str]:
""" """
List all baseline profile path candidates for a profile name. List all default profile path candidates for a profile name.
Args: Args:
name (str): Profile name without extension. name (str): Profile name without extension.
namespace (str | None, optional): Namespace label. Defaults to ``None``. namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns: Returns:
list[str]: De-duplicated list of candidate baseline profile paths. list[str]: De-duplicated list of candidate default profile paths.
""" """
return _segment_path_candidates("baseline", name, namespace) return list(dict.fromkeys(_default_path_candidates(name, namespace)))
def _existing_runtime_settings(name: str, namespace: str | None = None) -> QSettings | None: def _existing_user_settings(name: str, namespace: str | None = None) -> QSettings | None:
""" """
Resolve the first existing runtime profile settings object. Resolve the first existing user profile settings object.
Args: Args:
name (str): Profile name without extension. name (str): Profile name without extension.
namespace (str | None, optional): Namespace label to search. Defaults to ``None``. namespace (str | None, optional): Namespace label to search. Defaults to ``None``.
Returns: Returns:
QSettings | None: Config for the first existing runtime profile candidate, or ``None`` QSettings | None: Config for the first existing user profile candidate, or ``None``
when no files are present. when no files are present.
""" """
for path in runtime_profile_candidates(name, namespace): for path in user_profile_candidates(name, namespace):
if os.path.exists(path): if os.path.exists(path):
return QSettings(path, QSettings.IniFormat) return QSettings(path, QSettings.IniFormat)
return None return None
def _existing_baseline_settings(name: str, namespace: str | None = None) -> QSettings | None: def _existing_default_settings(name: str, namespace: str | None = None) -> QSettings | None:
""" """
Resolve the first existing baseline profile settings object. Resolve the first existing default profile settings object.
Args: Args:
name (str): Profile name without extension. name (str): Profile name without extension.
namespace (str | None, optional): Namespace label to search. Defaults to ``None``. namespace (str | None, optional): Namespace label to search. Defaults to ``None``.
Returns: Returns:
QSettings | None: Config for the first existing baseline profile candidate, or ``None`` QSettings | None: Config for the first existing default profile candidate, or ``None``
when no files are present. when no files are present.
""" """
for path in baseline_profile_candidates(name, namespace): for path in default_profile_candidates(name, namespace):
if os.path.exists(path): if os.path.exists(path):
return QSettings(path, QSettings.IniFormat) return QSettings(path, QSettings.IniFormat)
return None return None
@@ -375,7 +347,7 @@ def profile_origin(name: str, namespace: str | None = None) -> ProfileOrigin:
plugin_path = plugin_profile_path(name) plugin_path = plugin_profile_path(name)
if plugin_path and os.path.exists(plugin_path): if plugin_path and os.path.exists(plugin_path):
return "plugin" return "plugin"
for path in runtime_profile_candidates(name, namespace) + baseline_profile_candidates( for path in user_profile_candidates(name, namespace) + default_profile_candidates(
name, namespace name, namespace
): ):
if os.path.exists(path): if os.path.exists(path):
@@ -434,8 +406,8 @@ def delete_profile_files(name: str, namespace: str | None = None) -> bool:
read_only = is_profile_read_only(name, namespace) read_only = is_profile_read_only(name, namespace)
removed = False removed = False
# Always allow removing runtime copies; keep baseline copies for read-only origins. # Always allow removing user copies; keep default copies for read-only origins.
for path in set(runtime_profile_candidates(name, namespace)): for path in set(user_profile_candidates(name, namespace)):
try: try:
os.remove(path) os.remove(path)
removed = True removed = True
@@ -443,7 +415,7 @@ def delete_profile_files(name: str, namespace: str | None = None) -> bool:
continue continue
if not read_only: if not read_only:
for path in set(baseline_profile_candidates(name, namespace)): for path in set(default_profile_candidates(name, namespace)):
try: try:
os.remove(path) os.remove(path)
removed = True removed = True
@@ -471,7 +443,7 @@ SETTINGS_KEYS = {
def list_profiles(namespace: str | None = None) -> list[str]: def list_profiles(namespace: str | None = None) -> list[str]:
""" """
Enumerate all known profile names, syncing bundled baselines when missing locally. Enumerate all known profile names, syncing bundled defaults when missing locally.
Args: Args:
namespace (str | None, optional): Namespace label scoped to the profile set. namespace (str | None, optional): Namespace label scoped to the profile set.
@@ -487,27 +459,16 @@ def list_profiles(namespace: str | None = None) -> list[str]:
return set() return set()
return {os.path.splitext(f)[0] for f in os.listdir(directory) if f.endswith(".ini")} return {os.path.splitext(f)[0] for f in os.listdir(directory) if f.endswith(".ini")}
settings_dirs = {baseline_profiles_dir(namespace), runtime_profiles_dir(namespace)} settings_dirs = {default_profiles_dir(namespace), user_profiles_dir(namespace)}
if ns: if ns:
settings_dirs.add(baseline_profiles_dir(None)) settings_dirs.add(default_profiles_dir(None))
settings_dirs.add(runtime_profiles_dir(None)) settings_dirs.add(user_profiles_dir(None))
for segment in ("baseline", "runtime"):
for legacy_dir in [
_profiles_dir(_PROFILE_SEGMENT_ALIASES[segment][1], item)
for item in _candidate_namespaces(namespace)
]:
settings_dirs.add(legacy_dir)
settings_names: set[str] = set() settings_names: set[str] = set()
for directory in settings_dirs: for directory in settings_dirs:
settings_names |= _collect_from(directory) settings_names |= _collect_from(directory)
for name in sorted(settings_names): # Also consider read-only defaults from core module and beamline plugin repositories
runtime_profile_candidates(name, namespace)
baseline_profile_candidates(name, namespace)
# Also consider read-only baselines from core module and beamline plugin repositories
read_only_sources: dict[str, tuple[str, str]] = {} read_only_sources: dict[str, tuple[str, str]] = {}
sources: list[tuple[str, str | None]] = [ sources: list[tuple[str, str | None]] = [
("module", module_profiles_dir()), ("module", module_profiles_dir()),
@@ -523,17 +484,17 @@ def list_profiles(namespace: str | None = None) -> list[str]:
read_only_sources.setdefault(name, (origin, os.path.join(directory, filename))) read_only_sources.setdefault(name, (origin, os.path.join(directory, filename)))
for name, (_origin, src) in sorted(read_only_sources.items()): for name, (_origin, src) in sorted(read_only_sources.items()):
# Ensure a copy in the namespace-specific settings baseline directory. # Ensure a copy in the namespace-specific settings default directory
dst_baseline = baseline_profile_path(name, namespace) dst_default = default_profile_path(name, namespace)
if not os.path.exists(dst_baseline): if not os.path.exists(dst_default):
os.makedirs(os.path.dirname(dst_baseline), exist_ok=True) os.makedirs(os.path.dirname(dst_default), exist_ok=True)
shutil.copy2(src, dst_baseline) shutil.copyfile(src, dst_default)
# Ensure a runtime copy exists to allow edits in the writable settings area. # Ensure a user copy exists to allow edits in the writable settings area
dst_runtime = runtime_profile_path(name, namespace) dst_user = user_profile_path(name, namespace)
if not os.path.exists(dst_runtime): if not os.path.exists(dst_user):
os.makedirs(os.path.dirname(dst_runtime), exist_ok=True) os.makedirs(os.path.dirname(dst_user), exist_ok=True)
shutil.copy2(src, dst_runtime) shutil.copyfile(src, dst_user)
s = open_runtime_settings(name, namespace) s = open_user_settings(name, namespace)
if s.value(SETTINGS_KEYS["created_at"], "") == "": if s.value(SETTINGS_KEYS["created_at"], "") == "":
s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
@@ -543,34 +504,32 @@ def list_profiles(namespace: str | None = None) -> list[str]:
return sorted(settings_names) return sorted(settings_names)
def open_baseline_settings(name: str, namespace: str | None = None) -> QSettings: def open_default_settings(name: str, namespace: str | None = None) -> QSettings:
""" """
Open (and create if necessary) the baseline profile settings file. Open (and create if necessary) the default profile settings file.
Args: Args:
name (str): Profile name without extension. name (str): Profile name without extension.
namespace (str | None, optional): Namespace label. Defaults to ``None``. namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns: Returns:
QSettings: Settings instance targeting the baseline profile file. QSettings: Settings instance targeting the default profile file.
""" """
baseline_profile_candidates(name, namespace) return QSettings(default_profile_path(name, namespace), QSettings.IniFormat)
return QSettings(baseline_profile_path(name, namespace), QSettings.IniFormat)
def open_runtime_settings(name: str, namespace: str | None = None) -> QSettings: def open_user_settings(name: str, namespace: str | None = None) -> QSettings:
""" """
Open (and create if necessary) the runtime profile settings file. Open (and create if necessary) the user profile settings file.
Args: Args:
name (str): Profile name without extension. name (str): Profile name without extension.
namespace (str | None, optional): Namespace label. Defaults to ``None``. namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns: Returns:
QSettings: Settings instance targeting the runtime profile file. QSettings: Settings instance targeting the user profile file.
""" """
runtime_profile_candidates(name, namespace) return QSettings(user_profile_path(name, namespace), QSettings.IniFormat)
return QSettings(runtime_profile_path(name, namespace), QSettings.IniFormat)
def _app_settings() -> QSettings: def _app_settings() -> QSettings:
@@ -800,26 +759,26 @@ def read_manifest(settings: QSettings) -> list[dict]:
return items return items
def restore_runtime_from_baseline(name: str, namespace: str | None = None) -> None: def restore_user_from_default(name: str, namespace: str | None = None) -> None:
""" """
Copy the baseline profile to the runtime profile, preserving quick-select flag. Copy the default profile to the user profile, preserving quick-select flag.
Args: Args:
name(str): Profile name without extension. name(str): Profile name without extension.
namespace(str | None, optional): Namespace label. Defaults to ``None``. namespace(str | None, optional): Namespace label. Defaults to ``None``.
""" """
src = None src = None
for candidate in baseline_profile_candidates(name, namespace): for candidate in default_profile_candidates(name, namespace):
if os.path.exists(candidate): if os.path.exists(candidate):
src = candidate src = candidate
break break
if not src: if not src:
return return
dst = runtime_profile_path(name, namespace) dst = user_profile_path(name, namespace)
preserve_quick_select = is_quick_select(name, namespace) preserve_quick_select = is_quick_select(name, namespace)
os.makedirs(os.path.dirname(dst), exist_ok=True) os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copyfile(src, dst) shutil.copyfile(src, dst)
s = open_runtime_settings(name, namespace) s = open_user_settings(name, namespace)
if not s.value(SETTINGS_KEYS["created_at"], ""): if not s.value(SETTINGS_KEYS["created_at"], ""):
s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
if preserve_quick_select: if preserve_quick_select:
@@ -837,9 +796,9 @@ def is_quick_select(name: str, namespace: str | None = None) -> bool:
Returns: Returns:
bool: True if quick-select is enabled for the profile. bool: True if quick-select is enabled for the profile.
""" """
s = _existing_runtime_settings(name, namespace) s = _existing_user_settings(name, namespace)
if s is None: if s is None:
s = _existing_baseline_settings(name, namespace) s = _existing_default_settings(name, namespace)
if s is None: if s is None:
return False return False
return s.value(SETTINGS_KEYS["is_quick_select"], False, type=bool) return s.value(SETTINGS_KEYS["is_quick_select"], False, type=bool)
@@ -854,13 +813,13 @@ def set_quick_select(name: str, enabled: bool, namespace: str | None = None) ->
enabled(bool): True to enable quick-select, False to disable. enabled(bool): True to enable quick-select, False to disable.
namespace(str | None, optional): Namespace label. Defaults to ``None``. namespace(str | None, optional): Namespace label. Defaults to ``None``.
""" """
s = open_runtime_settings(name, namespace) s = open_user_settings(name, namespace)
s.setValue(SETTINGS_KEYS["is_quick_select"], bool(enabled)) s.setValue(SETTINGS_KEYS["is_quick_select"], bool(enabled))
def list_quick_profiles(namespace: str | None = None) -> list[str]: def list_quick_profiles(namespace: str | None = None) -> list[str]:
""" """
List only profiles that have quick-select enabled (runtime wins over baseline). List only profiles that have quick-select enabled (user wins over default).
Args: Args:
namespace(str | None, optional): Namespace label. Defaults to ``None``. namespace(str | None, optional): Namespace label. Defaults to ``None``.
@@ -950,8 +909,8 @@ class ProfileInfo(BaseModel):
is_quick_select: bool = False is_quick_select: bool = False
widget_count: int = 0 widget_count: int = 0
size_kb: int = 0 size_kb: int = 0
runtime_path: str = "" user_path: str = ""
baseline_path: str = "" default_path: str = ""
origin: ProfileOrigin = "unknown" origin: ProfileOrigin = "unknown"
is_read_only: bool = False is_read_only: bool = False
@@ -965,19 +924,19 @@ def get_profile_info(name: str, namespace: str | None = None) -> ProfileInfo:
namespace (str | None, optional): Namespace label. Defaults to ``None``. namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns: Returns:
ProfileInfo: Structured profile metadata, preferring the runtime copy when present. ProfileInfo: Structured profile metadata, preferring the user copy when present.
""" """
runtime_paths = runtime_profile_candidates(name, namespace) user_paths = user_profile_candidates(name, namespace)
baseline_paths = baseline_profile_candidates(name, namespace) default_paths = default_profile_candidates(name, namespace)
r_path = next((p for p in runtime_paths if os.path.exists(p)), runtime_paths[0]) u_path = next((p for p in user_paths if os.path.exists(p)), user_paths[0])
b_path = next((p for p in baseline_paths if os.path.exists(p)), baseline_paths[0]) d_path = next((p for p in default_paths if os.path.exists(p)), default_paths[0])
origin = profile_origin(name, namespace) origin = profile_origin(name, namespace)
read_only = origin in {"module", "plugin"} read_only = origin in {"module", "plugin"}
prefer_runtime = os.path.exists(r_path) prefer_user = os.path.exists(u_path)
if prefer_runtime: if prefer_user:
s = QSettings(r_path, QSettings.IniFormat) s = QSettings(u_path, QSettings.IniFormat)
elif os.path.exists(b_path): elif os.path.exists(d_path):
s = QSettings(b_path, QSettings.IniFormat) s = QSettings(d_path, QSettings.IniFormat)
else: else:
s = None s = None
if s is None: if s is None:
@@ -998,14 +957,14 @@ def get_profile_info(name: str, namespace: str | None = None) -> ProfileInfo:
is_quick_select=False, is_quick_select=False,
widget_count=0, widget_count=0,
size_kb=0, size_kb=0,
runtime_path=r_path, user_path=u_path,
baseline_path=b_path, default_path=d_path,
origin=origin, origin=origin,
is_read_only=read_only, is_read_only=read_only,
) )
created = s.value(SETTINGS_KEYS["created_at"], "", type=str) or now_iso_utc() created = s.value(SETTINGS_KEYS["created_at"], "", type=str) or now_iso_utc()
src_path = r_path if prefer_runtime else b_path src_path = u_path if prefer_user else d_path
modified = _file_modified_iso(src_path) modified = _file_modified_iso(src_path)
count = _manifest_count(s) count = _manifest_count(s)
try: try:
@@ -1031,8 +990,8 @@ def get_profile_info(name: str, namespace: str | None = None) -> ProfileInfo:
is_quick_select=is_quick_select(name, namespace), is_quick_select=is_quick_select(name, namespace),
widget_count=count, widget_count=count,
size_kb=size_kb, size_kb=size_kb,
runtime_path=r_path, user_path=u_path,
baseline_path=b_path, default_path=d_path,
origin=origin, origin=origin,
is_read_only=read_only, is_read_only=read_only,
) )
@@ -1040,7 +999,7 @@ def get_profile_info(name: str, namespace: str | None = None) -> ProfileInfo:
def load_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None: def load_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
""" """
Load the stored screenshot pixmap for a profile from settings (runtime preferred). Load the stored screenshot pixmap for a profile from settings (user preferred).
Args: Args:
name (str): Profile name without extension. name (str): Profile name without extension.
@@ -1049,17 +1008,17 @@ def load_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap
Returns: Returns:
QPixmap | None: Screenshot pixmap or ``None`` if unavailable. QPixmap | None: Screenshot pixmap or ``None`` if unavailable.
""" """
s = _existing_runtime_settings(name, namespace) s = _existing_user_settings(name, namespace)
if s is None: if s is None:
s = _existing_baseline_settings(name, namespace) s = _existing_default_settings(name, namespace)
if s is None: if s is None:
return None return None
return _load_screenshot_from_settings(s) return _load_screenshot_from_settings(s)
def load_baseline_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None: def load_default_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
""" """
Load the screenshot from the baseline profile copy, if available. Load the screenshot from the default profile copy, if available.
Args: Args:
name (str): Profile name without extension. name (str): Profile name without extension.
@@ -1068,15 +1027,15 @@ def load_baseline_profile_screenshot(name: str, namespace: str | None = None) ->
Returns: Returns:
QPixmap | None: Screenshot pixmap or ``None`` if unavailable. QPixmap | None: Screenshot pixmap or ``None`` if unavailable.
""" """
s = _existing_baseline_settings(name, namespace) s = _existing_default_settings(name, namespace)
if s is None: if s is None:
return None return None
return _load_screenshot_from_settings(s) return _load_screenshot_from_settings(s)
def load_runtime_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None: def load_user_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
""" """
Load the screenshot from the runtime profile copy, if available. Load the screenshot from the user profile copy, if available.
Args: Args:
name (str): Profile name without extension. name (str): Profile name without extension.
@@ -1085,7 +1044,7 @@ def load_runtime_profile_screenshot(name: str, namespace: str | None = None) ->
Returns: Returns:
QPixmap | None: Screenshot pixmap or ``None`` if unavailable. QPixmap | None: Screenshot pixmap or ``None`` if unavailable.
""" """
s = _existing_runtime_settings(name, namespace) s = _existing_user_settings(name, namespace)
if s is None: if s is None:
return None return None
return _load_screenshot_from_settings(s) return _load_screenshot_from_settings(s)
@@ -160,7 +160,7 @@ class SaveProfileDialog(QDialog):
self, self,
"Read-only profile", "Read-only profile",
( (
f"'{name}' is a baseline profile provided by {provider} and cannot be overwritten.\n" f"'{name}' is a default profile provided by {provider} and cannot be overwritten.\n"
"Please choose a different name." "Please choose a different name."
), ),
) )
@@ -179,7 +179,7 @@ class SaveProfileDialog(QDialog):
"Overwrite profile", "Overwrite profile",
( (
f"A profile named '{name}' already exists.\n\n" f"A profile named '{name}' already exists.\n\n"
"Overwriting will update both the runtime profile and its restore baseline.\n" "Overwriting will update both the saved profile and its restore default.\n"
"Do you want to continue?" "Do you want to continue?"
), ),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
@@ -257,24 +257,21 @@ class PreviewPanel(QGroupBox):
class RestoreProfileDialog(QDialog): class RestoreProfileDialog(QDialog):
""" """
Confirmation dialog that previews the current runtime screenshot against the baseline. Confirmation dialog that previews the current profile screenshot against the default baseline.
""" """
def __init__( def __init__(
self, self, parent: QWidget | None, current_pixmap: QPixmap | None, default_pixmap: QPixmap | None
parent: QWidget | None,
current_pixmap: QPixmap | None,
baseline_pixmap: QPixmap | None,
): ):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Restore Profile to Baseline") self.setWindowTitle("Restore Profile to Default")
self.setModal(True) self.setModal(True)
self.resize(880, 480) self.resize(880, 480)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
info_label = QLabel( info_label = QLabel(
"Restoring will discard your runtime layout and replace it with the baseline profile." "Restoring will discard your custom layout and replace it with the default profile."
) )
info_label.setWordWrap(True) info_label.setWordWrap(True)
layout.addWidget(info_label) layout.addWidget(info_label)
@@ -283,7 +280,7 @@ class RestoreProfileDialog(QDialog):
layout.addLayout(preview_row) layout.addLayout(preview_row)
current_preview = PreviewPanel("Current", current_pixmap, self) current_preview = PreviewPanel("Current", current_pixmap, self)
baseline_preview = PreviewPanel("Baseline", baseline_pixmap, self) default_preview = PreviewPanel("Default", default_pixmap, self)
# Equal expansion left/right # Equal expansion left/right
preview_row.addWidget(current_preview, 1) preview_row.addWidget(current_preview, 1)
@@ -295,7 +292,7 @@ class RestoreProfileDialog(QDialog):
arrow_label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) arrow_label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
preview_row.addWidget(arrow_label) preview_row.addWidget(arrow_label)
preview_row.addWidget(baseline_preview, 1) preview_row.addWidget(default_preview, 1)
# Enforce equal stretch for both previews # Enforce equal stretch for both previews
preview_row.setStretch(0, 1) preview_row.setStretch(0, 1)
@@ -303,7 +300,7 @@ class RestoreProfileDialog(QDialog):
preview_row.setStretch(2, 1) preview_row.setStretch(2, 1)
warn_label = QLabel( warn_label = QLabel(
"This action cannot be undone. Do you want to restore the baseline layout now?" "This action cannot be undone. Do you want to restore the default layout now?"
) )
warn_label.setWordWrap(True) warn_label.setWordWrap(True)
layout.addWidget(warn_label) layout.addWidget(warn_label)
@@ -327,7 +324,7 @@ class RestoreProfileDialog(QDialog):
@staticmethod @staticmethod
def confirm( def confirm(
parent: QWidget | None, current_pixmap: QPixmap | None, baseline_pixmap: QPixmap | None parent: QWidget | None, current_pixmap: QPixmap | None, default_pixmap: QPixmap | None
) -> bool: ) -> bool:
dialog = RestoreProfileDialog(parent, current_pixmap, baseline_pixmap) dialog = RestoreProfileDialog(parent, current_pixmap, default_pixmap)
return dialog.exec() == QDialog.Accepted return dialog.exec() == QDialog.Accepted
@@ -48,7 +48,7 @@ class WorkSpaceManager(BECWidget, QWidget):
HEADERS = ["Actions", "Profile", "Author"] HEADERS = ["Actions", "Profile", "Author"]
def __init__( def __init__(
self, parent=None, target_widget=None, active_profile: str | None = None, **kwargs self, parent=None, target_widget=None, default_profile: str | None = None, **kwargs
): ):
super().__init__(parent=parent, **kwargs) super().__init__(parent=parent, **kwargs)
self.target_widget = target_widget self.target_widget = target_widget
@@ -59,13 +59,13 @@ class WorkSpaceManager(BECWidget, QWidget):
self._init_ui() self._init_ui()
if self.target_widget is not None and hasattr(self.target_widget, "profile_changed"): if self.target_widget is not None and hasattr(self.target_widget, "profile_changed"):
self.target_widget.profile_changed.connect(self.on_profile_changed) self.target_widget.profile_changed.connect(self.on_profile_changed)
if active_profile is not None: if default_profile is not None:
self._select_by_name(active_profile) self._select_by_name(default_profile)
self._show_profile_details(active_profile) self._show_profile_details(default_profile)
def _init_ui(self): def _init_ui(self):
self.root_layout = QHBoxLayout(self) self.root_layout = QHBoxLayout(self)
self.splitter = QSplitter(Qt.Orientation.Horizontal, self) self.splitter = QSplitter(Qt.Horizontal, self)
self.root_layout.addWidget(self.splitter) self.root_layout.addWidget(self.splitter)
# Init components # Init components
@@ -89,9 +89,7 @@ class WorkSpaceManager(BECWidget, QWidget):
left_panel.setMinimumWidth(220) left_panel.setMinimumWidth(220)
# Make the screenshot preview expand to fill remaining space # Make the screenshot preview expand to fill remaining space
self.screenshot_label.setSizePolicy( self.screenshot_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
self.right_box = QGroupBox("Profile Screenshot Preview", self) self.right_box = QGroupBox("Profile Screenshot Preview", self)
right_col = QVBoxLayout(self.right_box) right_col = QVBoxLayout(self.right_box)
@@ -252,8 +250,8 @@ class WorkSpaceManager(BECWidget, QWidget):
("Quick select", "Yes" if info.is_quick_select else "No"), ("Quick select", "Yes" if info.is_quick_select else "No"),
("Widgets", str(info.widget_count)), ("Widgets", str(info.widget_count)),
("Size (KB)", str(info.size_kb)), ("Size (KB)", str(info.size_kb)),
("Runtime path", info.runtime_path or ""), ("User path", info.user_path or ""),
("Baseline path", info.baseline_path or ""), ("Default path", info.default_path or ""),
] ]
for k, v in entries: for k, v in entries:
self.profile_details_tree.addTopLevelItem(QTreeWidgetItem([k, v])) self.profile_details_tree.addTopLevelItem(QTreeWidgetItem([k, v]))
@@ -24,9 +24,19 @@ class ProfileComboBox(QComboBox):
def set_quick_profile_provider(self, provider: Callable[[], list[str]]) -> None: def set_quick_profile_provider(self, provider: Callable[[], list[str]]) -> None:
self._quick_provider = provider self._quick_provider = provider
def _refresh_profiles( def refresh_profiles(
self, current_text: str, active_profile: str | None = None, show_empty_profile: bool = False self, active_profile: str | None = None, show_empty_profile: bool = False
) -> None: ) -> None:
"""
Refresh the profile list and ensure the active profile is visible.
Args:
active_profile(str | None): The currently active profile name.
show_empty_profile(bool): If True, show an explicit empty unsaved workspace entry.
"""
current_text = active_profile or self.currentText()
self.blockSignals(True)
self.clear() self.clear()
quick_profiles = self._quick_provider() quick_profiles = self._quick_provider()
@@ -93,6 +103,7 @@ class ProfileComboBox(QComboBox):
if index >= 0: if index >= 0:
self.setCurrentIndex(index) self.setCurrentIndex(index)
self.blockSignals(False)
if active_profile and self.currentText() != active_profile: if active_profile and self.currentText() != active_profile:
idx = self.findText(active_profile) idx = self.findText(active_profile)
if idx >= 0: if idx >= 0:
@@ -104,24 +115,6 @@ class ProfileComboBox(QComboBox):
else: else:
self.setToolTip("") self.setToolTip("")
def refresh_profiles(
self, active_profile: str | None = None, show_empty_profile: bool = False
) -> None:
"""
Refresh the profile list and ensure the active profile is visible.
Args:
active_profile(str | None): The currently active profile name.
show_empty_profile(bool): If True, show an explicit empty unsaved workspace entry.
"""
current_text = active_profile or self.currentText()
was_blocked = self.blockSignals(True)
try:
self._refresh_profiles(current_text, active_profile, show_empty_profile)
finally:
self.blockSignals(was_blocked)
def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -> ToolbarBundle: def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -> ToolbarBundle:
""" """
@@ -129,7 +122,6 @@ def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -
Args: Args:
components (ToolbarComponents): The components to be added to the bundle. components (ToolbarComponents): The components to be added to the bundle.
enable_tools(bool): If True, show the workspace management tools; otherwise, only show the profile combo.
Returns: Returns:
ToolbarBundle: The workspace toolbar bundle. ToolbarBundle: The workspace toolbar bundle.
@@ -151,15 +143,15 @@ def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -
components.get_action("save_workspace").action.setVisible(enable_tools) components.get_action("save_workspace").action.setVisible(enable_tools)
components.add_safe( components.add_safe(
"reset_baseline_workspace", "reset_default_workspace",
MaterialIconAction( MaterialIconAction(
icon_name="undo", icon_name="undo",
tooltip="Restore Baseline Profile", tooltip="Refresh Current Workspace",
checkable=False, checkable=False,
parent=components.toolbar, parent=components.toolbar,
), ),
) )
components.get_action("reset_baseline_workspace").action.setVisible(enable_tools) components.get_action("reset_default_workspace").action.setVisible(enable_tools)
components.add_safe( components.add_safe(
"manage_workspaces", "manage_workspaces",
@@ -172,7 +164,7 @@ def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -
bundle = ToolbarBundle("workspace", components) bundle = ToolbarBundle("workspace", components)
bundle.add_action("workspace_combo") bundle.add_action("workspace_combo")
bundle.add_action("save_workspace") bundle.add_action("save_workspace")
bundle.add_action("reset_baseline_workspace") bundle.add_action("reset_default_workspace")
bundle.add_action("manage_workspaces") bundle.add_action("manage_workspaces")
return bundle return bundle
@@ -202,9 +194,9 @@ class WorkspaceConnection(BundleConnection):
self.target_widget.load_profile self.target_widget.load_profile
) )
reset_action = self.components.get_action("reset_baseline_workspace").action reset_action = self.components.get_action("reset_default_workspace").action
if reset_action.isVisible(): if reset_action.isVisible():
reset_action.triggered.connect(self._reset_workspace_to_baseline) reset_action.triggered.connect(self._reset_workspace_to_default)
manage_action = self.components.get_action("manage_workspaces").action manage_action = self.components.get_action("manage_workspaces").action
if manage_action.isVisible(): if manage_action.isVisible():
@@ -221,9 +213,9 @@ class WorkspaceConnection(BundleConnection):
self.target_widget.load_profile self.target_widget.load_profile
) )
reset_action = self.components.get_action("reset_baseline_workspace").action reset_action = self.components.get_action("reset_default_workspace").action
if reset_action.isVisible(): if reset_action.isVisible():
reset_action.triggered.disconnect(self._reset_workspace_to_baseline) reset_action.triggered.disconnect(self._reset_workspace_to_default)
manage_action = self.components.get_action("manage_workspaces").action manage_action = self.components.get_action("manage_workspaces").action
if manage_action.isVisible(): if manage_action.isVisible():
@@ -231,8 +223,8 @@ class WorkspaceConnection(BundleConnection):
self._connected = False self._connected = False
@SafeSlot() @SafeSlot()
def _reset_workspace_to_baseline(self): def _reset_workspace_to_default(self):
""" """
Refreshes the current workspace. Refreshes the current workspace.
""" """
self.target_widget.restore_baseline_profile(show_dialog=True) self.target_widget.restore_user_profile_from_default()
@@ -4,7 +4,11 @@ import webbrowser
class BECWebLinksMixin: class BECWebLinksMixin:
@staticmethod @staticmethod
def open_bec_docs(): def open_bec_docs():
webbrowser.open("https://bec-project.github.io/bec_docs/") webbrowser.open("https://beamline-experiment-control.readthedocs.io/en/latest/")
@staticmethod
def open_bec_widgets_docs():
webbrowser.open("https://bec.readthedocs.io/projects/bec-widgets/en/latest/")
@staticmethod @staticmethod
def open_bec_bug_report(): def open_bec_bug_report():
@@ -355,13 +355,17 @@ class BECMainWindow(BECWidget, QMainWindow):
bec_docs = QAction("BEC Docs", self) bec_docs = QAction("BEC Docs", self)
bec_docs.setIcon(help_icon) bec_docs.setIcon(help_icon)
widgets_docs = QAction("BEC Widgets Docs", self)
widgets_docs.setIcon(help_icon)
bug_report = QAction("Bug Report", self) bug_report = QAction("Bug Report", self)
bug_report.setIcon(bug_icon) bug_report.setIcon(bug_icon)
bec_docs.triggered.connect(BECWebLinksMixin.open_bec_docs) bec_docs.triggered.connect(BECWebLinksMixin.open_bec_docs)
widgets_docs.triggered.connect(BECWebLinksMixin.open_bec_widgets_docs)
bug_report.triggered.connect(BECWebLinksMixin.open_bec_bug_report) bug_report.triggered.connect(BECWebLinksMixin.open_bec_bug_report)
help_menu.addAction(bec_docs) help_menu.addAction(bec_docs)
help_menu.addAction(widgets_docs)
help_menu.addAction(bug_report) help_menu.addAction(bug_report)
################################################################################ ################################################################################
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "bec_widgets" name = "bec_widgets"
version = "3.8.0" version = "3.7.1"
description = "BEC Widgets" description = "BEC Widgets"
requires-python = ">=3.11" requires-python = ">=3.11"
classifiers = [ classifiers = [
+1
View File
@@ -59,4 +59,5 @@ def test_run_line_scan_with_parameters_e2e(scan_control, bec_client_lib, qtbot):
last_scan = queue.scan_storage.storage[-1] last_scan = queue.scan_storage.storage[-1]
assert last_scan.status_message.info["scan_name"] == scan_name 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["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"] assert last_scan.status_message.info["num_points"] == kwargs["steps"]
+1
View File
@@ -84,6 +84,7 @@ def test_scan_metadata_for_custom_scan(
last_scan = queue.scan_storage.storage[-1] last_scan = queue.scan_storage.storage[-1]
assert last_scan.status_message.info["scan_name"] == scan_name 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["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"] assert last_scan.status_message.info["num_points"] == kwargs["steps"]
if valid: if valid:
+1
View File
@@ -71,6 +71,7 @@ def bec_queue_msg_full():
}, },
"report_instructions": [{"scan_progress": 20}], "report_instructions": [{"scan_progress": 20}],
"scan_id": "2d704cc3-c172-404c-866d-608ce09fce40", "scan_id": "2d704cc3-c172-404c-866d-608ce09fce40",
"scan_motors": ["samx"],
"scan_number": 1289, "scan_number": 1289,
} }
], ],
+5 -2
View File
@@ -146,7 +146,10 @@ class TestDeviceManagerViewDialogs:
group_combo: QtWidgets.QComboBox = dialog._control_widgets["group_combo"] group_combo: QtWidgets.QComboBox = dialog._control_widgets["group_combo"]
assert group_combo.count() == len(OPHYD_DEVICE_TEMPLATES) assert group_combo.count() == len(OPHYD_DEVICE_TEMPLATES)
# Test select a group from available templates
variant_combo = dialog._control_widgets["variant_combo"] variant_combo = dialog._control_widgets["variant_combo"]
assert variant_combo.isEnabled() is False
with qtbot.waitSignal(group_combo.currentTextChanged): with qtbot.waitSignal(group_combo.currentTextChanged):
epics_signal_index = group_combo.findText("EpicsSignal") epics_signal_index = group_combo.findText("EpicsSignal")
group_combo.setCurrentIndex(epics_signal_index) # Select "EpicsSignal" group group_combo.setCurrentIndex(epics_signal_index) # Select "EpicsSignal" group
@@ -232,7 +235,7 @@ class TestDeviceManagerViewDialogs:
sample_config = { sample_config = {
"name": "TestDevice", "name": "TestDevice",
"enabled": True, "enabled": True,
"deviceClass": "ophyd_devices.EpicsSignal", "deviceClass": "ophyd.EpicsSignal",
"readoutPriority": "baseline", "readoutPriority": "baseline",
"deviceConfig": {"read_pv": "X25DA-ES1-MOT:GET"}, "deviceConfig": {"read_pv": "X25DA-ES1-MOT:GET"},
} }
@@ -245,7 +248,7 @@ class TestDeviceManagerViewDialogs:
assert variant_combo.currentText() == "EpicsSignal" assert variant_combo.currentText() == "EpicsSignal"
config = dialog._device_config_template.get_config_fields() config = dialog._device_config_template.get_config_fields()
assert config["name"] == "TestDevice" assert config["name"] == "TestDevice"
assert config["deviceClass"] == "ophyd_devices.EpicsSignal" assert config["deviceClass"] == "ophyd.EpicsSignal"
assert config["deviceConfig"]["read_pv"] == "X25DA-ES1-MOT:GET" assert config["deviceConfig"]["read_pv"] == "X25DA-ES1-MOT:GET"
# Test now to add the device config with different validation results # Test now to add the device config with different validation results
+180 -312
View File
@@ -19,19 +19,19 @@ from bec_widgets.widgets.containers.dock_area.basic_dock_area import (
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea, SaveProfileDialog from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea, SaveProfileDialog
from bec_widgets.widgets.containers.dock_area.profile_utils import ( from bec_widgets.widgets.containers.dock_area.profile_utils import (
SETTINGS_KEYS, SETTINGS_KEYS,
baseline_profile_path, default_profile_path,
get_profile_info, get_profile_info,
is_profile_read_only, is_profile_read_only,
is_quick_select, is_quick_select,
list_profiles, list_profiles,
load_baseline_profile_screenshot, load_default_profile_screenshot,
load_runtime_profile_screenshot, load_user_profile_screenshot,
open_baseline_settings, open_default_settings,
open_runtime_settings, open_user_settings,
read_manifest, read_manifest,
restore_runtime_from_baseline, restore_user_from_default,
runtime_profile_path,
set_quick_select, set_quick_select,
user_profile_path,
write_manifest, write_manifest,
) )
from bec_widgets.widgets.containers.dock_area.settings.dialogs import ( from bec_widgets.widgets.containers.dock_area.settings.dialogs import (
@@ -188,17 +188,17 @@ class _NamespaceProfiles:
def __init__(self, widget: BECDockArea): def __init__(self, widget: BECDockArea):
self.namespace = widget.profile_namespace self.namespace = widget.profile_namespace
def open_runtime(self, name: str): def open_user(self, name: str):
return open_runtime_settings(name, namespace=self.namespace) return open_user_settings(name, namespace=self.namespace)
def open_baseline(self, name: str): def open_default(self, name: str):
return open_baseline_settings(name, namespace=self.namespace) return open_default_settings(name, namespace=self.namespace)
def runtime_path(self, name: str) -> str: def user_path(self, name: str) -> str:
return runtime_profile_path(name, namespace=self.namespace) return user_profile_path(name, namespace=self.namespace)
def baseline_path(self, name: str) -> str: def default_path(self, name: str) -> str:
return baseline_profile_path(name, namespace=self.namespace) return default_profile_path(name, namespace=self.namespace)
def list_profiles(self) -> list[str]: def list_profiles(self) -> list[str]:
return list_profiles(namespace=self.namespace) return list_profiles(namespace=self.namespace)
@@ -615,6 +615,35 @@ class TestBasicDockArea:
] ]
class TestAdvancedDockAreaInit:
"""Test initialization and basic properties."""
def test_init(self, advanced_dock_area):
assert advanced_dock_area is not None
assert isinstance(advanced_dock_area, BECDockArea)
assert advanced_dock_area.mode == "creator"
assert hasattr(advanced_dock_area, "dock_manager")
assert hasattr(advanced_dock_area, "toolbar")
assert hasattr(advanced_dock_area, "dark_mode_button")
assert hasattr(advanced_dock_area, "state_manager")
def test_rpc_and_plugin_flags(self):
assert BECDockArea.RPC is True
assert BECDockArea.PLUGIN is False
def test_user_access_list(self):
expected_methods = [
"new",
"widget_map",
"widget_list",
"workspace_is_locked",
"attach_all",
"delete_all",
]
for method in expected_methods:
assert method in BECDockArea.USER_ACCESS
class TestDockManagement: class TestDockManagement:
"""Test dock creation, management, and manipulation.""" """Test dock creation, management, and manipulation."""
@@ -822,6 +851,16 @@ class TestDeveloperMode:
class TestToolbarFunctionality: class TestToolbarFunctionality:
"""Test toolbar setup and functionality.""" """Test toolbar setup and functionality."""
def test_toolbar_setup(self, advanced_dock_area):
"""Test toolbar is properly set up."""
assert hasattr(advanced_dock_area, "toolbar")
assert hasattr(advanced_dock_area, "_ACTION_MAPPINGS")
# Check that action mappings are properly set
assert "menu_plots" in advanced_dock_area._ACTION_MAPPINGS
assert "menu_devices" in advanced_dock_area._ACTION_MAPPINGS
assert "menu_utils" in advanced_dock_area._ACTION_MAPPINGS
def test_toolbar_plot_actions(self, advanced_dock_area): def test_toolbar_plot_actions(self, advanced_dock_area):
"""Test plot toolbar actions trigger widget creation.""" """Test plot toolbar actions trigger widget creation."""
plot_actions = [ plot_actions = [
@@ -907,7 +946,7 @@ class TestToolbarFunctionality:
def test_load_profile_restores_floating_dock(self, advanced_dock_area, qtbot): def test_load_profile_restores_floating_dock(self, advanced_dock_area, qtbot):
helper = profile_helper(advanced_dock_area) helper = profile_helper(advanced_dock_area)
settings = helper.open_runtime("floating_profile") settings = helper.open_user("floating_profile")
settings.clear() settings.clear()
settings.setValue("profile/created_at", "2025-11-23T00:00:00Z") settings.setValue("profile/created_at", "2025-11-23T00:00:00Z")
@@ -1176,6 +1215,18 @@ class TestPreviewPanel:
assert "No preview available" in panel.image_label.text() assert "No preview available" in panel.image_label.text()
class TestRestoreProfileDialog:
"""Test restore dialog confirmation flow."""
def test_confirm_accepts(self, monkeypatch):
monkeypatch.setattr(RestoreProfileDialog, "exec", lambda self: QDialog.Accepted)
assert RestoreProfileDialog.confirm(None, QPixmap(), QPixmap()) is True
def test_confirm_rejects(self, monkeypatch):
monkeypatch.setattr(RestoreProfileDialog, "exec", lambda self: QDialog.Rejected)
assert RestoreProfileDialog.confirm(None, QPixmap(), QPixmap()) is False
class TestProfileInfoAndScreenshots: class TestProfileInfoAndScreenshots:
"""Tests for profile utilities metadata and screenshot helpers.""" """Tests for profile utilities metadata and screenshot helpers."""
@@ -1195,9 +1246,9 @@ class TestProfileInfoAndScreenshots:
settings.endArray() settings.endArray()
settings.sync() settings.sync()
def test_get_profile_info_runtime_origin(self, temp_profile_dir): def test_get_profile_info_user_origin(self, temp_profile_dir):
name = "info_runtime" name = "info_user"
settings = open_runtime_settings(name) settings = open_user_settings(name)
settings.setValue(profile_utils.SETTINGS_KEYS["created_at"], "2023-01-01T00:00:00Z") settings.setValue(profile_utils.SETTINGS_KEYS["created_at"], "2023-01-01T00:00:00Z")
settings.setValue("profile/author", "Custom") settings.setValue("profile/author", "Custom")
set_quick_select(name, True) set_quick_select(name, True)
@@ -1211,22 +1262,22 @@ class TestProfileInfoAndScreenshots:
assert info.is_quick_select is True assert info.is_quick_select is True
assert info.widget_count == 3 assert info.widget_count == 3
assert info.author == "User" assert info.author == "User"
assert info.runtime_path.endswith(f"{name}.ini") assert info.user_path.endswith(f"{name}.ini")
assert info.size_kb >= 0 assert info.size_kb >= 0
def test_get_profile_info_baseline_only(self, temp_profile_dir): def test_get_profile_info_default_only(self, temp_profile_dir):
name = "info_baseline" name = "info_default"
settings = open_baseline_settings(name) settings = open_default_settings(name)
self._write_manifest(settings, count=1) self._write_manifest(settings, count=1)
runtime_path = runtime_profile_path(name) user_path = user_profile_path(name)
if os.path.exists(runtime_path): if os.path.exists(user_path):
os.remove(runtime_path) os.remove(user_path)
info = get_profile_info(name) info = get_profile_info(name)
assert info.origin == "settings" assert info.origin == "settings"
assert info.baseline_path.endswith(f"{name}.ini") assert info.user_path.endswith(f"{name}.ini")
assert info.widget_count == 1 assert info.widget_count == 1
def test_get_profile_info_module_readonly(self, module_profile_factory): def test_get_profile_info_module_readonly(self, module_profile_factory):
@@ -1238,10 +1289,10 @@ class TestProfileInfoAndScreenshots:
def test_get_profile_info_unknown_profile(self): def test_get_profile_info_unknown_profile(self):
name = "nonexistent_profile" name = "nonexistent_profile"
if os.path.exists(runtime_profile_path(name)): if os.path.exists(user_profile_path(name)):
os.remove(runtime_profile_path(name)) os.remove(user_profile_path(name))
if os.path.exists(baseline_profile_path(name)): if os.path.exists(default_profile_path(name)):
os.remove(baseline_profile_path(name)) os.remove(default_profile_path(name))
info = get_profile_info(name) info = get_profile_info(name)
@@ -1249,29 +1300,29 @@ class TestProfileInfoAndScreenshots:
assert info.is_read_only is False assert info.is_read_only is False
assert info.widget_count == 0 assert info.widget_count == 0
def test_load_runtime_profile_screenshot(self, temp_profile_dir): def test_load_user_profile_screenshot(self, temp_profile_dir):
name = "runtime_screenshot" name = "user_screenshot"
settings = open_runtime_settings(name) settings = open_user_settings(name)
settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], self.PNG_BYTES) settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], self.PNG_BYTES)
settings.sync() settings.sync()
pix = load_runtime_profile_screenshot(name) pix = load_user_profile_screenshot(name)
assert pix is not None and not pix.isNull() assert pix is not None and not pix.isNull()
def test_load_baseline_profile_screenshot(self, temp_profile_dir): def test_load_default_profile_screenshot(self, temp_profile_dir):
name = "baseline_screenshot" name = "default_screenshot"
settings = open_baseline_settings(name) settings = open_default_settings(name)
settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], self.PNG_BYTES) settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], self.PNG_BYTES)
settings.sync() settings.sync()
pix = load_baseline_profile_screenshot(name) pix = load_default_profile_screenshot(name)
assert pix is not None and not pix.isNull() assert pix is not None and not pix.isNull()
def test_load_screenshot_from_settings_invalid(self, temp_profile_dir): def test_load_screenshot_from_settings_invalid(self, temp_profile_dir):
name = "invalid_screenshot" name = "invalid_screenshot"
settings = open_runtime_settings(name) settings = open_user_settings(name)
settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], "not-an-image") settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], "not-an-image")
settings.sync() settings.sync()
@@ -1281,7 +1332,7 @@ class TestProfileInfoAndScreenshots:
def test_load_screenshot_from_settings_bytes(self, temp_profile_dir): def test_load_screenshot_from_settings_bytes(self, temp_profile_dir):
name = "bytes_screenshot" name = "bytes_screenshot"
settings = open_runtime_settings(name) settings = open_user_settings(name)
settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], self.PNG_BYTES) settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], self.PNG_BYTES)
settings.sync() settings.sync()
@@ -1296,7 +1347,7 @@ class TestWorkSpaceManager:
@staticmethod @staticmethod
def _create_profiles(names): def _create_profiles(names):
for name in names: for name in names:
settings = open_runtime_settings(name) settings = open_user_settings(name)
settings.setValue("meta", "value") settings.setValue("meta", "value")
settings.sync() settings.sync()
@@ -1360,7 +1411,7 @@ class TestWorkSpaceManager:
manager.delete_profile(name) manager.delete_profile(name)
assert not os.path.exists(runtime_profile_path(name)) assert not os.path.exists(user_profile_path(name))
assert target.refresh_calls >= 1 assert target.refresh_calls >= 1
def test_delete_readonly_profile_shows_message( def test_delete_readonly_profile_shows_message(
@@ -1390,23 +1441,21 @@ class TestWorkSpaceManager:
class TestAdvancedDockAreaRestoreAndDialogs: class TestAdvancedDockAreaRestoreAndDialogs:
"""Additional coverage for restore flows and workspace dialogs.""" """Additional coverage for restore flows and workspace dialogs."""
def test_restore_runtime_profile_from_baseline_confirm_true( def test_restore_user_profile_from_default_confirm_true(self, advanced_dock_area, monkeypatch):
self, advanced_dock_area, monkeypatch
):
profile_name = "profile_restore_true" profile_name = "profile_restore_true"
helper = profile_helper(advanced_dock_area) helper = profile_helper(advanced_dock_area)
helper.open_baseline(profile_name).sync() helper.open_default(profile_name).sync()
helper.open_runtime(profile_name).sync() helper.open_user(profile_name).sync()
advanced_dock_area._current_profile_name = profile_name advanced_dock_area._current_profile_name = profile_name
advanced_dock_area.isVisible = lambda: False advanced_dock_area.isVisible = lambda: False
pix = QPixmap(8, 8) pix = QPixmap(8, 8)
pix.fill(Qt.red) pix.fill(Qt.red)
monkeypatch.setattr( monkeypatch.setattr(
"bec_widgets.widgets.containers.dock_area.dock_area.load_runtime_profile_screenshot", "bec_widgets.widgets.containers.dock_area.dock_area.load_user_profile_screenshot",
lambda name, namespace=None: pix, lambda name, namespace=None: pix,
) )
monkeypatch.setattr( monkeypatch.setattr(
"bec_widgets.widgets.containers.dock_area.dock_area.load_baseline_profile_screenshot", "bec_widgets.widgets.containers.dock_area.dock_area.load_default_profile_screenshot",
lambda name, namespace=None: pix, lambda name, namespace=None: pix,
) )
monkeypatch.setattr( monkeypatch.setattr(
@@ -1416,12 +1465,12 @@ class TestAdvancedDockAreaRestoreAndDialogs:
with ( with (
patch( patch(
"bec_widgets.widgets.containers.dock_area.dock_area.restore_runtime_from_baseline" "bec_widgets.widgets.containers.dock_area.dock_area.restore_user_from_default"
) as mock_restore, ) as mock_restore,
patch.object(advanced_dock_area, "delete_all") as mock_delete_all, patch.object(advanced_dock_area, "delete_all") as mock_delete_all,
patch.object(advanced_dock_area, "load_profile") as mock_load_profile, patch.object(advanced_dock_area, "load_profile") as mock_load_profile,
): ):
advanced_dock_area.restore_baseline_profile(show_dialog=True) advanced_dock_area.restore_user_profile_from_default()
assert mock_restore.call_count == 1 assert mock_restore.call_count == 1
args, kwargs = mock_restore.call_args args, kwargs = mock_restore.call_args
@@ -1430,22 +1479,20 @@ class TestAdvancedDockAreaRestoreAndDialogs:
mock_delete_all.assert_called_once() mock_delete_all.assert_called_once()
mock_load_profile.assert_called_once_with(profile_name) mock_load_profile.assert_called_once_with(profile_name)
def test_restore_runtime_profile_from_baseline_confirm_false( def test_restore_user_profile_from_default_confirm_false(self, advanced_dock_area, monkeypatch):
self, advanced_dock_area, monkeypatch
):
profile_name = "profile_restore_false" profile_name = "profile_restore_false"
helper = profile_helper(advanced_dock_area) helper = profile_helper(advanced_dock_area)
helper.open_baseline(profile_name).sync() helper.open_default(profile_name).sync()
helper.open_runtime(profile_name).sync() helper.open_user(profile_name).sync()
advanced_dock_area._current_profile_name = profile_name advanced_dock_area._current_profile_name = profile_name
advanced_dock_area.isVisible = lambda: False advanced_dock_area.isVisible = lambda: False
monkeypatch.setattr( monkeypatch.setattr(
"bec_widgets.widgets.containers.dock_area.dock_area.load_runtime_profile_screenshot", "bec_widgets.widgets.containers.dock_area.dock_area.load_user_profile_screenshot",
lambda name, namespace=None: QPixmap(), lambda name: QPixmap(),
) )
monkeypatch.setattr( monkeypatch.setattr(
"bec_widgets.widgets.containers.dock_area.dock_area.load_baseline_profile_screenshot", "bec_widgets.widgets.containers.dock_area.dock_area.load_default_profile_screenshot",
lambda name, namespace=None: QPixmap(), lambda name: QPixmap(),
) )
monkeypatch.setattr( monkeypatch.setattr(
"bec_widgets.widgets.containers.dock_area.dock_area.RestoreProfileDialog.confirm", "bec_widgets.widgets.containers.dock_area.dock_area.RestoreProfileDialog.confirm",
@@ -1453,49 +1500,24 @@ class TestAdvancedDockAreaRestoreAndDialogs:
) )
with patch( with patch(
"bec_widgets.widgets.containers.dock_area.dock_area.restore_runtime_from_baseline" "bec_widgets.widgets.containers.dock_area.dock_area.restore_user_from_default"
) as mock_restore: ) as mock_restore:
advanced_dock_area.restore_baseline_profile(show_dialog=True) advanced_dock_area.restore_user_profile_from_default()
mock_restore.assert_not_called() mock_restore.assert_not_called()
def test_restore_runtime_profile_from_baseline_without_dialog(self, advanced_dock_area): def test_restore_user_profile_from_default_no_target(self, advanced_dock_area, monkeypatch):
profile_name = "alignment_scan"
helper = profile_helper(advanced_dock_area)
helper.open_baseline(profile_name).sync()
helper.open_runtime(profile_name).sync()
with (
patch(
"bec_widgets.widgets.containers.dock_area.dock_area.RestoreProfileDialog.confirm"
) as mock_confirm,
patch(
"bec_widgets.widgets.containers.dock_area.dock_area.restore_runtime_from_baseline"
) as mock_restore,
patch.object(advanced_dock_area, "delete_all") as mock_delete_all,
patch.object(advanced_dock_area, "load_profile") as mock_load_profile,
):
advanced_dock_area.restore_baseline_profile(profile_name, show_dialog=False)
mock_confirm.assert_not_called()
mock_restore.assert_called_once_with(
profile_name, namespace=advanced_dock_area.profile_namespace
)
mock_delete_all.assert_called_once()
mock_load_profile.assert_called_once_with(profile_name)
def test_restore_runtime_profile_from_baseline_no_target(self, advanced_dock_area, monkeypatch):
advanced_dock_area._current_profile_name = None advanced_dock_area._current_profile_name = None
with patch( with patch(
"bec_widgets.widgets.containers.dock_area.dock_area.RestoreProfileDialog.confirm" "bec_widgets.widgets.containers.dock_area.dock_area.RestoreProfileDialog.confirm"
) as mock_confirm: ) as mock_confirm:
advanced_dock_area.restore_baseline_profile(show_dialog=True) advanced_dock_area.restore_user_profile_from_default()
mock_confirm.assert_not_called() mock_confirm.assert_not_called()
def test_refresh_workspace_list_with_refresh_profiles(self, advanced_dock_area): def test_refresh_workspace_list_with_refresh_profiles(self, advanced_dock_area):
profile_name = "refresh_profile" profile_name = "refresh_profile"
helper = profile_helper(advanced_dock_area) helper = profile_helper(advanced_dock_area)
helper.open_runtime(profile_name).sync() helper.open_user(profile_name).sync()
# Simulate a normal named-profile state (not transient empty startup mode). # Simulate a normal named-profile state (not transient empty startup mode).
advanced_dock_area._empty_profile_active = False advanced_dock_area._empty_profile_active = False
advanced_dock_area._current_profile_name = profile_name advanced_dock_area._current_profile_name = profile_name
@@ -1550,8 +1572,8 @@ class TestAdvancedDockAreaRestoreAndDialogs:
active = "active_profile" active = "active_profile"
quick = "quick_profile" quick = "quick_profile"
helper = profile_helper(advanced_dock_area) helper = profile_helper(advanced_dock_area)
helper.open_runtime(active).sync() helper.open_user(active).sync()
helper.open_runtime(quick).sync() helper.open_user(quick).sync()
helper.set_quick_select(quick, True) helper.set_quick_select(quick, True)
combo_stub = ComboStub() combo_stub = ComboStub()
@@ -1578,7 +1600,7 @@ class TestAdvancedDockAreaRestoreAndDialogs:
advanced_dock_area._current_profile_name = "manager_profile" advanced_dock_area._current_profile_name = "manager_profile"
helper = profile_helper(advanced_dock_area) helper = profile_helper(advanced_dock_area)
helper.open_runtime("manager_profile").sync() helper.open_user("manager_profile").sync()
advanced_dock_area.show_workspace_manager() advanced_dock_area.show_workspace_manager()
@@ -1613,104 +1635,18 @@ class TestProfileManagement:
def test_profile_path(self, temp_profile_dir): def test_profile_path(self, temp_profile_dir):
"""Test profile path generation.""" """Test profile path generation."""
path = runtime_profile_path("test_profile") path = user_profile_path("test_profile")
expected = os.path.join(temp_profile_dir, "runtime", "test_profile.ini") expected = os.path.join(temp_profile_dir, "user", "test_profile.ini")
assert path == expected assert path == expected
baseline_path = baseline_profile_path("test_profile") default_path = default_profile_path("test_profile")
expected_baseline = os.path.join(temp_profile_dir, "baseline", "test_profile.ini") expected_default = os.path.join(temp_profile_dir, "default", "test_profile.ini")
assert baseline_path == expected_baseline assert default_path == expected_default
def test_legacy_user_profile_is_mapped_to_runtime(self, temp_profile_dir): def test_open_settings(self, temp_profile_dir):
"""Legacy user profiles are copied into the canonical runtime segment.""" """Test opening settings for a profile."""
name = "legacy_runtime" settings = open_user_settings("test_profile")
legacy_dir = os.path.join(temp_profile_dir, "user") assert isinstance(settings, QSettings)
os.makedirs(legacy_dir, exist_ok=True)
legacy_path = os.path.join(legacy_dir, f"{name}.ini")
legacy_settings = QSettings(legacy_path, QSettings.IniFormat)
legacy_settings.setValue("test/value", "legacy")
legacy_settings.sync()
canonical_path = runtime_profile_path(name)
assert not os.path.exists(canonical_path)
assert name in list_profiles()
assert os.path.exists(canonical_path)
assert open_runtime_settings(name).value("test/value", "", type=str) == "legacy"
def test_legacy_default_profile_is_mapped_to_baseline(self, temp_profile_dir):
"""Legacy default profiles are copied into the canonical baseline segment."""
name = "legacy_baseline"
legacy_dir = os.path.join(temp_profile_dir, "default")
os.makedirs(legacy_dir, exist_ok=True)
legacy_path = os.path.join(legacy_dir, f"{name}.ini")
legacy_settings = QSettings(legacy_path, QSettings.IniFormat)
legacy_settings.setValue("test/value", "legacy")
legacy_settings.sync()
canonical_path = baseline_profile_path(name)
assert not os.path.exists(canonical_path)
assert name in list_profiles()
assert os.path.exists(canonical_path)
assert open_baseline_settings(name).value("test/value", "", type=str) == "legacy"
def test_runtime_namespace_fallback_is_materialized(self, temp_profile_dir):
"""Canonical runtime namespace fallback is copied before opening primary settings."""
name = "runtime_namespace_fallback"
fallback_settings = open_runtime_settings(name)
fallback_settings.setValue("test/value", "fallback")
fallback_settings.sync()
namespaced_path = runtime_profile_path(name, namespace="beamline")
assert not os.path.exists(namespaced_path)
settings = open_runtime_settings(name, namespace="beamline")
assert os.path.exists(namespaced_path)
assert settings.value("test/value", "", type=str) == "fallback"
def test_baseline_namespace_fallback_is_materialized(self, temp_profile_dir):
"""Canonical baseline namespace fallback is copied before opening primary settings."""
name = "baseline_namespace_fallback"
fallback_settings = open_baseline_settings(name)
fallback_settings.setValue("test/value", "fallback")
fallback_settings.sync()
namespaced_path = baseline_profile_path(name, namespace="beamline")
assert not os.path.exists(namespaced_path)
settings = open_baseline_settings(name, namespace="beamline")
assert os.path.exists(namespaced_path)
assert settings.value("test/value", "", type=str) == "fallback"
def test_canonical_profile_wins_over_legacy_profile(self, temp_profile_dir):
"""Canonical runtime/baseline files are not overwritten by legacy fallback files."""
name = "canonical_wins"
runtime_settings = open_runtime_settings(name)
runtime_settings.setValue("test/value", "canonical-runtime")
runtime_settings.sync()
baseline_settings = open_baseline_settings(name)
baseline_settings.setValue("test/value", "canonical-baseline")
baseline_settings.sync()
for segment, value in (("user", "legacy-runtime"), ("default", "legacy-baseline")):
legacy_dir = os.path.join(temp_profile_dir, segment)
os.makedirs(legacy_dir, exist_ok=True)
legacy_settings = QSettings(
os.path.join(legacy_dir, f"{name}.ini"), QSettings.IniFormat
)
legacy_settings.setValue("test/value", value)
legacy_settings.sync()
assert name in list_profiles()
assert open_runtime_settings(name).value("test/value", "", type=str) == "canonical-runtime"
assert (
open_baseline_settings(name).value("test/value", "", type=str) == "canonical-baseline"
)
def test_list_profiles_empty(self, temp_profile_dir): def test_list_profiles_empty(self, temp_profile_dir):
"""Test listing profiles when directory is empty.""" """Test listing profiles when directory is empty."""
@@ -1730,7 +1666,7 @@ class TestProfileManagement:
# Create some test profile files # Create some test profile files
profile_names = ["profile1", "profile2", "profile3"] profile_names = ["profile1", "profile2", "profile3"]
for name in profile_names: for name in profile_names:
settings = open_runtime_settings(name) settings = open_user_settings(name)
settings.setValue("test", "value") settings.setValue("test", "value")
settings.sync() settings.sync()
@@ -1740,24 +1676,24 @@ class TestProfileManagement:
def test_readonly_profile_operations(self, temp_profile_dir, module_profile_factory): def test_readonly_profile_operations(self, temp_profile_dir, module_profile_factory):
"""Test read-only profile functionality.""" """Test read-only profile functionality."""
profile_name = "runtime_profile" profile_name = "user_profile"
# Initially should not be read-only # Initially should not be read-only
assert not is_profile_read_only(profile_name) assert not is_profile_read_only(profile_name)
# Create a runtime profile and ensure it's writable # Create a user profile and ensure it's writable
settings = open_runtime_settings(profile_name) settings = open_user_settings(profile_name)
settings.setValue("test", "value") settings.setValue("test", "value")
settings.sync() settings.sync()
assert not is_profile_read_only(profile_name) assert not is_profile_read_only(profile_name)
# Verify a bundled module profile is detected as read-only # Verify a bundled module profile is detected as read-only
readonly_name = module_profile_factory("module_baseline") readonly_name = module_profile_factory("module_default")
assert is_profile_read_only(readonly_name) assert is_profile_read_only(readonly_name)
def test_write_and_read_manifest(self, temp_profile_dir, advanced_dock_area, qtbot): def test_write_and_read_manifest(self, temp_profile_dir, advanced_dock_area, qtbot):
"""Test writing and reading dock manifest.""" """Test writing and reading dock manifest."""
settings = open_runtime_settings("test_manifest") settings = open_user_settings("test_manifest")
# Create real docks # Create real docks
advanced_dock_area.new("DarkModeButton") advanced_dock_area.new("DarkModeButton")
@@ -1787,18 +1723,18 @@ class TestProfileManagement:
def test_restore_preserves_quick_select(self, temp_profile_dir): def test_restore_preserves_quick_select(self, temp_profile_dir):
"""Ensure restoring keeps the quick select flag when it was enabled.""" """Ensure restoring keeps the quick select flag when it was enabled."""
profile_name = "restorable_profile" profile_name = "restorable_profile"
baseline_settings = open_baseline_settings(profile_name) default_settings = open_default_settings(profile_name)
baseline_settings.setValue("test", "baseline") default_settings.setValue("test", "default")
baseline_settings.sync() default_settings.sync()
runtime_settings = open_runtime_settings(profile_name) user_settings = open_user_settings(profile_name)
runtime_settings.setValue("test", "runtime") user_settings.setValue("test", "user")
runtime_settings.sync() user_settings.sync()
set_quick_select(profile_name, True) set_quick_select(profile_name, True)
assert is_quick_select(profile_name) assert is_quick_select(profile_name)
restore_runtime_from_baseline(profile_name) restore_user_from_default(profile_name)
assert is_quick_select(profile_name) assert is_quick_select(profile_name)
@@ -1822,7 +1758,7 @@ class TestWorkspaceProfileOperations:
widget.prepare_for_shutdown() widget.prepare_for_shutdown()
mock_write.assert_not_called() mock_write.assert_not_called()
helper.open_runtime("real_profile").sync() helper.open_user("real_profile").sync()
widget.load_profile("real_profile") widget.load_profile("real_profile")
assert widget._empty_profile_active is False assert widget._empty_profile_active is False
assert widget._empty_profile_consumed is True assert widget._empty_profile_consumed is True
@@ -1836,7 +1772,7 @@ class TestWorkspaceProfileOperations:
profile_name = module_profile_factory("readonly_profile") profile_name = module_profile_factory("readonly_profile")
new_profile = f"{profile_name}_custom" new_profile = f"{profile_name}_custom"
helper = profile_helper(advanced_dock_area) helper = profile_helper(advanced_dock_area)
target_path = helper.runtime_path(new_profile) target_path = helper.user_path(new_profile)
if os.path.exists(target_path): if os.path.exists(target_path):
os.remove(target_path) os.remove(target_path)
@@ -1866,7 +1802,7 @@ class TestWorkspaceProfileOperations:
helper = profile_helper(advanced_dock_area) helper = profile_helper(advanced_dock_area)
# Create a profile with manifest # Create a profile with manifest
settings = helper.open_runtime(profile_name) settings = helper.open_user(profile_name)
settings.beginWriteArray("manifest/widgets", 1) settings.beginWriteArray("manifest/widgets", 1)
settings.setArrayIndex(0) settings.setArrayIndex(0)
settings.setValue("object_name", "test_widget") settings.setValue("object_name", "test_widget")
@@ -1887,83 +1823,6 @@ class TestWorkspaceProfileOperations:
widget_map = advanced_dock_area.widget_map() widget_map = advanced_dock_area.widget_map()
assert "test_widget" in widget_map assert "test_widget" in widget_map
def test_load_profile_default_does_not_restore_baseline(self, advanced_dock_area):
"""Regular profile loading should not restore the runtime copy."""
profile_name = "load_without_baseline_restore"
helper = profile_helper(advanced_dock_area)
helper.open_runtime(profile_name).sync()
with patch(
"bec_widgets.widgets.containers.dock_area.dock_area.restore_runtime_from_baseline"
) as mock_restore:
advanced_dock_area.load_profile(profile_name)
mock_restore.assert_not_called()
assert advanced_dock_area._current_profile_name == profile_name
def test_load_profile_restores_baseline_without_dialog(self, advanced_dock_area):
"""CLI loading can restore the runtime copy from baseline without confirmation."""
profile_name = "alignment_scan"
helper = profile_helper(advanced_dock_area)
helper.open_baseline(profile_name).sync()
helper.open_runtime(profile_name).sync()
with (
patch(
"bec_widgets.widgets.containers.dock_area.dock_area.RestoreProfileDialog.confirm"
) as mock_confirm,
patch(
"bec_widgets.widgets.containers.dock_area.dock_area.restore_runtime_from_baseline"
) as mock_restore,
):
advanced_dock_area.load_profile(profile_name, restore_baseline=True)
mock_confirm.assert_not_called()
mock_restore.assert_called_once_with(
profile_name, namespace=advanced_dock_area.profile_namespace
)
assert advanced_dock_area._current_profile_name == profile_name
def test_load_profile_materializes_runtime_namespace_fallback(self, advanced_dock_area):
"""Loading a runtime fallback copies it into the active namespace before opening."""
profile_name = "load_runtime_namespace_fallback"
helper = profile_helper(advanced_dock_area)
fallback_settings = open_runtime_settings(profile_name)
fallback_settings.setValue("test/value", "fallback")
fallback_settings.sync()
namespaced_path = helper.runtime_path(profile_name)
assert not os.path.exists(namespaced_path)
advanced_dock_area.load_profile(profile_name)
assert os.path.exists(namespaced_path)
assert (
QSettings(namespaced_path, QSettings.IniFormat).value("test/value", "", type=str)
== "fallback"
)
assert advanced_dock_area._current_profile_name == profile_name
def test_load_profile_materializes_baseline_namespace_fallback(self, advanced_dock_area):
"""Loading a baseline fallback copies it into the active namespace before opening."""
profile_name = "load_baseline_namespace_fallback"
helper = profile_helper(advanced_dock_area)
fallback_settings = open_baseline_settings(profile_name)
fallback_settings.setValue("test/value", "fallback")
fallback_settings.sync()
namespaced_path = helper.baseline_path(profile_name)
assert not os.path.exists(namespaced_path)
advanced_dock_area.load_profile(profile_name)
assert os.path.exists(namespaced_path)
assert (
QSettings(namespaced_path, QSettings.IniFormat).value("test/value", "", type=str)
== "fallback"
)
assert advanced_dock_area._current_profile_name == profile_name
def test_save_as_skips_autosave_source_profile( def test_save_as_skips_autosave_source_profile(
self, advanced_dock_area, temp_profile_dir, qtbot self, advanced_dock_area, temp_profile_dir, qtbot
): ):
@@ -1972,7 +1831,7 @@ class TestWorkspaceProfileOperations:
new_profile = "autosave_new" new_profile = "autosave_new"
helper = profile_helper(advanced_dock_area) helper = profile_helper(advanced_dock_area)
settings = helper.open_runtime(source_profile) settings = helper.open_user(source_profile)
settings.beginWriteArray("manifest/widgets", 1) settings.beginWriteArray("manifest/widgets", 1)
settings.setArrayIndex(0) settings.setArrayIndex(0)
settings.setValue("object_name", "source_widget") settings.setValue("object_name", "source_widget")
@@ -2004,16 +1863,11 @@ class TestWorkspaceProfileOperations:
with patch( with patch(
"bec_widgets.widgets.containers.dock_area.dock_area.SaveProfileDialog", StubDialog "bec_widgets.widgets.containers.dock_area.dock_area.SaveProfileDialog", StubDialog
): ):
widgets_before_save = list(advanced_dock_area.widget_list()) advanced_dock_area.save_profile(show_dialog=True)
with patch.object(advanced_dock_area, "load_profile") as mock_load_profile:
advanced_dock_area.save_profile(show_dialog=True)
qtbot.wait(100)
mock_load_profile.assert_not_called()
qtbot.wait(500) qtbot.wait(500)
assert list(advanced_dock_area.widget_list()) == widgets_before_save source_manifest = read_manifest(helper.open_user(source_profile))
source_manifest = read_manifest(helper.open_runtime(source_profile)) new_manifest = read_manifest(helper.open_user(new_profile))
new_manifest = read_manifest(helper.open_runtime(new_profile))
assert len(source_manifest) == 1 assert len(source_manifest) == 1
assert len(new_manifest) == 2 assert len(new_manifest) == 2
@@ -2025,7 +1879,7 @@ class TestWorkspaceProfileOperations:
helper = profile_helper(advanced_dock_area) helper = profile_helper(advanced_dock_area)
for profile in (profile_a, profile_b): for profile in (profile_a, profile_b):
settings = helper.open_runtime(profile) settings = helper.open_user(profile)
settings.beginWriteArray("manifest/widgets", 1) settings.beginWriteArray("manifest/widgets", 1)
settings.setArrayIndex(0) settings.setArrayIndex(0)
settings.setValue("object_name", f"{profile}_widget") settings.setValue("object_name", f"{profile}_widget")
@@ -2044,7 +1898,7 @@ class TestWorkspaceProfileOperations:
advanced_dock_area.load_profile(profile_b) advanced_dock_area.load_profile(profile_b)
qtbot.wait(500) qtbot.wait(500)
manifest_a = read_manifest(helper.open_runtime(profile_a)) manifest_a = read_manifest(helper.open_user(profile_a))
assert len(manifest_a) == 2 assert len(manifest_a) == 2
def test_delete_profile_readonly( def test_delete_profile_readonly(
@@ -2053,15 +1907,15 @@ class TestWorkspaceProfileOperations:
"""Test deleting bundled profile removes only the writable copy.""" """Test deleting bundled profile removes only the writable copy."""
profile_name = module_profile_factory("readonly_profile") profile_name = module_profile_factory("readonly_profile")
helper = profile_helper(advanced_dock_area) helper = profile_helper(advanced_dock_area)
helper.list_profiles() # ensure baseline and runtime copies are materialized helper.list_profiles() # ensure default and user copies are materialized
helper.open_baseline(profile_name).sync() helper.open_default(profile_name).sync()
settings = helper.open_runtime(profile_name) settings = helper.open_user(profile_name)
settings.setValue("test", "value") settings.setValue("test", "value")
settings.sync() settings.sync()
runtime_path = helper.runtime_path(profile_name) user_path = helper.user_path(profile_name)
baseline_path = helper.baseline_path(profile_name) default_path = helper.default_path(profile_name)
assert os.path.exists(runtime_path) assert os.path.exists(user_path)
assert os.path.exists(baseline_path) assert os.path.exists(default_path)
with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action: with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action:
mock_combo = MagicMock() mock_combo = MagicMock()
@@ -2082,9 +1936,9 @@ class TestWorkspaceProfileOperations:
mock_question.assert_not_called() mock_question.assert_not_called()
mock_info.assert_called_once() mock_info.assert_called_once()
# Read-only profile should remain intact (runtime + baseline copies) # Read-only profile should remain intact (user + default copies)
assert os.path.exists(runtime_path) assert os.path.exists(user_path)
assert os.path.exists(baseline_path) assert os.path.exists(default_path)
def test_delete_profile_success(self, advanced_dock_area, temp_profile_dir): def test_delete_profile_success(self, advanced_dock_area, temp_profile_dir):
"""Test successful profile deletion.""" """Test successful profile deletion."""
@@ -2092,11 +1946,11 @@ class TestWorkspaceProfileOperations:
helper = profile_helper(advanced_dock_area) helper = profile_helper(advanced_dock_area)
# Create regular profile # Create regular profile
settings = helper.open_runtime(profile_name) settings = helper.open_user(profile_name)
settings.setValue("test", "value") settings.setValue("test", "value")
settings.sync() settings.sync()
runtime_path = helper.runtime_path(profile_name) user_path = helper.user_path(profile_name)
assert os.path.exists(runtime_path) assert os.path.exists(user_path)
with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action: with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action:
mock_combo = MagicMock() mock_combo = MagicMock()
@@ -2114,7 +1968,7 @@ class TestWorkspaceProfileOperations:
mock_question.assert_called_once() mock_question.assert_called_once()
mock_refresh.assert_called_once() mock_refresh.assert_called_once()
# Profile should be deleted # Profile should be deleted
assert not os.path.exists(runtime_path) assert not os.path.exists(user_path)
def test_delete_profile_cli_usage(self, advanced_dock_area, temp_profile_dir): def test_delete_profile_cli_usage(self, advanced_dock_area, temp_profile_dir):
"""Test delete_profile with explicit name (CLI usage - no dialog by default).""" """Test delete_profile with explicit name (CLI usage - no dialog by default)."""
@@ -2122,24 +1976,24 @@ class TestWorkspaceProfileOperations:
helper = profile_helper(advanced_dock_area) helper = profile_helper(advanced_dock_area)
# Create regular profile # Create regular profile
settings = helper.open_runtime(profile_name) settings = helper.open_user(profile_name)
settings.setValue("test", "value") settings.setValue("test", "value")
settings.sync() settings.sync()
runtime_path = helper.runtime_path(profile_name) user_path = helper.user_path(profile_name)
assert os.path.exists(runtime_path) assert os.path.exists(user_path)
# Delete without dialog (CLI usage - default behavior) # Delete without dialog (CLI usage - default behavior)
result = advanced_dock_area.delete_profile(profile_name) result = advanced_dock_area.delete_profile(profile_name)
assert result is True assert result is True
assert not os.path.exists(runtime_path) assert not os.path.exists(user_path)
def test_refresh_workspace_list(self, advanced_dock_area, temp_profile_dir): def test_refresh_workspace_list(self, advanced_dock_area, temp_profile_dir):
"""Test refreshing workspace list.""" """Test refreshing workspace list."""
# Create some profiles # Create some profiles
helper = profile_helper(advanced_dock_area) helper = profile_helper(advanced_dock_area)
for name in ["profile1", "profile2"]: for name in ["profile1", "profile2"]:
settings = helper.open_runtime(name) settings = helper.open_user(name)
settings.setValue("test", "value") settings.setValue("test", "value")
settings.sync() settings.sync()
@@ -2179,6 +2033,20 @@ class TestCleanupAndMisc:
# Verify dock was removed # Verify dock was removed
assert len(advanced_dock_area.dock_list()) == initial_count - 1 assert len(advanced_dock_area.dock_list()) == initial_count - 1
def test_apply_dock_lock(self, advanced_dock_area, qtbot):
"""Test _apply_dock_lock functionality."""
# Create a dock first
advanced_dock_area.new("DarkModeButton")
qtbot.wait(200)
# Test locking
advanced_dock_area._apply_dock_lock(True)
# No assertion needed - just verify it doesn't crash
# Test unlocking
advanced_dock_area._apply_dock_lock(False)
# No assertion needed - just verify it doesn't crash
def test_make_dock(self, advanced_dock_area): def test_make_dock(self, advanced_dock_area):
"""Test _make_dock functionality.""" """Test _make_dock functionality."""
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import ( from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import (
+3 -1
View File
@@ -247,10 +247,12 @@ def test_bec_weblinks(monkeypatch):
monkeypatch.setattr(webbrowser, "open", fake_open) monkeypatch.setattr(webbrowser, "open", fake_open)
BECWebLinksMixin.open_bec_docs() BECWebLinksMixin.open_bec_docs()
BECWebLinksMixin.open_bec_widgets_docs()
BECWebLinksMixin.open_bec_bug_report() BECWebLinksMixin.open_bec_bug_report()
assert opened_urls == [ assert opened_urls == [
"https://bec-project.github.io/bec_docs/", "https://beamline-experiment-control.readthedocs.io/en/latest/",
"https://bec.readthedocs.io/projects/bec-widgets/en/latest/",
"https://github.com/bec-project/bec_widgets/issues", "https://github.com/bec-project/bec_widgets/issues",
] ]