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

Compare commits

..

7 Commits

Author SHA1 Message Date
semantic-release c1d5069a48 3.8.0
Automatically generated by python-semantic-release
2026-05-01 15:16:03 +00:00
wyzula_j 0b1f0b4c26 fix(dock_area): change to show_dialo=False for CLI profile baseline restore 2026-05-01 17:15:03 +02:00
wyzula_j cc825972c2 fix(dock_area): cli call load_profile has restore_baseline kwarg 2026-05-01 17:15:03 +02:00
wyzula_j 17865a2c33 feat(dock_area): add CLI restore current profile from baseline with optional confirmation dialog 2026-05-01 17:15:03 +02:00
semantic-release 0728811238 3.7.3
Automatically generated by python-semantic-release
2026-05-01 11:33:30 +00:00
wyzula_j 717d74b19e test(dock_area): remove low-value tests 2026-05-01 13:32:46 +02:00
wyzula_j dd32caf6e8 fix(dock_area): profile names changed, default->baseline, user->runtime 2026-05-01 13:32:46 +02:00
9 changed files with 669 additions and 517 deletions
+29
View File
@@ -1,6 +1,35 @@
# 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) ## v3.7.2 (2026-04-29)
### Bug Fixes ### Bug Fixes
+46 -14
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 default copy to states/default/<name>.ini with tag=default and created_at - writes a baseline copy to profiles/baseline/<name>.ini with created_at
- writes a user copy to states/user/<name>.ini with tag=user and created_at - writes a runtime copy to profiles/runtime/<name>.ini with created_at
On subsequent saves of user-owned profiles: On subsequent saves:
- updates both the default and user copies so restore uses the latest snapshot. - updates both the baseline and runtime copies so restore uses the latest snapshot.
Read-only bundled profiles cannot be overwritten. Read-only bundled profiles cannot be overwritten.
Args: Args:
@@ -358,15 +358,31 @@ class BECDockArea(RPCBase):
@rpc_timeout(None) @rpc_timeout(None)
@rpc_call @rpc_call
def load_profile(self, name: "str | None" = None): def load_profile(self, name: "str | None" = None, restore_baseline: "bool" = False):
""" """
Load a workspace profile. Load a workspace profile.
Before switching, persist the current profile to the user copy. Before switching, persist the current profile to the runtime copy.
Prefer loading the user copy; fall back to the default copy. Prefer loading the runtime copy; fall back to the baseline copy. When
``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
@@ -1348,10 +1364,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 default copy to states/default/<name>.ini with tag=default and created_at - writes a baseline copy to profiles/baseline/<name>.ini with created_at
- writes a user copy to states/user/<name>.ini with tag=user and created_at - writes a runtime copy to profiles/runtime/<name>.ini with created_at
On subsequent saves of user-owned profiles: On subsequent saves:
- updates both the default and user copies so restore uses the latest snapshot. - updates both the baseline and runtime copies so restore uses the latest snapshot.
Read-only bundled profiles cannot be overwritten. Read-only bundled profiles cannot be overwritten.
Args: Args:
@@ -1366,15 +1382,31 @@ class DockAreaView(RPCBase):
@rpc_timeout(None) @rpc_timeout(None)
@rpc_call @rpc_call
def load_profile(self, name: "str | None" = None): def load_profile(self, name: "str | None" = None, restore_baseline: "bool" = False):
""" """
Load a workspace profile. Load a workspace profile.
Before switching, persist the current profile to the user copy. Before switching, persist the current profile to the runtime copy.
Prefer loading the user copy; fall back to the default copy. Prefer loading the runtime copy; fall back to the baseline copy. When
``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
@@ -21,7 +21,6 @@ import bec_widgets.widgets.containers.qt_ads as QtAds
from bec_widgets import BECWidget, SafeProperty, SafeSlot from bec_widgets import BECWidget, SafeProperty, SafeSlot
from bec_widgets.applications.views.view import ViewTourSteps from bec_widgets.applications.views.view import ViewTourSteps
from bec_widgets.utils.bec_dispatcher import BECDispatcher from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.rpc_decorator import rpc_timeout from bec_widgets.utils.rpc_decorator import rpc_timeout
from bec_widgets.utils.rpc_widget_handler import widget_handler from bec_widgets.utils.rpc_widget_handler import widget_handler
@@ -36,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,
default_profile_candidates, baseline_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_default_profile_screenshot, load_baseline_profile_screenshot,
load_user_profile_screenshot, load_runtime_profile_screenshot,
now_iso_utc, now_iso_utc,
open_default_settings, open_baseline_settings,
open_user_settings, open_runtime_settings,
profile_origin, profile_origin,
profile_origin_display, profile_origin_display,
read_manifest, read_manifest,
restore_user_from_default, restore_runtime_from_baseline,
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 (
@@ -109,6 +108,7 @@ class BECDockArea(DockAreaWidget):
"list_profiles", "list_profiles",
"save_profile", "save_profile",
"load_profile", "load_profile",
"restore_baseline_profile",
"delete_profile", "delete_profile",
] ]
@@ -399,26 +399,6 @@ class BECDockArea(DockAreaWidget):
_build_menu("menu_devices", "Add Device Control ", device_actions) _build_menu("menu_devices", "Add Device Control ", device_actions)
_build_menu("menu_utils", "Add Utils ", util_actions) _build_menu("menu_utils", "Add Utils ", util_actions)
# Build plugin widget menu (only shown when plugin widgets are available)
try: # TODO move this check to bec_plugin_helper.ser_widget_plugin method to fix globally
plugin_widgets_dict = get_all_plugin_widgets().as_dict()
except (ImportError, AttributeError, RuntimeError):
logger.warning("Failed to discover plugin widgets for toolbar menu.", exc_info=True)
plugin_widgets_dict = {}
plugin_actions: dict[str, tuple[str, str, str]] = {
widget_name: (
getattr(widget_cls, "ICON_NAME", "widgets"),
f"Add {widget_name}",
widget_name,
)
for widget_name, widget_cls in plugin_widgets_dict.items()
}
if plugin_actions:
_build_menu("menu_plugins", "Add Plugins ", plugin_actions)
logger.success(
"Plugin widgets added to toolbar menu: " + ", ".join(plugin_actions.keys())
)
# Create flat toolbar bundles for each widget type # Create flat toolbar bundles for each widget type
def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]): def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]):
bundle = ToolbarBundle(f"flat_{category}", self.toolbar.components) bundle = ToolbarBundle(f"flat_{category}", self.toolbar.components)
@@ -489,16 +469,14 @@ class BECDockArea(DockAreaWidget):
bda.add_action("dark_mode") bda.add_action("dark_mode")
self.toolbar.add_bundle(bda) self.toolbar.add_bundle(bda)
# Store mappings on self for use in _hook_toolbar and _apply_toolbar_layout self._apply_toolbar_layout()
# Store mappings on self for use in _hook_toolbar
self._ACTION_MAPPINGS = { self._ACTION_MAPPINGS = {
"menu_plots": plot_actions, "menu_plots": plot_actions,
"menu_devices": device_actions, "menu_devices": device_actions,
"menu_utils": util_actions, "menu_utils": util_actions,
} }
if plugin_actions:
self._ACTION_MAPPINGS["menu_plugins"] = plugin_actions
self._apply_toolbar_layout()
def _hook_toolbar(self): def _hook_toolbar(self):
def _connect_menu(menu_key: str): def _connect_menu(menu_key: str):
@@ -524,8 +502,6 @@ class BECDockArea(DockAreaWidget):
_connect_menu("menu_plots") _connect_menu("menu_plots")
_connect_menu("menu_devices") _connect_menu("menu_devices")
_connect_menu("menu_utils") _connect_menu("menu_utils")
if "menu_plugins" in self._ACTION_MAPPINGS:
_connect_menu("menu_plugins")
def _connect_flat_actions(mapping: dict[str, tuple[str, str, str]]): def _connect_flat_actions(mapping: dict[str, tuple[str, str, str]]):
for action_id, (_, _, widget_type) in mapping.items(): for action_id, (_, _, widget_type) in mapping.items():
@@ -613,13 +589,13 @@ class BECDockArea(DockAreaWidget):
@property @property
def profile_namespace(self) -> str | None: def profile_namespace(self) -> str | None:
"""Namespace used to scope user/default profile files for this dock area.""" """Namespace used to scope runtime/baseline 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 user_profile_candidates(name, namespace) os.path.exists(path) for path in runtime_profile_candidates(name, namespace)
) or any(os.path.exists(path) for path in default_profile_candidates(name, namespace)) ) or any(os.path.exists(path) for path in baseline_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:
""" """
@@ -645,35 +621,34 @@ class BECDockArea(DockAreaWidget):
name: str, name: str,
namespace: str | None, namespace: str | None,
*, *,
write_default: bool = True, write_baseline: bool = True,
write_user: bool = True, write_runtime: bool = True,
save_preview: bool = True, save_preview: bool = True,
) -> None: ) -> None:
""" """
Write profile settings to default and/or user settings files. Write profile settings to baseline and/or runtime settings files.
Args: Args:
name: The profile name. name: The profile name.
namespace: The profile namespace. namespace: The profile namespace.
write_default: Whether to write to the default settings file. write_baseline: Whether to write to the baseline settings file.
write_user: Whether to write to the user settings file. write_runtime: Whether to write to the runtime 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)
if write_user: def _write_settings(open_settings) -> None:
us = open_user_settings(name, namespace=namespace) settings = open_settings(name, namespace=namespace)
self._write_snapshot_to_settings(us, save_preview=save_preview) self._write_snapshot_to_settings(settings, save_preview=save_preview)
if not us.value(SETTINGS_KEYS["created_at"], ""): if not settings.value(SETTINGS_KEYS["created_at"], ""):
us.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) settings.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
if not us.value(SETTINGS_KEYS["is_quick_select"], None): if not settings.value(SETTINGS_KEYS["is_quick_select"], None):
us.setValue(SETTINGS_KEYS["is_quick_select"], True) settings.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:
""" """
@@ -735,10 +710,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 default copy to states/default/<name>.ini with tag=default and created_at - writes a baseline copy to profiles/baseline/<name>.ini with created_at
- writes a user copy to states/user/<name>.ini with tag=user and created_at - writes a runtime copy to profiles/runtime/<name>.ini with created_at
On subsequent saves of user-owned profiles: On subsequent saves:
- updates both the default and user copies so restore uses the latest snapshot. - updates both the baseline and runtime copies so restore uses the latest snapshot.
Read-only bundled profiles cannot be overwritten. Read-only bundled profiles cannot be overwritten.
Args: Args:
@@ -802,7 +777,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_default = overwrite_existing and origin_before_save == "settings" overwrite_baseline = 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
@@ -811,12 +786,12 @@ class BECDockArea(DockAreaWidget):
workspace_combo.setCurrentIndex(0) workspace_combo.setCurrentIndex(0)
workspace_combo.blockSignals(False) workspace_combo.blockSignals(False)
# Write to default and/or user settings # Write to baseline and/or runtime settings
should_write_default = overwrite_default or not any( should_write_baseline = overwrite_baseline or not any(
os.path.exists(path) for path in default_profile_candidates(name, namespace) os.path.exists(path) for path in baseline_profile_candidates(name, namespace)
) )
self._write_profile_settings( self._write_profile_settings(
name, namespace, write_default=should_write_default, write_user=True name, namespace, write_baseline=should_write_baseline, write_runtime=True
) )
set_quick_select(name, quickselect, namespace=namespace) set_quick_select(name, quickselect, namespace=namespace)
@@ -845,16 +820,21 @@ 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): def load_profile(self, name: str | None = None, restore_baseline: bool = False):
""" """
Load a workspace profile. Load a workspace profile.
Before switching, persist the current profile to the user copy. Before switching, persist the current profile to the runtime copy.
Prefer loading the user copy; fall back to the default copy. Prefer loading the runtime copy; fall back to the baseline copy. When
``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
@@ -873,14 +853,17 @@ 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_user_settings(prev_name, namespace=namespace) us_prev = open_runtime_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 user_profile_candidates(name, namespace)): if any(os.path.exists(path) for path in runtime_profile_candidates(name, namespace)):
settings = open_user_settings(name, namespace=namespace) settings = open_runtime_settings(name, namespace=namespace)
elif any(os.path.exists(path) for path in default_profile_candidates(name, namespace)): elif any(os.path.exists(path) for path in baseline_profile_candidates(name, namespace)):
settings = open_default_settings(name, namespace=namespace) settings = open_baseline_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()
@@ -922,32 +905,36 @@ class BECDockArea(DockAreaWidget):
@SafeSlot() @SafeSlot()
@SafeSlot(str) @SafeSlot(str)
def restore_user_profile_from_default(self, name: str | None = None): @SafeSlot(str, bool)
@rpc_timeout(None)
def restore_baseline_profile(self, name: str | None = None, show_dialog: bool = False):
""" """
Overwrite the user copy of *name* with the default baseline. Overwrite the runtime copy of *name* with the 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, default_pixmap): if not RestoreProfileDialog.confirm(self, current_pixmap, baseline_pixmap):
return return
restore_user_from_default(target, namespace=namespace) restore_runtime_from_baseline(target, namespace=namespace)
self.delete_all() self.delete_all()
self.load_profile(target) self.load_profile(target)
@@ -1082,7 +1069,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, default_profile=self._current_profile_name self, target_widget=self, active_profile=self._current_profile_name
) )
self.manage_dialog = QDialog(modal=False) self.manage_dialog = QDialog(modal=False)
@@ -1130,10 +1117,14 @@ class BECDockArea(DockAreaWidget):
if mode_key == "user": if mode_key == "user":
bundles = ["spacer_bundle", "workspace", "dock_actions"] bundles = ["spacer_bundle", "workspace", "dock_actions"]
elif mode_key == "creator": elif mode_key == "creator":
bundles = ["menu_plots", "menu_devices", "menu_utils"] bundles = [
if "menu_plugins" in getattr(self, "_ACTION_MAPPINGS", {}): "menu_plots",
bundles.append("menu_plugins") "menu_devices",
bundles += ["spacer_bundle", "workspace", "dock_actions"] "menu_utils",
"spacer_bundle",
"workspace",
"dock_actions",
]
elif mode_key == "plot": elif mode_key == "plot":
bundles = ["flat_plots", "spacer_bundle", "workspace", "dock_actions"] bundles = ["flat_plots", "spacer_bundle", "workspace", "dock_actions"]
elif mode_key == "device": elif mode_key == "device":
@@ -1177,7 +1168,7 @@ class BECDockArea(DockAreaWidget):
return return
namespace = self.profile_namespace namespace = self.profile_namespace
settings = open_user_settings(name, namespace=namespace) settings = open_runtime_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,9 +2,13 @@
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: <base_path>/profiles/{default,user} - All created/modified profiles are stored under the BEC settings root:
- Bundled read-only defaults are discovered in BW core states/default and plugin bec_widgets/profiles but never written to. <base_path>/profiles/{baseline,runtime}
- Lookup order when reading: user → settings default → app or plugin bundled default. - Bundled read-only baselines are discovered in BW core profiles and plugin
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
@@ -32,6 +36,12 @@ 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:
@@ -130,7 +140,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): Either ``"user"`` or ``"default"``. segment (str): Profile segment directory name.
namespace (str | None): Optional namespace label to scope profiles. namespace (str | None): Optional namespace label to scope profiles.
Returns: Returns:
@@ -143,157 +153,175 @@ def _profiles_dir(segment: str, namespace: str | None) -> str:
return path return path
def _user_path_candidates(name: str, namespace: str | None) -> list[str]: def _candidate_namespaces(namespace: str | None) -> list[str | None]:
"""
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 [primary] return [None]
legacy = os.path.join(_profiles_dir("user", None), f"{name}.ini") return [ns, None]
return [primary, legacy] if legacy != primary else [primary]
def _default_path_candidates(name: str, namespace: str | None) -> list[str]: def _segment_profile_path(segment_name: str, name: str, namespace: str | None) -> 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 candidate default-profile paths honoring namespace fallbacks. Generate profile candidates for a canonical segment.
Args: Canonical baseline/runtime files are always preferred. Namespace fallback
name (str): Profile name without extension. files and legacy default/user files are copied to the primary canonical path
namespace (str | None): Optional namespace label. when the primary file does not exist.
Returns:
list[str]: Ordered list of candidate default profile paths (.ini files).
""" """
ns = slugify.slugify(namespace, separator="_") if namespace else None canonical = [
primary = os.path.join(_profiles_dir("default", ns), f"{name}.ini") _segment_profile_path(_PROFILE_SEGMENT_ALIASES[segment][0], name, ns)
if not ns: for ns in _candidate_namespaces(namespace)
return [primary] ]
legacy = os.path.join(_profiles_dir("default", None), f"{name}.ini") legacy = []
return [primary, legacy] if legacy != primary else [primary] if include_legacy:
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 default_profiles_dir(namespace: str | None = None) -> str: def baseline_profiles_dir(namespace: str | None = None) -> str:
""" """
Return the directory that stores default profiles for the namespace. Return the directory that stores baseline 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 default profile directory. str: Absolute path to the baseline profile directory.
""" """
return _profiles_dir("default", namespace) return _profiles_dir("baseline", namespace)
def user_profiles_dir(namespace: str | None = None) -> str: def runtime_profiles_dir(namespace: str | None = None) -> str:
""" """
Return the directory that stores user profiles for the namespace. Return the directory that stores runtime 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 user profile directory. str: Absolute path to the runtime profile directory.
""" """
return _profiles_dir("user", namespace) return _profiles_dir("runtime", namespace)
def default_profile_path(name: str, namespace: str | None = None) -> str: def baseline_profile_path(name: str, namespace: str | None = None) -> str:
""" """
Compute the canonical default profile path for a profile name. Compute the canonical baseline 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 default profile file (.ini). str: Absolute path to the baseline profile file (.ini).
""" """
return _default_path_candidates(name, namespace)[0] return _canonical_profile_path("baseline", name, namespace)
def user_profile_path(name: str, namespace: str | None = None) -> str: def runtime_profile_path(name: str, namespace: str | None = None) -> str:
""" """
Compute the canonical user profile path for a profile name. Compute the canonical runtime 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 user profile file (.ini). str: Absolute path to the runtime profile file (.ini).
""" """
return _user_path_candidates(name, namespace)[0] return _canonical_profile_path("runtime", name, namespace)
def user_profile_candidates(name: str, namespace: str | None = None) -> list[str]: def runtime_profile_candidates(name: str, namespace: str | None = None) -> list[str]:
""" """
List all user profile path candidates for a profile name. List all runtime 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 user profile paths. list[str]: De-duplicated list of candidate runtime profile paths.
""" """
return list(dict.fromkeys(_user_path_candidates(name, namespace))) return _segment_path_candidates("runtime", name, namespace)
def default_profile_candidates(name: str, namespace: str | None = None) -> list[str]: def baseline_profile_candidates(name: str, namespace: str | None = None) -> list[str]:
""" """
List all default profile path candidates for a profile name. List all baseline 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 default profile paths. list[str]: De-duplicated list of candidate baseline profile paths.
""" """
return list(dict.fromkeys(_default_path_candidates(name, namespace))) return _segment_path_candidates("baseline", name, namespace)
def _existing_user_settings(name: str, namespace: str | None = None) -> QSettings | None: def _existing_runtime_settings(name: str, namespace: str | None = None) -> QSettings | None:
""" """
Resolve the first existing user profile settings object. Resolve the first existing runtime 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 user profile candidate, or ``None`` QSettings | None: Config for the first existing runtime profile candidate, or ``None``
when no files are present. when no files are present.
""" """
for path in user_profile_candidates(name, namespace): for path in runtime_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_default_settings(name: str, namespace: str | None = None) -> QSettings | None: def _existing_baseline_settings(name: str, namespace: str | None = None) -> QSettings | None:
""" """
Resolve the first existing default profile settings object. Resolve the first existing baseline 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 default profile candidate, or ``None`` QSettings | None: Config for the first existing baseline profile candidate, or ``None``
when no files are present. when no files are present.
""" """
for path in default_profile_candidates(name, namespace): for path in baseline_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
@@ -347,7 +375,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 user_profile_candidates(name, namespace) + default_profile_candidates( for path in runtime_profile_candidates(name, namespace) + baseline_profile_candidates(
name, namespace name, namespace
): ):
if os.path.exists(path): if os.path.exists(path):
@@ -406,8 +434,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 user copies; keep default copies for read-only origins. # Always allow removing runtime copies; keep baseline copies for read-only origins.
for path in set(user_profile_candidates(name, namespace)): for path in set(runtime_profile_candidates(name, namespace)):
try: try:
os.remove(path) os.remove(path)
removed = True removed = True
@@ -415,7 +443,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(default_profile_candidates(name, namespace)): for path in set(baseline_profile_candidates(name, namespace)):
try: try:
os.remove(path) os.remove(path)
removed = True removed = True
@@ -443,7 +471,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 defaults when missing locally. Enumerate all known profile names, syncing bundled baselines 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.
@@ -459,16 +487,27 @@ 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 = {default_profiles_dir(namespace), user_profiles_dir(namespace)} settings_dirs = {baseline_profiles_dir(namespace), runtime_profiles_dir(namespace)}
if ns: if ns:
settings_dirs.add(default_profiles_dir(None)) settings_dirs.add(baseline_profiles_dir(None))
settings_dirs.add(user_profiles_dir(None)) settings_dirs.add(runtime_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)
# Also consider read-only defaults from core module and beamline plugin repositories for name in sorted(settings_names):
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()),
@@ -484,17 +523,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 default directory # Ensure a copy in the namespace-specific settings baseline directory.
dst_default = default_profile_path(name, namespace) dst_baseline = baseline_profile_path(name, namespace)
if not os.path.exists(dst_default): if not os.path.exists(dst_baseline):
os.makedirs(os.path.dirname(dst_default), exist_ok=True) os.makedirs(os.path.dirname(dst_baseline), exist_ok=True)
shutil.copyfile(src, dst_default) shutil.copy2(src, dst_baseline)
# Ensure a user copy exists to allow edits in the writable settings area # Ensure a runtime copy exists to allow edits in the writable settings area.
dst_user = user_profile_path(name, namespace) dst_runtime = runtime_profile_path(name, namespace)
if not os.path.exists(dst_user): if not os.path.exists(dst_runtime):
os.makedirs(os.path.dirname(dst_user), exist_ok=True) os.makedirs(os.path.dirname(dst_runtime), exist_ok=True)
shutil.copyfile(src, dst_user) shutil.copy2(src, dst_runtime)
s = open_user_settings(name, namespace) s = open_runtime_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())
@@ -504,32 +543,34 @@ def list_profiles(namespace: str | None = None) -> list[str]:
return sorted(settings_names) return sorted(settings_names)
def open_default_settings(name: str, namespace: str | None = None) -> QSettings: def open_baseline_settings(name: str, namespace: str | None = None) -> QSettings:
""" """
Open (and create if necessary) the default profile settings file. Open (and create if necessary) the baseline 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 default profile file. QSettings: Settings instance targeting the baseline profile file.
""" """
return QSettings(default_profile_path(name, namespace), QSettings.IniFormat) baseline_profile_candidates(name, namespace)
return QSettings(baseline_profile_path(name, namespace), QSettings.IniFormat)
def open_user_settings(name: str, namespace: str | None = None) -> QSettings: def open_runtime_settings(name: str, namespace: str | None = None) -> QSettings:
""" """
Open (and create if necessary) the user profile settings file. Open (and create if necessary) the runtime 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 user profile file. QSettings: Settings instance targeting the runtime profile file.
""" """
return QSettings(user_profile_path(name, namespace), QSettings.IniFormat) runtime_profile_candidates(name, namespace)
return QSettings(runtime_profile_path(name, namespace), QSettings.IniFormat)
def _app_settings() -> QSettings: def _app_settings() -> QSettings:
@@ -759,26 +800,26 @@ def read_manifest(settings: QSettings) -> list[dict]:
return items return items
def restore_user_from_default(name: str, namespace: str | None = None) -> None: def restore_runtime_from_baseline(name: str, namespace: str | None = None) -> None:
""" """
Copy the default profile to the user profile, preserving quick-select flag. Copy the baseline profile to the runtime 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 default_profile_candidates(name, namespace): for candidate in baseline_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 = user_profile_path(name, namespace) dst = runtime_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_user_settings(name, namespace) s = open_runtime_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:
@@ -796,9 +837,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_user_settings(name, namespace) s = _existing_runtime_settings(name, namespace)
if s is None: if s is None:
s = _existing_default_settings(name, namespace) s = _existing_baseline_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)
@@ -813,13 +854,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_user_settings(name, namespace) s = open_runtime_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 (user wins over default). List only profiles that have quick-select enabled (runtime wins over baseline).
Args: Args:
namespace(str | None, optional): Namespace label. Defaults to ``None``. namespace(str | None, optional): Namespace label. Defaults to ``None``.
@@ -909,8 +950,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
user_path: str = "" runtime_path: str = ""
default_path: str = "" baseline_path: str = ""
origin: ProfileOrigin = "unknown" origin: ProfileOrigin = "unknown"
is_read_only: bool = False is_read_only: bool = False
@@ -924,19 +965,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 user copy when present. ProfileInfo: Structured profile metadata, preferring the runtime copy when present.
""" """
user_paths = user_profile_candidates(name, namespace) runtime_paths = runtime_profile_candidates(name, namespace)
default_paths = default_profile_candidates(name, namespace) baseline_paths = baseline_profile_candidates(name, namespace)
u_path = next((p for p in user_paths if os.path.exists(p)), user_paths[0]) r_path = next((p for p in runtime_paths if os.path.exists(p)), runtime_paths[0])
d_path = next((p for p in default_paths if os.path.exists(p)), default_paths[0]) b_path = next((p for p in baseline_paths if os.path.exists(p)), baseline_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_user = os.path.exists(u_path) prefer_runtime = os.path.exists(r_path)
if prefer_user: if prefer_runtime:
s = QSettings(u_path, QSettings.IniFormat) s = QSettings(r_path, QSettings.IniFormat)
elif os.path.exists(d_path): elif os.path.exists(b_path):
s = QSettings(d_path, QSettings.IniFormat) s = QSettings(b_path, QSettings.IniFormat)
else: else:
s = None s = None
if s is None: if s is None:
@@ -957,14 +998,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,
user_path=u_path, runtime_path=r_path,
default_path=d_path, baseline_path=b_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 = u_path if prefer_user else d_path src_path = r_path if prefer_runtime else b_path
modified = _file_modified_iso(src_path) modified = _file_modified_iso(src_path)
count = _manifest_count(s) count = _manifest_count(s)
try: try:
@@ -990,8 +1031,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,
user_path=u_path, runtime_path=r_path,
default_path=d_path, baseline_path=b_path,
origin=origin, origin=origin,
is_read_only=read_only, is_read_only=read_only,
) )
@@ -999,7 +1040,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 (user preferred). Load the stored screenshot pixmap for a profile from settings (runtime preferred).
Args: Args:
name (str): Profile name without extension. name (str): Profile name without extension.
@@ -1008,17 +1049,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_user_settings(name, namespace) s = _existing_runtime_settings(name, namespace)
if s is None: if s is None:
s = _existing_default_settings(name, namespace) s = _existing_baseline_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_default_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None: def load_baseline_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
""" """
Load the screenshot from the default profile copy, if available. Load the screenshot from the baseline profile copy, if available.
Args: Args:
name (str): Profile name without extension. name (str): Profile name without extension.
@@ -1027,15 +1068,15 @@ def load_default_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_default_settings(name, namespace) s = _existing_baseline_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_user_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None: def load_runtime_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
""" """
Load the screenshot from the user profile copy, if available. Load the screenshot from the runtime profile copy, if available.
Args: Args:
name (str): Profile name without extension. name (str): Profile name without extension.
@@ -1044,7 +1085,7 @@ def load_user_profile_screenshot(name: str, namespace: str | None = None) -> QPi
Returns: Returns:
QPixmap | None: Screenshot pixmap or ``None`` if unavailable. QPixmap | None: Screenshot pixmap or ``None`` if unavailable.
""" """
s = _existing_user_settings(name, namespace) s = _existing_runtime_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 default profile provided by {provider} and cannot be overwritten.\n" f"'{name}' is a baseline 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 saved profile and its restore default.\n" "Overwriting will update both the runtime profile and its restore baseline.\n"
"Do you want to continue?" "Do you want to continue?"
), ),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
@@ -257,21 +257,24 @@ class PreviewPanel(QGroupBox):
class RestoreProfileDialog(QDialog): class RestoreProfileDialog(QDialog):
""" """
Confirmation dialog that previews the current profile screenshot against the default baseline. Confirmation dialog that previews the current runtime screenshot against the baseline.
""" """
def __init__( def __init__(
self, parent: QWidget | None, current_pixmap: QPixmap | None, default_pixmap: QPixmap | None self,
parent: QWidget | None,
current_pixmap: QPixmap | None,
baseline_pixmap: QPixmap | None,
): ):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Restore Profile to Default") self.setWindowTitle("Restore Profile to Baseline")
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 custom layout and replace it with the default profile." "Restoring will discard your runtime layout and replace it with the baseline profile."
) )
info_label.setWordWrap(True) info_label.setWordWrap(True)
layout.addWidget(info_label) layout.addWidget(info_label)
@@ -280,7 +283,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)
default_preview = PreviewPanel("Default", default_pixmap, self) baseline_preview = PreviewPanel("Baseline", baseline_pixmap, self)
# Equal expansion left/right # Equal expansion left/right
preview_row.addWidget(current_preview, 1) preview_row.addWidget(current_preview, 1)
@@ -292,7 +295,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(default_preview, 1) preview_row.addWidget(baseline_preview, 1)
# Enforce equal stretch for both previews # Enforce equal stretch for both previews
preview_row.setStretch(0, 1) preview_row.setStretch(0, 1)
@@ -300,7 +303,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 default layout now?" "This action cannot be undone. Do you want to restore the baseline layout now?"
) )
warn_label.setWordWrap(True) warn_label.setWordWrap(True)
layout.addWidget(warn_label) layout.addWidget(warn_label)
@@ -324,7 +327,7 @@ class RestoreProfileDialog(QDialog):
@staticmethod @staticmethod
def confirm( def confirm(
parent: QWidget | None, current_pixmap: QPixmap | None, default_pixmap: QPixmap | None parent: QWidget | None, current_pixmap: QPixmap | None, baseline_pixmap: QPixmap | None
) -> bool: ) -> bool:
dialog = RestoreProfileDialog(parent, current_pixmap, default_pixmap) dialog = RestoreProfileDialog(parent, current_pixmap, baseline_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, default_profile: str | None = None, **kwargs self, parent=None, target_widget=None, active_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 default_profile is not None: if active_profile is not None:
self._select_by_name(default_profile) self._select_by_name(active_profile)
self._show_profile_details(default_profile) self._show_profile_details(active_profile)
def _init_ui(self): def _init_ui(self):
self.root_layout = QHBoxLayout(self) self.root_layout = QHBoxLayout(self)
self.splitter = QSplitter(Qt.Horizontal, self) self.splitter = QSplitter(Qt.Orientation.Horizontal, self)
self.root_layout.addWidget(self.splitter) self.root_layout.addWidget(self.splitter)
# Init components # Init components
@@ -89,7 +89,9 @@ 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(QSizePolicy.Expanding, QSizePolicy.Expanding) self.screenshot_label.setSizePolicy(
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)
@@ -250,8 +252,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)),
("User path", info.user_path or ""), ("Runtime path", info.runtime_path or ""),
("Default path", info.default_path or ""), ("Baseline path", info.baseline_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]))
@@ -151,15 +151,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_default_workspace", "reset_baseline_workspace",
MaterialIconAction( MaterialIconAction(
icon_name="undo", icon_name="undo",
tooltip="Refresh Current Workspace", tooltip="Restore Baseline Profile",
checkable=False, checkable=False,
parent=components.toolbar, parent=components.toolbar,
), ),
) )
components.get_action("reset_default_workspace").action.setVisible(enable_tools) components.get_action("reset_baseline_workspace").action.setVisible(enable_tools)
components.add_safe( components.add_safe(
"manage_workspaces", "manage_workspaces",
@@ -172,7 +172,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_default_workspace") bundle.add_action("reset_baseline_workspace")
bundle.add_action("manage_workspaces") bundle.add_action("manage_workspaces")
return bundle return bundle
@@ -202,9 +202,9 @@ class WorkspaceConnection(BundleConnection):
self.target_widget.load_profile self.target_widget.load_profile
) )
reset_action = self.components.get_action("reset_default_workspace").action reset_action = self.components.get_action("reset_baseline_workspace").action
if reset_action.isVisible(): if reset_action.isVisible():
reset_action.triggered.connect(self._reset_workspace_to_default) reset_action.triggered.connect(self._reset_workspace_to_baseline)
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 +221,9 @@ class WorkspaceConnection(BundleConnection):
self.target_widget.load_profile self.target_widget.load_profile
) )
reset_action = self.components.get_action("reset_default_workspace").action reset_action = self.components.get_action("reset_baseline_workspace").action
if reset_action.isVisible(): if reset_action.isVisible():
reset_action.triggered.disconnect(self._reset_workspace_to_default) reset_action.triggered.disconnect(self._reset_workspace_to_baseline)
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 +231,8 @@ class WorkspaceConnection(BundleConnection):
self._connected = False self._connected = False
@SafeSlot() @SafeSlot()
def _reset_workspace_to_default(self): def _reset_workspace_to_baseline(self):
""" """
Refreshes the current workspace. Refreshes the current workspace.
""" """
self.target_widget.restore_user_profile_from_default() self.target_widget.restore_baseline_profile(show_dialog=True)
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "bec_widgets" name = "bec_widgets"
version = "3.7.2" version = "3.8.0"
description = "BEC Widgets" description = "BEC Widgets"
requires-python = ">=3.11" requires-python = ">=3.11"
classifiers = [ classifiers = [
+306 -252
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,
default_profile_path, baseline_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_default_profile_screenshot, load_baseline_profile_screenshot,
load_user_profile_screenshot, load_runtime_profile_screenshot,
open_default_settings, open_baseline_settings,
open_user_settings, open_runtime_settings,
read_manifest, read_manifest,
restore_user_from_default, restore_runtime_from_baseline,
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 (
@@ -44,23 +44,6 @@ from bec_widgets.widgets.plots.waveform.waveform import Waveform
from .client_mocks import mocked_client from .client_mocks import mocked_client
@pytest.fixture
def fake_plugin_widget_cls():
"""Return a fake plugin widget class for use in toolbar plugin menu tests."""
from qtpy.QtWidgets import QWidget as _QWidget
from bec_widgets.utils.bec_widget import BECWidget
class FakePluginWidget(BECWidget, _QWidget):
ICON_NAME = "star"
PLUGIN = True
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, **kwargs)
return FakePluginWidget
@pytest.fixture @pytest.fixture
def advanced_dock_area(qtbot, mocked_client): def advanced_dock_area(qtbot, mocked_client):
"""Create an AdvancedDockArea instance for testing.""" """Create an AdvancedDockArea instance for testing."""
@@ -205,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_user(self, name: str): def open_runtime(self, name: str):
return open_user_settings(name, namespace=self.namespace) return open_runtime_settings(name, namespace=self.namespace)
def open_default(self, name: str): def open_baseline(self, name: str):
return open_default_settings(name, namespace=self.namespace) return open_baseline_settings(name, namespace=self.namespace)
def user_path(self, name: str) -> str: def runtime_path(self, name: str) -> str:
return user_profile_path(name, namespace=self.namespace) return runtime_profile_path(name, namespace=self.namespace)
def default_path(self, name: str) -> str: def baseline_path(self, name: str) -> str:
return default_profile_path(name, namespace=self.namespace) return baseline_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)
@@ -632,35 +615,6 @@ 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."""
@@ -868,16 +822,6 @@ 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 = [
@@ -963,7 +907,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_user("floating_profile") settings = helper.open_runtime("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")
@@ -1041,62 +985,6 @@ class TestToolbarFunctionality:
# Verify save was called with the filename # Verify save was called with the filename
mock_screenshot.save.assert_called_once_with(str(screenshot_path)) mock_screenshot.save.assert_called_once_with(str(screenshot_path))
def test_plugin_menu_not_shown_when_no_plugins(self, qtbot, mocked_client):
"""Test that the plugin menu is not shown when there are no plugin widgets."""
with patch(
"bec_widgets.widgets.containers.dock_area.dock_area.get_all_plugin_widgets"
) as mock_plugins:
from bec_widgets.utils.plugin_utils import BECClassContainer
mock_plugins.return_value = BECClassContainer()
widget = BECDockArea(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
assert "menu_plugins" not in widget._ACTION_MAPPINGS
# Verify no "menu_plugins" bundle exists in the toolbar
assert "menu_plugins" not in widget.toolbar.bundles
def test_plugin_menu_includes_available_plugins(self, qtbot, mocked_client, fake_plugin_widget_cls):
"""Test that the plugin menu is shown when plugin widgets are available."""
with patch(
"bec_widgets.widgets.containers.dock_area.dock_area.get_all_plugin_widgets"
) as mock_plugins:
container_mock = MagicMock()
container_mock.as_dict.return_value = {"FakePluginWidget": fake_plugin_widget_cls}
mock_plugins.return_value = container_mock
widget = BECDockArea(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
assert "menu_plugins" in widget._ACTION_MAPPINGS
assert "FakePluginWidget" in widget._ACTION_MAPPINGS["menu_plugins"]
plugin_entry = widget._ACTION_MAPPINGS["menu_plugins"]["FakePluginWidget"]
assert plugin_entry[0] == "star" # icon name
assert plugin_entry[1] == "Add FakePluginWidget" # tooltip
assert plugin_entry[2] == "FakePluginWidget" # widget type name
assert "menu_plugins" in widget.toolbar.bundles
def test_plugin_menu_action_triggers_new(self, qtbot, mocked_client, fake_plugin_widget_cls):
"""Test that clicking a plugin menu action creates a new dock with the plugin widget."""
with patch(
"bec_widgets.widgets.containers.dock_area.dock_area.get_all_plugin_widgets"
) as mock_plugins:
container_mock = MagicMock()
container_mock.as_dict.return_value = {"FakePluginWidget": fake_plugin_widget_cls}
mock_plugins.return_value = container_mock
widget = BECDockArea(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
with patch.object(widget, "new") as mock_new:
menu_plugins = widget.toolbar.components.get_action("menu_plugins")
action = menu_plugins.actions["FakePluginWidget"].action
action.trigger()
mock_new.assert_called_once_with(widget="FakePluginWidget")
class TestDockSettingsDialog: class TestDockSettingsDialog:
"""Test dock settings dialog functionality.""" """Test dock settings dialog functionality."""
@@ -1288,18 +1176,6 @@ 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."""
@@ -1319,9 +1195,9 @@ class TestProfileInfoAndScreenshots:
settings.endArray() settings.endArray()
settings.sync() settings.sync()
def test_get_profile_info_user_origin(self, temp_profile_dir): def test_get_profile_info_runtime_origin(self, temp_profile_dir):
name = "info_user" name = "info_runtime"
settings = open_user_settings(name) settings = open_runtime_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)
@@ -1335,22 +1211,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.user_path.endswith(f"{name}.ini") assert info.runtime_path.endswith(f"{name}.ini")
assert info.size_kb >= 0 assert info.size_kb >= 0
def test_get_profile_info_default_only(self, temp_profile_dir): def test_get_profile_info_baseline_only(self, temp_profile_dir):
name = "info_default" name = "info_baseline"
settings = open_default_settings(name) settings = open_baseline_settings(name)
self._write_manifest(settings, count=1) self._write_manifest(settings, count=1)
user_path = user_profile_path(name) runtime_path = runtime_profile_path(name)
if os.path.exists(user_path): if os.path.exists(runtime_path):
os.remove(user_path) os.remove(runtime_path)
info = get_profile_info(name) info = get_profile_info(name)
assert info.origin == "settings" assert info.origin == "settings"
assert info.user_path.endswith(f"{name}.ini") assert info.baseline_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):
@@ -1362,10 +1238,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(user_profile_path(name)): if os.path.exists(runtime_profile_path(name)):
os.remove(user_profile_path(name)) os.remove(runtime_profile_path(name))
if os.path.exists(default_profile_path(name)): if os.path.exists(baseline_profile_path(name)):
os.remove(default_profile_path(name)) os.remove(baseline_profile_path(name))
info = get_profile_info(name) info = get_profile_info(name)
@@ -1373,29 +1249,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_user_profile_screenshot(self, temp_profile_dir): def test_load_runtime_profile_screenshot(self, temp_profile_dir):
name = "user_screenshot" name = "runtime_screenshot"
settings = open_user_settings(name) settings = open_runtime_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_user_profile_screenshot(name) pix = load_runtime_profile_screenshot(name)
assert pix is not None and not pix.isNull() assert pix is not None and not pix.isNull()
def test_load_default_profile_screenshot(self, temp_profile_dir): def test_load_baseline_profile_screenshot(self, temp_profile_dir):
name = "default_screenshot" name = "baseline_screenshot"
settings = open_default_settings(name) settings = open_baseline_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_default_profile_screenshot(name) pix = load_baseline_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_user_settings(name) settings = open_runtime_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()
@@ -1405,7 +1281,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_user_settings(name) settings = open_runtime_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()
@@ -1420,7 +1296,7 @@ class TestWorkSpaceManager:
@staticmethod @staticmethod
def _create_profiles(names): def _create_profiles(names):
for name in names: for name in names:
settings = open_user_settings(name) settings = open_runtime_settings(name)
settings.setValue("meta", "value") settings.setValue("meta", "value")
settings.sync() settings.sync()
@@ -1484,7 +1360,7 @@ class TestWorkSpaceManager:
manager.delete_profile(name) manager.delete_profile(name)
assert not os.path.exists(user_profile_path(name)) assert not os.path.exists(runtime_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(
@@ -1514,21 +1390,23 @@ 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_user_profile_from_default_confirm_true(self, advanced_dock_area, monkeypatch): def test_restore_runtime_profile_from_baseline_confirm_true(
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_default(profile_name).sync() helper.open_baseline(profile_name).sync()
helper.open_user(profile_name).sync() helper.open_runtime(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_user_profile_screenshot", "bec_widgets.widgets.containers.dock_area.dock_area.load_runtime_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_default_profile_screenshot", "bec_widgets.widgets.containers.dock_area.dock_area.load_baseline_profile_screenshot",
lambda name, namespace=None: pix, lambda name, namespace=None: pix,
) )
monkeypatch.setattr( monkeypatch.setattr(
@@ -1538,12 +1416,12 @@ class TestAdvancedDockAreaRestoreAndDialogs:
with ( with (
patch( patch(
"bec_widgets.widgets.containers.dock_area.dock_area.restore_user_from_default" "bec_widgets.widgets.containers.dock_area.dock_area.restore_runtime_from_baseline"
) 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_user_profile_from_default() advanced_dock_area.restore_baseline_profile(show_dialog=True)
assert mock_restore.call_count == 1 assert mock_restore.call_count == 1
args, kwargs = mock_restore.call_args args, kwargs = mock_restore.call_args
@@ -1552,20 +1430,22 @@ 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_user_profile_from_default_confirm_false(self, advanced_dock_area, monkeypatch): def test_restore_runtime_profile_from_baseline_confirm_false(
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_default(profile_name).sync() helper.open_baseline(profile_name).sync()
helper.open_user(profile_name).sync() helper.open_runtime(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_user_profile_screenshot", "bec_widgets.widgets.containers.dock_area.dock_area.load_runtime_profile_screenshot",
lambda name: QPixmap(), lambda name, namespace=None: QPixmap(),
) )
monkeypatch.setattr( monkeypatch.setattr(
"bec_widgets.widgets.containers.dock_area.dock_area.load_default_profile_screenshot", "bec_widgets.widgets.containers.dock_area.dock_area.load_baseline_profile_screenshot",
lambda name: QPixmap(), lambda name, namespace=None: 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",
@@ -1573,24 +1453,49 @@ class TestAdvancedDockAreaRestoreAndDialogs:
) )
with patch( with patch(
"bec_widgets.widgets.containers.dock_area.dock_area.restore_user_from_default" "bec_widgets.widgets.containers.dock_area.dock_area.restore_runtime_from_baseline"
) as mock_restore: ) as mock_restore:
advanced_dock_area.restore_user_profile_from_default() advanced_dock_area.restore_baseline_profile(show_dialog=True)
mock_restore.assert_not_called() mock_restore.assert_not_called()
def test_restore_user_profile_from_default_no_target(self, advanced_dock_area, monkeypatch): def test_restore_runtime_profile_from_baseline_without_dialog(self, advanced_dock_area):
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_user_profile_from_default() advanced_dock_area.restore_baseline_profile(show_dialog=True)
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_user(profile_name).sync() helper.open_runtime(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
@@ -1645,8 +1550,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_user(active).sync() helper.open_runtime(active).sync()
helper.open_user(quick).sync() helper.open_runtime(quick).sync()
helper.set_quick_select(quick, True) helper.set_quick_select(quick, True)
combo_stub = ComboStub() combo_stub = ComboStub()
@@ -1673,7 +1578,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_user("manager_profile").sync() helper.open_runtime("manager_profile").sync()
advanced_dock_area.show_workspace_manager() advanced_dock_area.show_workspace_manager()
@@ -1708,18 +1613,104 @@ 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 = user_profile_path("test_profile") path = runtime_profile_path("test_profile")
expected = os.path.join(temp_profile_dir, "user", "test_profile.ini") expected = os.path.join(temp_profile_dir, "runtime", "test_profile.ini")
assert path == expected assert path == expected
default_path = default_profile_path("test_profile") baseline_path = baseline_profile_path("test_profile")
expected_default = os.path.join(temp_profile_dir, "default", "test_profile.ini") expected_baseline = os.path.join(temp_profile_dir, "baseline", "test_profile.ini")
assert default_path == expected_default assert baseline_path == expected_baseline
def test_open_settings(self, temp_profile_dir): def test_legacy_user_profile_is_mapped_to_runtime(self, temp_profile_dir):
"""Test opening settings for a profile.""" """Legacy user profiles are copied into the canonical runtime segment."""
settings = open_user_settings("test_profile") name = "legacy_runtime"
assert isinstance(settings, QSettings) legacy_dir = os.path.join(temp_profile_dir, "user")
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."""
@@ -1739,7 +1730,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_user_settings(name) settings = open_runtime_settings(name)
settings.setValue("test", "value") settings.setValue("test", "value")
settings.sync() settings.sync()
@@ -1749,24 +1740,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 = "user_profile" profile_name = "runtime_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 user profile and ensure it's writable # Create a runtime profile and ensure it's writable
settings = open_user_settings(profile_name) settings = open_runtime_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_default") readonly_name = module_profile_factory("module_baseline")
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_user_settings("test_manifest") settings = open_runtime_settings("test_manifest")
# Create real docks # Create real docks
advanced_dock_area.new("DarkModeButton") advanced_dock_area.new("DarkModeButton")
@@ -1796,18 +1787,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"
default_settings = open_default_settings(profile_name) baseline_settings = open_baseline_settings(profile_name)
default_settings.setValue("test", "default") baseline_settings.setValue("test", "baseline")
default_settings.sync() baseline_settings.sync()
user_settings = open_user_settings(profile_name) runtime_settings = open_runtime_settings(profile_name)
user_settings.setValue("test", "user") runtime_settings.setValue("test", "runtime")
user_settings.sync() runtime_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_user_from_default(profile_name) restore_runtime_from_baseline(profile_name)
assert is_quick_select(profile_name) assert is_quick_select(profile_name)
@@ -1831,7 +1822,7 @@ class TestWorkspaceProfileOperations:
widget.prepare_for_shutdown() widget.prepare_for_shutdown()
mock_write.assert_not_called() mock_write.assert_not_called()
helper.open_user("real_profile").sync() helper.open_runtime("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
@@ -1845,7 +1836,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.user_path(new_profile) target_path = helper.runtime_path(new_profile)
if os.path.exists(target_path): if os.path.exists(target_path):
os.remove(target_path) os.remove(target_path)
@@ -1875,7 +1866,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_user(profile_name) settings = helper.open_runtime(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")
@@ -1896,6 +1887,83 @@ 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
): ):
@@ -1904,7 +1972,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_user(source_profile) settings = helper.open_runtime(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")
@@ -1944,8 +2012,8 @@ class TestWorkspaceProfileOperations:
qtbot.wait(500) qtbot.wait(500)
assert list(advanced_dock_area.widget_list()) == widgets_before_save 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
@@ -1957,7 +2025,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_user(profile) settings = helper.open_runtime(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")
@@ -1976,7 +2044,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_user(profile_a)) manifest_a = read_manifest(helper.open_runtime(profile_a))
assert len(manifest_a) == 2 assert len(manifest_a) == 2
def test_delete_profile_readonly( def test_delete_profile_readonly(
@@ -1985,15 +2053,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 default and user copies are materialized helper.list_profiles() # ensure baseline and runtime copies are materialized
helper.open_default(profile_name).sync() helper.open_baseline(profile_name).sync()
settings = helper.open_user(profile_name) settings = helper.open_runtime(profile_name)
settings.setValue("test", "value") settings.setValue("test", "value")
settings.sync() settings.sync()
user_path = helper.user_path(profile_name) runtime_path = helper.runtime_path(profile_name)
default_path = helper.default_path(profile_name) baseline_path = helper.baseline_path(profile_name)
assert os.path.exists(user_path) assert os.path.exists(runtime_path)
assert os.path.exists(default_path) assert os.path.exists(baseline_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()
@@ -2014,9 +2082,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 (user + default copies) # Read-only profile should remain intact (runtime + baseline copies)
assert os.path.exists(user_path) assert os.path.exists(runtime_path)
assert os.path.exists(default_path) assert os.path.exists(baseline_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."""
@@ -2024,11 +2092,11 @@ class TestWorkspaceProfileOperations:
helper = profile_helper(advanced_dock_area) helper = profile_helper(advanced_dock_area)
# Create regular profile # Create regular profile
settings = helper.open_user(profile_name) settings = helper.open_runtime(profile_name)
settings.setValue("test", "value") settings.setValue("test", "value")
settings.sync() settings.sync()
user_path = helper.user_path(profile_name) runtime_path = helper.runtime_path(profile_name)
assert os.path.exists(user_path) assert os.path.exists(runtime_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()
@@ -2046,7 +2114,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(user_path) assert not os.path.exists(runtime_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)."""
@@ -2054,24 +2122,24 @@ class TestWorkspaceProfileOperations:
helper = profile_helper(advanced_dock_area) helper = profile_helper(advanced_dock_area)
# Create regular profile # Create regular profile
settings = helper.open_user(profile_name) settings = helper.open_runtime(profile_name)
settings.setValue("test", "value") settings.setValue("test", "value")
settings.sync() settings.sync()
user_path = helper.user_path(profile_name) runtime_path = helper.runtime_path(profile_name)
assert os.path.exists(user_path) assert os.path.exists(runtime_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(user_path) assert not os.path.exists(runtime_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_user(name) settings = helper.open_runtime(name)
settings.setValue("test", "value") settings.setValue("test", "value")
settings.sync() settings.sync()
@@ -2111,20 +2179,6 @@ 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 (