diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 33cecbb0..11663d1e 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -340,10 +340,10 @@ class BECDockArea(RPCBase): Save the current workspace profile. On first save of a given name: - - writes a default copy to states/default/.ini with tag=default and created_at - - writes a user copy to states/user/.ini with tag=user and created_at - On subsequent saves of user-owned profiles: - - updates both the default and user copies so restore uses the latest snapshot. + - writes a baseline copy to profiles/baseline/.ini with created_at + - writes a runtime copy to profiles/runtime/.ini with created_at + On subsequent saves of settings-owned profiles: + - updates both the baseline and runtime copies so restore uses the latest snapshot. Read-only bundled profiles cannot be overwritten. Args: @@ -362,8 +362,8 @@ class BECDockArea(RPCBase): """ Load a workspace profile. - Before switching, persist the current profile to the user copy. - Prefer loading the user copy; fall back to the default copy. + Before switching, persist the current profile to the runtime copy. + Prefer loading the runtime copy; fall back to the baseline copy. Args: name (str | None): The name of the profile to load. If None, prompts the user. @@ -1348,10 +1348,10 @@ class DockAreaView(RPCBase): Save the current workspace profile. On first save of a given name: - - writes a default copy to states/default/.ini with tag=default and created_at - - writes a user copy to states/user/.ini with tag=user and created_at - On subsequent saves of user-owned profiles: - - updates both the default and user copies so restore uses the latest snapshot. + - writes a baseline copy to profiles/baseline/.ini with created_at + - writes a runtime copy to profiles/runtime/.ini with created_at + On subsequent saves of settings-owned profiles: + - updates both the baseline and runtime copies so restore uses the latest snapshot. Read-only bundled profiles cannot be overwritten. Args: @@ -1370,8 +1370,8 @@ class DockAreaView(RPCBase): """ Load a workspace profile. - Before switching, persist the current profile to the user copy. - Prefer loading the user copy; fall back to the default copy. + Before switching, persist the current profile to the runtime copy. + Prefer loading the runtime copy; fall back to the baseline copy. Args: name (str | None): The name of the profile to load. If None, prompts the user. diff --git a/bec_widgets/widgets/containers/dock_area/dock_area.py b/bec_widgets/widgets/containers/dock_area/dock_area.py index 23ae5b68..783f9b6d 100644 --- a/bec_widgets/widgets/containers/dock_area/dock_area.py +++ b/bec_widgets/widgets/containers/dock_area/dock_area.py @@ -35,25 +35,25 @@ from bec_widgets.utils.widget_state_manager import WidgetStateManager from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget from bec_widgets.widgets.containers.dock_area.profile_utils import ( SETTINGS_KEYS, - default_profile_candidates, + baseline_profile_candidates, delete_profile_files, get_last_profile, is_profile_read_only, is_quick_select, list_profiles, list_quick_profiles, - load_default_profile_screenshot, - load_user_profile_screenshot, + load_baseline_profile_screenshot, + load_runtime_profile_screenshot, now_iso_utc, - open_default_settings, - open_user_settings, + open_baseline_settings, + open_runtime_settings, profile_origin, profile_origin_display, read_manifest, - restore_user_from_default, + restore_runtime_from_baseline, + runtime_profile_candidates, set_last_profile, set_quick_select, - user_profile_candidates, write_manifest, ) from bec_widgets.widgets.containers.dock_area.settings.dialogs import ( @@ -588,13 +588,13 @@ class BECDockArea(DockAreaWidget): @property 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() def _profile_exists(self, name: str, namespace: str | None) -> bool: return any( - os.path.exists(path) for path in user_profile_candidates(name, namespace) - ) or any(os.path.exists(path) for path in default_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 baseline_profile_candidates(name, namespace)) def _write_snapshot_to_settings(self, settings, save_preview: bool = True) -> None: """ @@ -620,35 +620,34 @@ class BECDockArea(DockAreaWidget): name: str, namespace: str | None, *, - write_default: bool = True, - write_user: bool = True, + write_baseline: bool = True, + write_runtime: bool = True, save_preview: bool = True, ) -> None: """ - Write profile settings to default and/or user settings files. + Write profile settings to baseline and/or runtime settings files. Args: name: The profile name. namespace: The profile namespace. - write_default: Whether to write to the default settings file. - write_user: Whether to write to the user settings file. + write_baseline: Whether to write to the baseline settings file. + write_runtime: Whether to write to the runtime settings file. 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: - us = open_user_settings(name, namespace=namespace) - self._write_snapshot_to_settings(us, save_preview=save_preview) - if not us.value(SETTINGS_KEYS["created_at"], ""): - us.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) - if not us.value(SETTINGS_KEYS["is_quick_select"], None): - us.setValue(SETTINGS_KEYS["is_quick_select"], True) + def _write_settings(open_settings) -> None: + settings = open_settings(name, namespace=namespace) + self._write_snapshot_to_settings(settings, save_preview=save_preview) + if not settings.value(SETTINGS_KEYS["created_at"], ""): + settings.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) + if not settings.value(SETTINGS_KEYS["is_quick_select"], None): + 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: """ @@ -710,10 +709,10 @@ class BECDockArea(DockAreaWidget): Save the current workspace profile. On first save of a given name: - - writes a default copy to states/default/.ini with tag=default and created_at - - writes a user copy to states/user/.ini with tag=user and created_at - On subsequent saves of user-owned profiles: - - updates both the default and user copies so restore uses the latest snapshot. + - writes a baseline copy to profiles/baseline/.ini with created_at + - writes a runtime copy to profiles/runtime/.ini with created_at + On subsequent saves of settings-owned profiles: + - updates both the baseline and runtime copies so restore uses the latest snapshot. Read-only bundled profiles cannot be overwritten. Args: @@ -777,7 +776,7 @@ class BECDockArea(DockAreaWidget): overwrite_existing = origin == "settings" 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 workspace_combo = self.toolbar.components.get_action("workspace_combo").widget @@ -786,12 +785,12 @@ class BECDockArea(DockAreaWidget): workspace_combo.setCurrentIndex(0) workspace_combo.blockSignals(False) - # Write to default and/or user settings - should_write_default = overwrite_default or not any( - os.path.exists(path) for path in default_profile_candidates(name, namespace) + # Write to baseline and/or runtime settings + should_write_baseline = overwrite_baseline or not any( + os.path.exists(path) for path in baseline_profile_candidates(name, namespace) ) 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) @@ -825,8 +824,8 @@ class BECDockArea(DockAreaWidget): """ Load a workspace profile. - Before switching, persist the current profile to the user copy. - Prefer loading the user copy; fall back to the default copy. + Before switching, persist the current profile to the runtime copy. + Prefer loading the runtime copy; fall back to the baseline copy. Args: name (str | None): The name of the profile to load. If None, prompts the user. @@ -848,14 +847,14 @@ class BECDockArea(DockAreaWidget): if skip_pair and skip_pair == (prev_name, name): self._pending_autosave_skip = None 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) settings = None - if any(os.path.exists(path) for path in user_profile_candidates(name, namespace)): - settings = open_user_settings(name, namespace=namespace) - elif any(os.path.exists(path) for path in default_profile_candidates(name, namespace)): - settings = open_default_settings(name, namespace=namespace) + if any(os.path.exists(path) for path in runtime_profile_candidates(name, namespace)): + settings = open_runtime_settings(name, namespace=namespace) + elif any(os.path.exists(path) for path in baseline_profile_candidates(name, namespace)): + settings = open_baseline_settings(name, namespace=namespace) if settings is None: logger.warning(f"Profile '{name}' not found in namespace '{namespace}'. Creating new.") self.delete_all() @@ -897,9 +896,9 @@ class BECDockArea(DockAreaWidget): @SafeSlot() @SafeSlot(str) - def restore_user_profile_from_default(self, name: str | None = None): + def restore_runtime_profile_from_baseline(self, name: str | None = None): """ - 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. Args: @@ -916,13 +915,13 @@ class BECDockArea(DockAreaWidget): ba = bytes(self.screenshot_bytes()) current_pixmap.loadFromData(ba) if current_pixmap is None or current_pixmap.isNull(): - current_pixmap = load_user_profile_screenshot(target, namespace=namespace) - default_pixmap = load_default_profile_screenshot(target, namespace=namespace) + current_pixmap = load_runtime_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 - restore_user_from_default(target, namespace=namespace) + restore_runtime_from_baseline(target, namespace=namespace) self.delete_all() self.load_profile(target) @@ -1057,7 +1056,7 @@ class BECDockArea(DockAreaWidget): manage_action = self.toolbar.components.get_action("manage_workspaces").action if self.manage_dialog is None or not self.manage_dialog.isVisible(): 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) @@ -1156,7 +1155,7 @@ class BECDockArea(DockAreaWidget): return namespace = self.profile_namespace - settings = open_user_settings(name, namespace=namespace) + settings = open_runtime_settings(name, namespace=namespace) self._write_snapshot_to_settings(settings) set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) self._exit_snapshot_written = True diff --git a/bec_widgets/widgets/containers/dock_area/profile_utils.py b/bec_widgets/widgets/containers/dock_area/profile_utils.py index 4f080f34..396849d7 100644 --- a/bec_widgets/widgets/containers/dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/dock_area/profile_utils.py @@ -2,9 +2,13 @@ Utilities for managing BECDockArea profiles stored in INI files. Policy: -- All created/modified profiles are stored under the BEC settings root: /profiles/{default,user} -- Bundled read-only defaults are discovered in BW core states/default and plugin bec_widgets/profiles but never written to. -- Lookup order when reading: user → settings default → app or plugin bundled default. +- All created/modified profiles are stored under the BEC settings root: + /profiles/{baseline,runtime} +- 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 @@ -32,6 +36,12 @@ logger = bec_logger.logger MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) 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: @@ -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. Args: - segment (str): Either ``"user"`` or ``"default"``. + segment (str): Profile segment directory name. namespace (str | None): Optional namespace label to scope profiles. Returns: @@ -143,157 +153,175 @@ def _profiles_dir(segment: str, namespace: str | None) -> str: return path -def _user_path_candidates(name: str, namespace: str | None) -> list[str]: - """ - Generate candidate user-profile paths honoring namespace fallbacks. - - Args: - name (str): Profile name without extension. - namespace (str | None): Optional namespace label. - - Returns: - list[str]: Ordered list of candidate user profile paths (.ini files). - """ +def _candidate_namespaces(namespace: str | None) -> list[str | None]: ns = slugify.slugify(namespace, separator="_") if namespace else None - primary = os.path.join(_profiles_dir("user", ns), f"{name}.ini") if not ns: - return [primary] - legacy = os.path.join(_profiles_dir("user", None), f"{name}.ini") - return [primary, legacy] if legacy != primary else [primary] + return [None] + return [ns, None] -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: - name (str): Profile name without extension. - namespace (str | None): Optional namespace label. - - Returns: - list[str]: Ordered list of candidate default profile paths (.ini files). + Canonical baseline/runtime files are always preferred. Namespace fallback + files and legacy default/user files are copied to the primary canonical path + when the primary file does not exist. """ - ns = slugify.slugify(namespace, separator="_") if namespace else None - primary = os.path.join(_profiles_dir("default", ns), f"{name}.ini") - if not ns: - return [primary] - legacy = os.path.join(_profiles_dir("default", None), f"{name}.ini") - return [primary, legacy] if legacy != primary else [primary] + canonical = [ + _segment_profile_path(_PROFILE_SEGMENT_ALIASES[segment][0], name, ns) + for ns in _candidate_namespaces(namespace) + ] + legacy = [] + 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: namespace (str | None, optional): Namespace label. Defaults to ``None``. 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: namespace (str | None, optional): Namespace label. Defaults to ``None``. 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: name (str): Profile name without extension. namespace (str | None, optional): Namespace label. Defaults to ``None``. 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: name (str): Profile name without extension. namespace (str | None, optional): Namespace label. Defaults to ``None``. 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: name (str): Profile name without extension. namespace (str | None, optional): Namespace label. Defaults to ``None``. 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: name (str): Profile name without extension. namespace (str | None, optional): Namespace label. Defaults to ``None``. 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: name (str): Profile name without extension. namespace (str | None, optional): Namespace label to search. Defaults to ``None``. 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. """ - for path in user_profile_candidates(name, namespace): + for path in runtime_profile_candidates(name, namespace): if os.path.exists(path): return QSettings(path, QSettings.IniFormat) 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: name (str): Profile name without extension. namespace (str | None, optional): Namespace label to search. Defaults to ``None``. 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. """ - for path in default_profile_candidates(name, namespace): + for path in baseline_profile_candidates(name, namespace): if os.path.exists(path): return QSettings(path, QSettings.IniFormat) return None @@ -347,7 +375,7 @@ def profile_origin(name: str, namespace: str | None = None) -> ProfileOrigin: plugin_path = plugin_profile_path(name) if plugin_path and os.path.exists(plugin_path): 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 ): 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) removed = False - # Always allow removing user copies; keep default copies for read-only origins. - for path in set(user_profile_candidates(name, namespace)): + # Always allow removing runtime copies; keep baseline copies for read-only origins. + for path in set(runtime_profile_candidates(name, namespace)): try: os.remove(path) removed = True @@ -415,7 +443,7 @@ def delete_profile_files(name: str, namespace: str | None = None) -> bool: continue if not read_only: - for path in set(default_profile_candidates(name, namespace)): + for path in set(baseline_profile_candidates(name, namespace)): try: os.remove(path) removed = True @@ -443,7 +471,7 @@ SETTINGS_KEYS = { 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: 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 {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: - settings_dirs.add(default_profiles_dir(None)) - settings_dirs.add(user_profiles_dir(None)) + settings_dirs.add(baseline_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() for directory in settings_dirs: 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]] = {} sources: list[tuple[str, str | None]] = [ ("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))) for name, (_origin, src) in sorted(read_only_sources.items()): - # Ensure a copy in the namespace-specific settings default directory - dst_default = default_profile_path(name, namespace) - if not os.path.exists(dst_default): - os.makedirs(os.path.dirname(dst_default), exist_ok=True) - shutil.copyfile(src, dst_default) - # Ensure a user copy exists to allow edits in the writable settings area - dst_user = user_profile_path(name, namespace) - if not os.path.exists(dst_user): - os.makedirs(os.path.dirname(dst_user), exist_ok=True) - shutil.copyfile(src, dst_user) - s = open_user_settings(name, namespace) + # Ensure a copy in the namespace-specific settings baseline directory. + dst_baseline = baseline_profile_path(name, namespace) + if not os.path.exists(dst_baseline): + os.makedirs(os.path.dirname(dst_baseline), exist_ok=True) + shutil.copy2(src, dst_baseline) + # Ensure a runtime copy exists to allow edits in the writable settings area. + dst_runtime = runtime_profile_path(name, namespace) + if not os.path.exists(dst_runtime): + os.makedirs(os.path.dirname(dst_runtime), exist_ok=True) + shutil.copy2(src, dst_runtime) + s = open_runtime_settings(name, namespace) if s.value(SETTINGS_KEYS["created_at"], "") == "": 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) -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: name (str): Profile name without extension. namespace (str | None, optional): Namespace label. Defaults to ``None``. 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: name (str): Profile name without extension. namespace (str | None, optional): Namespace label. Defaults to ``None``. 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: @@ -759,26 +800,26 @@ def read_manifest(settings: QSettings) -> list[dict]: 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: name(str): Profile name without extension. namespace(str | None, optional): Namespace label. Defaults to ``None``. """ src = None - for candidate in default_profile_candidates(name, namespace): + for candidate in baseline_profile_candidates(name, namespace): if os.path.exists(candidate): src = candidate break if not src: return - dst = user_profile_path(name, namespace) + dst = runtime_profile_path(name, namespace) preserve_quick_select = is_quick_select(name, namespace) os.makedirs(os.path.dirname(dst), exist_ok=True) shutil.copyfile(src, dst) - s = open_user_settings(name, namespace) + s = open_runtime_settings(name, namespace) if not s.value(SETTINGS_KEYS["created_at"], ""): s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) if preserve_quick_select: @@ -796,9 +837,9 @@ def is_quick_select(name: str, namespace: str | None = None) -> bool: Returns: 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: - s = _existing_default_settings(name, namespace) + s = _existing_baseline_settings(name, namespace) if s is None: return False 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. 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)) 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: namespace(str | None, optional): Namespace label. Defaults to ``None``. @@ -909,8 +950,8 @@ class ProfileInfo(BaseModel): is_quick_select: bool = False widget_count: int = 0 size_kb: int = 0 - user_path: str = "" - default_path: str = "" + runtime_path: str = "" + baseline_path: str = "" origin: ProfileOrigin = "unknown" 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``. 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) - default_paths = default_profile_candidates(name, namespace) - u_path = next((p for p in user_paths if os.path.exists(p)), user_paths[0]) - d_path = next((p for p in default_paths if os.path.exists(p)), default_paths[0]) + runtime_paths = runtime_profile_candidates(name, namespace) + baseline_paths = baseline_profile_candidates(name, namespace) + r_path = next((p for p in runtime_paths if os.path.exists(p)), runtime_paths[0]) + b_path = next((p for p in baseline_paths if os.path.exists(p)), baseline_paths[0]) origin = profile_origin(name, namespace) read_only = origin in {"module", "plugin"} - prefer_user = os.path.exists(u_path) - if prefer_user: - s = QSettings(u_path, QSettings.IniFormat) - elif os.path.exists(d_path): - s = QSettings(d_path, QSettings.IniFormat) + prefer_runtime = os.path.exists(r_path) + if prefer_runtime: + s = QSettings(r_path, QSettings.IniFormat) + elif os.path.exists(b_path): + s = QSettings(b_path, QSettings.IniFormat) else: s = None if s is None: @@ -957,14 +998,14 @@ def get_profile_info(name: str, namespace: str | None = None) -> ProfileInfo: is_quick_select=False, widget_count=0, size_kb=0, - user_path=u_path, - default_path=d_path, + runtime_path=r_path, + baseline_path=b_path, origin=origin, is_read_only=read_only, ) 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) count = _manifest_count(s) try: @@ -990,8 +1031,8 @@ def get_profile_info(name: str, namespace: str | None = None) -> ProfileInfo: is_quick_select=is_quick_select(name, namespace), widget_count=count, size_kb=size_kb, - user_path=u_path, - default_path=d_path, + runtime_path=r_path, + baseline_path=b_path, origin=origin, 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: """ - 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: name (str): Profile name without extension. @@ -1008,17 +1049,17 @@ def load_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap Returns: QPixmap | None: Screenshot pixmap or ``None`` if unavailable. """ - s = _existing_user_settings(name, namespace) + s = _existing_runtime_settings(name, namespace) if s is None: - s = _existing_default_settings(name, namespace) + s = _existing_baseline_settings(name, namespace) if s is None: return None 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: name (str): Profile name without extension. @@ -1027,15 +1068,15 @@ def load_default_profile_screenshot(name: str, namespace: str | None = None) -> Returns: QPixmap | None: Screenshot pixmap or ``None`` if unavailable. """ - s = _existing_default_settings(name, namespace) + s = _existing_baseline_settings(name, namespace) if s is None: return None 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: name (str): Profile name without extension. @@ -1044,7 +1085,7 @@ def load_user_profile_screenshot(name: str, namespace: str | None = None) -> QPi Returns: QPixmap | None: Screenshot pixmap or ``None`` if unavailable. """ - s = _existing_user_settings(name, namespace) + s = _existing_runtime_settings(name, namespace) if s is None: return None return _load_screenshot_from_settings(s) diff --git a/bec_widgets/widgets/containers/dock_area/settings/dialogs.py b/bec_widgets/widgets/containers/dock_area/settings/dialogs.py index 19329c34..3b07ce9a 100644 --- a/bec_widgets/widgets/containers/dock_area/settings/dialogs.py +++ b/bec_widgets/widgets/containers/dock_area/settings/dialogs.py @@ -160,7 +160,7 @@ class SaveProfileDialog(QDialog): self, "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." ), ) @@ -179,7 +179,7 @@ class SaveProfileDialog(QDialog): "Overwrite profile", ( 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?" ), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, @@ -257,21 +257,24 @@ class PreviewPanel(QGroupBox): 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__( - 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) - self.setWindowTitle("Restore Profile to Default") + self.setWindowTitle("Restore Profile to Baseline") self.setModal(True) self.resize(880, 480) layout = QVBoxLayout(self) 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) layout.addWidget(info_label) @@ -280,7 +283,7 @@ class RestoreProfileDialog(QDialog): layout.addLayout(preview_row) 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 preview_row.addWidget(current_preview, 1) @@ -292,7 +295,7 @@ class RestoreProfileDialog(QDialog): arrow_label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) preview_row.addWidget(arrow_label) - preview_row.addWidget(default_preview, 1) + preview_row.addWidget(baseline_preview, 1) # Enforce equal stretch for both previews preview_row.setStretch(0, 1) @@ -300,7 +303,7 @@ class RestoreProfileDialog(QDialog): preview_row.setStretch(2, 1) 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) layout.addWidget(warn_label) @@ -324,7 +327,7 @@ class RestoreProfileDialog(QDialog): @staticmethod 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: - dialog = RestoreProfileDialog(parent, current_pixmap, default_pixmap) + dialog = RestoreProfileDialog(parent, current_pixmap, baseline_pixmap) return dialog.exec() == QDialog.Accepted diff --git a/bec_widgets/widgets/containers/dock_area/settings/workspace_manager.py b/bec_widgets/widgets/containers/dock_area/settings/workspace_manager.py index eaf39ee1..3b37cec3 100644 --- a/bec_widgets/widgets/containers/dock_area/settings/workspace_manager.py +++ b/bec_widgets/widgets/containers/dock_area/settings/workspace_manager.py @@ -48,9 +48,16 @@ class WorkSpaceManager(BECWidget, QWidget): HEADERS = ["Actions", "Profile", "Author"] 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, + default_profile: str | None = None, + **kwargs, ): super().__init__(parent=parent, **kwargs) + if active_profile is None: + active_profile = default_profile self.target_widget = target_widget self.profile_namespace = ( getattr(target_widget, "profile_namespace", None) if target_widget else None @@ -59,9 +66,9 @@ class WorkSpaceManager(BECWidget, QWidget): self._init_ui() if self.target_widget is not None and hasattr(self.target_widget, "profile_changed"): self.target_widget.profile_changed.connect(self.on_profile_changed) - if default_profile is not None: - self._select_by_name(default_profile) - self._show_profile_details(default_profile) + if active_profile is not None: + self._select_by_name(active_profile) + self._show_profile_details(active_profile) def _init_ui(self): self.root_layout = QHBoxLayout(self) @@ -250,8 +257,8 @@ class WorkSpaceManager(BECWidget, QWidget): ("Quick select", "Yes" if info.is_quick_select else "No"), ("Widgets", str(info.widget_count)), ("Size (KB)", str(info.size_kb)), - ("User path", info.user_path or ""), - ("Default path", info.default_path or ""), + ("Runtime path", info.runtime_path or ""), + ("Baseline path", info.baseline_path or ""), ] for k, v in entries: self.profile_details_tree.addTopLevelItem(QTreeWidgetItem([k, v])) diff --git a/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py b/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py index 114d6856..a7f49b5c 100644 --- a/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py +++ b/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py @@ -145,15 +145,15 @@ def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) - components.get_action("save_workspace").action.setVisible(enable_tools) components.add_safe( - "reset_default_workspace", + "reset_baseline_workspace", MaterialIconAction( icon_name="undo", - tooltip="Refresh Current Workspace", + tooltip="Restore Baseline Profile", checkable=False, 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( "manage_workspaces", @@ -166,7 +166,7 @@ def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) - bundle = ToolbarBundle("workspace", components) bundle.add_action("workspace_combo") bundle.add_action("save_workspace") - bundle.add_action("reset_default_workspace") + bundle.add_action("reset_baseline_workspace") bundle.add_action("manage_workspaces") return bundle @@ -196,9 +196,9 @@ class WorkspaceConnection(BundleConnection): 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(): - 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 if manage_action.isVisible(): @@ -215,9 +215,9 @@ class WorkspaceConnection(BundleConnection): 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(): - 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 if manage_action.isVisible(): @@ -225,8 +225,8 @@ class WorkspaceConnection(BundleConnection): self._connected = False @SafeSlot() - def _reset_workspace_to_default(self): + def _reset_workspace_to_baseline(self): """ Refreshes the current workspace. """ - self.target_widget.restore_user_profile_from_default() + self.target_widget.restore_runtime_profile_from_baseline() diff --git a/tests/unit_tests/test_dock_area.py b/tests/unit_tests/test_dock_area.py index 92fd076c..7701cc17 100644 --- a/tests/unit_tests/test_dock_area.py +++ b/tests/unit_tests/test_dock_area.py @@ -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.profile_utils import ( SETTINGS_KEYS, - default_profile_path, + baseline_profile_path, get_profile_info, is_profile_read_only, is_quick_select, list_profiles, - load_default_profile_screenshot, - load_user_profile_screenshot, - open_default_settings, - open_user_settings, + load_baseline_profile_screenshot, + load_runtime_profile_screenshot, + open_baseline_settings, + open_runtime_settings, read_manifest, - restore_user_from_default, + restore_runtime_from_baseline, + runtime_profile_path, set_quick_select, - user_profile_path, write_manifest, ) from bec_widgets.widgets.containers.dock_area.settings.dialogs import ( @@ -188,17 +188,17 @@ class _NamespaceProfiles: def __init__(self, widget: BECDockArea): self.namespace = widget.profile_namespace - def open_user(self, name: str): - return open_user_settings(name, namespace=self.namespace) + def open_runtime(self, name: str): + return open_runtime_settings(name, namespace=self.namespace) - def open_default(self, name: str): - return open_default_settings(name, namespace=self.namespace) + def open_baseline(self, name: str): + return open_baseline_settings(name, namespace=self.namespace) - def user_path(self, name: str) -> str: - return user_profile_path(name, namespace=self.namespace) + def runtime_path(self, name: str) -> str: + return runtime_profile_path(name, namespace=self.namespace) - def default_path(self, name: str) -> str: - return default_profile_path(name, namespace=self.namespace) + def baseline_path(self, name: str) -> str: + return baseline_profile_path(name, namespace=self.namespace) def list_profiles(self) -> list[str]: return list_profiles(namespace=self.namespace) @@ -946,7 +946,7 @@ class TestToolbarFunctionality: def test_load_profile_restores_floating_dock(self, advanced_dock_area, qtbot): helper = profile_helper(advanced_dock_area) - settings = helper.open_user("floating_profile") + settings = helper.open_runtime("floating_profile") settings.clear() settings.setValue("profile/created_at", "2025-11-23T00:00:00Z") @@ -1246,9 +1246,9 @@ class TestProfileInfoAndScreenshots: settings.endArray() settings.sync() - def test_get_profile_info_user_origin(self, temp_profile_dir): - name = "info_user" - settings = open_user_settings(name) + def test_get_profile_info_runtime_origin(self, temp_profile_dir): + name = "info_runtime" + settings = open_runtime_settings(name) settings.setValue(profile_utils.SETTINGS_KEYS["created_at"], "2023-01-01T00:00:00Z") settings.setValue("profile/author", "Custom") set_quick_select(name, True) @@ -1262,22 +1262,22 @@ class TestProfileInfoAndScreenshots: assert info.is_quick_select is True assert info.widget_count == 3 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 - def test_get_profile_info_default_only(self, temp_profile_dir): - name = "info_default" - settings = open_default_settings(name) + def test_get_profile_info_baseline_only(self, temp_profile_dir): + name = "info_baseline" + settings = open_baseline_settings(name) self._write_manifest(settings, count=1) - user_path = user_profile_path(name) - if os.path.exists(user_path): - os.remove(user_path) + runtime_path = runtime_profile_path(name) + if os.path.exists(runtime_path): + os.remove(runtime_path) info = get_profile_info(name) 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 def test_get_profile_info_module_readonly(self, module_profile_factory): @@ -1289,10 +1289,10 @@ class TestProfileInfoAndScreenshots: def test_get_profile_info_unknown_profile(self): name = "nonexistent_profile" - if os.path.exists(user_profile_path(name)): - os.remove(user_profile_path(name)) - if os.path.exists(default_profile_path(name)): - os.remove(default_profile_path(name)) + if os.path.exists(runtime_profile_path(name)): + os.remove(runtime_profile_path(name)) + if os.path.exists(baseline_profile_path(name)): + os.remove(baseline_profile_path(name)) info = get_profile_info(name) @@ -1300,29 +1300,29 @@ class TestProfileInfoAndScreenshots: assert info.is_read_only is False assert info.widget_count == 0 - def test_load_user_profile_screenshot(self, temp_profile_dir): - name = "user_screenshot" - settings = open_user_settings(name) + def test_load_runtime_profile_screenshot(self, temp_profile_dir): + name = "runtime_screenshot" + settings = open_runtime_settings(name) settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], self.PNG_BYTES) settings.sync() - pix = load_user_profile_screenshot(name) + pix = load_runtime_profile_screenshot(name) assert pix is not None and not pix.isNull() - def test_load_default_profile_screenshot(self, temp_profile_dir): - name = "default_screenshot" - settings = open_default_settings(name) + def test_load_baseline_profile_screenshot(self, temp_profile_dir): + name = "baseline_screenshot" + settings = open_baseline_settings(name) settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], self.PNG_BYTES) settings.sync() - pix = load_default_profile_screenshot(name) + pix = load_baseline_profile_screenshot(name) assert pix is not None and not pix.isNull() def test_load_screenshot_from_settings_invalid(self, temp_profile_dir): name = "invalid_screenshot" - settings = open_user_settings(name) + settings = open_runtime_settings(name) settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], "not-an-image") settings.sync() @@ -1332,7 +1332,7 @@ class TestProfileInfoAndScreenshots: def test_load_screenshot_from_settings_bytes(self, temp_profile_dir): name = "bytes_screenshot" - settings = open_user_settings(name) + settings = open_runtime_settings(name) settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], self.PNG_BYTES) settings.sync() @@ -1347,7 +1347,7 @@ class TestWorkSpaceManager: @staticmethod def _create_profiles(names): for name in names: - settings = open_user_settings(name) + settings = open_runtime_settings(name) settings.setValue("meta", "value") settings.sync() @@ -1411,7 +1411,7 @@ class TestWorkSpaceManager: 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 def test_delete_readonly_profile_shows_message( @@ -1441,21 +1441,23 @@ class TestWorkSpaceManager: class TestAdvancedDockAreaRestoreAndDialogs: """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" helper = profile_helper(advanced_dock_area) - helper.open_default(profile_name).sync() - helper.open_user(profile_name).sync() + helper.open_baseline(profile_name).sync() + helper.open_runtime(profile_name).sync() advanced_dock_area._current_profile_name = profile_name advanced_dock_area.isVisible = lambda: False pix = QPixmap(8, 8) pix.fill(Qt.red) 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, ) 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, ) monkeypatch.setattr( @@ -1465,12 +1467,12 @@ class TestAdvancedDockAreaRestoreAndDialogs: 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, 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_user_profile_from_default() + advanced_dock_area.restore_runtime_profile_from_baseline() assert mock_restore.call_count == 1 args, kwargs = mock_restore.call_args @@ -1479,20 +1481,22 @@ class TestAdvancedDockAreaRestoreAndDialogs: mock_delete_all.assert_called_once() 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" helper = profile_helper(advanced_dock_area) - helper.open_default(profile_name).sync() - helper.open_user(profile_name).sync() + helper.open_baseline(profile_name).sync() + helper.open_runtime(profile_name).sync() advanced_dock_area._current_profile_name = profile_name advanced_dock_area.isVisible = lambda: False monkeypatch.setattr( - "bec_widgets.widgets.containers.dock_area.dock_area.load_user_profile_screenshot", - lambda name: QPixmap(), + "bec_widgets.widgets.containers.dock_area.dock_area.load_runtime_profile_screenshot", + lambda name, namespace=None: QPixmap(), ) monkeypatch.setattr( - "bec_widgets.widgets.containers.dock_area.dock_area.load_default_profile_screenshot", - lambda name: QPixmap(), + "bec_widgets.widgets.containers.dock_area.dock_area.load_baseline_profile_screenshot", + lambda name, namespace=None: QPixmap(), ) monkeypatch.setattr( "bec_widgets.widgets.containers.dock_area.dock_area.RestoreProfileDialog.confirm", @@ -1500,24 +1504,24 @@ class TestAdvancedDockAreaRestoreAndDialogs: ) 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: - advanced_dock_area.restore_user_profile_from_default() + advanced_dock_area.restore_runtime_profile_from_baseline() 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_no_target(self, advanced_dock_area, monkeypatch): advanced_dock_area._current_profile_name = None with patch( "bec_widgets.widgets.containers.dock_area.dock_area.RestoreProfileDialog.confirm" ) as mock_confirm: - advanced_dock_area.restore_user_profile_from_default() + advanced_dock_area.restore_runtime_profile_from_baseline() mock_confirm.assert_not_called() def test_refresh_workspace_list_with_refresh_profiles(self, advanced_dock_area): profile_name = "refresh_profile" 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). advanced_dock_area._empty_profile_active = False advanced_dock_area._current_profile_name = profile_name @@ -1572,8 +1576,8 @@ class TestAdvancedDockAreaRestoreAndDialogs: active = "active_profile" quick = "quick_profile" helper = profile_helper(advanced_dock_area) - helper.open_user(active).sync() - helper.open_user(quick).sync() + helper.open_runtime(active).sync() + helper.open_runtime(quick).sync() helper.set_quick_select(quick, True) combo_stub = ComboStub() @@ -1600,7 +1604,7 @@ class TestAdvancedDockAreaRestoreAndDialogs: advanced_dock_area._current_profile_name = "manager_profile" helper = profile_helper(advanced_dock_area) - helper.open_user("manager_profile").sync() + helper.open_runtime("manager_profile").sync() advanced_dock_area.show_workspace_manager() @@ -1635,17 +1639,108 @@ class TestProfileManagement: def test_profile_path(self, temp_profile_dir): """Test profile path generation.""" - path = user_profile_path("test_profile") - expected = os.path.join(temp_profile_dir, "user", "test_profile.ini") + path = runtime_profile_path("test_profile") + expected = os.path.join(temp_profile_dir, "runtime", "test_profile.ini") assert path == expected - default_path = default_profile_path("test_profile") - expected_default = os.path.join(temp_profile_dir, "default", "test_profile.ini") - assert default_path == expected_default + baseline_path = baseline_profile_path("test_profile") + expected_baseline = os.path.join(temp_profile_dir, "baseline", "test_profile.ini") + assert baseline_path == expected_baseline + + def test_legacy_user_profile_is_mapped_to_runtime(self, temp_profile_dir): + """Legacy user profiles are copied into the canonical runtime segment.""" + name = "legacy_runtime" + 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_open_settings(self, temp_profile_dir): """Test opening settings for a profile.""" - settings = open_user_settings("test_profile") + settings = open_runtime_settings("test_profile") assert isinstance(settings, QSettings) def test_list_profiles_empty(self, temp_profile_dir): @@ -1666,7 +1761,7 @@ class TestProfileManagement: # Create some test profile files profile_names = ["profile1", "profile2", "profile3"] for name in profile_names: - settings = open_user_settings(name) + settings = open_runtime_settings(name) settings.setValue("test", "value") settings.sync() @@ -1676,24 +1771,24 @@ class TestProfileManagement: def test_readonly_profile_operations(self, temp_profile_dir, module_profile_factory): """Test read-only profile functionality.""" - profile_name = "user_profile" + profile_name = "runtime_profile" # Initially should not be read-only assert not is_profile_read_only(profile_name) - # Create a user profile and ensure it's writable - settings = open_user_settings(profile_name) + # Create a runtime profile and ensure it's writable + settings = open_runtime_settings(profile_name) settings.setValue("test", "value") settings.sync() assert not is_profile_read_only(profile_name) # 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) def test_write_and_read_manifest(self, temp_profile_dir, advanced_dock_area, qtbot): """Test writing and reading dock manifest.""" - settings = open_user_settings("test_manifest") + settings = open_runtime_settings("test_manifest") # Create real docks advanced_dock_area.new("DarkModeButton") @@ -1723,18 +1818,18 @@ class TestProfileManagement: def test_restore_preserves_quick_select(self, temp_profile_dir): """Ensure restoring keeps the quick select flag when it was enabled.""" profile_name = "restorable_profile" - default_settings = open_default_settings(profile_name) - default_settings.setValue("test", "default") - default_settings.sync() + baseline_settings = open_baseline_settings(profile_name) + baseline_settings.setValue("test", "baseline") + baseline_settings.sync() - user_settings = open_user_settings(profile_name) - user_settings.setValue("test", "user") - user_settings.sync() + runtime_settings = open_runtime_settings(profile_name) + runtime_settings.setValue("test", "runtime") + runtime_settings.sync() set_quick_select(profile_name, True) assert is_quick_select(profile_name) - restore_user_from_default(profile_name) + restore_runtime_from_baseline(profile_name) assert is_quick_select(profile_name) @@ -1758,7 +1853,7 @@ class TestWorkspaceProfileOperations: widget.prepare_for_shutdown() mock_write.assert_not_called() - helper.open_user("real_profile").sync() + helper.open_runtime("real_profile").sync() widget.load_profile("real_profile") assert widget._empty_profile_active is False assert widget._empty_profile_consumed is True @@ -1772,7 +1867,7 @@ class TestWorkspaceProfileOperations: profile_name = module_profile_factory("readonly_profile") new_profile = f"{profile_name}_custom" 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): os.remove(target_path) @@ -1802,7 +1897,7 @@ class TestWorkspaceProfileOperations: helper = profile_helper(advanced_dock_area) # Create a profile with manifest - settings = helper.open_user(profile_name) + settings = helper.open_runtime(profile_name) settings.beginWriteArray("manifest/widgets", 1) settings.setArrayIndex(0) settings.setValue("object_name", "test_widget") @@ -1823,6 +1918,46 @@ class TestWorkspaceProfileOperations: widget_map = advanced_dock_area.widget_map() assert "test_widget" in widget_map + 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( self, advanced_dock_area, temp_profile_dir, qtbot ): @@ -1831,7 +1966,7 @@ class TestWorkspaceProfileOperations: new_profile = "autosave_new" helper = profile_helper(advanced_dock_area) - settings = helper.open_user(source_profile) + settings = helper.open_runtime(source_profile) settings.beginWriteArray("manifest/widgets", 1) settings.setArrayIndex(0) settings.setValue("object_name", "source_widget") @@ -1871,8 +2006,8 @@ class TestWorkspaceProfileOperations: qtbot.wait(500) assert list(advanced_dock_area.widget_list()) == widgets_before_save - source_manifest = read_manifest(helper.open_user(source_profile)) - new_manifest = read_manifest(helper.open_user(new_profile)) + source_manifest = read_manifest(helper.open_runtime(source_profile)) + new_manifest = read_manifest(helper.open_runtime(new_profile)) assert len(source_manifest) == 1 assert len(new_manifest) == 2 @@ -1884,7 +2019,7 @@ class TestWorkspaceProfileOperations: helper = profile_helper(advanced_dock_area) for profile in (profile_a, profile_b): - settings = helper.open_user(profile) + settings = helper.open_runtime(profile) settings.beginWriteArray("manifest/widgets", 1) settings.setArrayIndex(0) settings.setValue("object_name", f"{profile}_widget") @@ -1903,7 +2038,7 @@ class TestWorkspaceProfileOperations: advanced_dock_area.load_profile(profile_b) 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 def test_delete_profile_readonly( @@ -1912,15 +2047,15 @@ class TestWorkspaceProfileOperations: """Test deleting bundled profile removes only the writable copy.""" profile_name = module_profile_factory("readonly_profile") helper = profile_helper(advanced_dock_area) - helper.list_profiles() # ensure default and user copies are materialized - helper.open_default(profile_name).sync() - settings = helper.open_user(profile_name) + helper.list_profiles() # ensure baseline and runtime copies are materialized + helper.open_baseline(profile_name).sync() + settings = helper.open_runtime(profile_name) settings.setValue("test", "value") settings.sync() - user_path = helper.user_path(profile_name) - default_path = helper.default_path(profile_name) - assert os.path.exists(user_path) - assert os.path.exists(default_path) + runtime_path = helper.runtime_path(profile_name) + baseline_path = helper.baseline_path(profile_name) + assert os.path.exists(runtime_path) + assert os.path.exists(baseline_path) with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action: mock_combo = MagicMock() @@ -1941,9 +2076,9 @@ class TestWorkspaceProfileOperations: mock_question.assert_not_called() mock_info.assert_called_once() - # Read-only profile should remain intact (user + default copies) - assert os.path.exists(user_path) - assert os.path.exists(default_path) + # Read-only profile should remain intact (runtime + baseline copies) + assert os.path.exists(runtime_path) + assert os.path.exists(baseline_path) def test_delete_profile_success(self, advanced_dock_area, temp_profile_dir): """Test successful profile deletion.""" @@ -1951,11 +2086,11 @@ class TestWorkspaceProfileOperations: helper = profile_helper(advanced_dock_area) # Create regular profile - settings = helper.open_user(profile_name) + settings = helper.open_runtime(profile_name) settings.setValue("test", "value") settings.sync() - user_path = helper.user_path(profile_name) - assert os.path.exists(user_path) + runtime_path = helper.runtime_path(profile_name) + assert os.path.exists(runtime_path) with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action: mock_combo = MagicMock() @@ -1973,7 +2108,7 @@ class TestWorkspaceProfileOperations: mock_question.assert_called_once() mock_refresh.assert_called_once() # 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): """Test delete_profile with explicit name (CLI usage - no dialog by default).""" @@ -1981,24 +2116,24 @@ class TestWorkspaceProfileOperations: helper = profile_helper(advanced_dock_area) # Create regular profile - settings = helper.open_user(profile_name) + settings = helper.open_runtime(profile_name) settings.setValue("test", "value") settings.sync() - user_path = helper.user_path(profile_name) - assert os.path.exists(user_path) + runtime_path = helper.runtime_path(profile_name) + assert os.path.exists(runtime_path) # Delete without dialog (CLI usage - default behavior) result = advanced_dock_area.delete_profile(profile_name) 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): """Test refreshing workspace list.""" # Create some profiles helper = profile_helper(advanced_dock_area) for name in ["profile1", "profile2"]: - settings = helper.open_user(name) + settings = helper.open_runtime(name) settings.setValue("test", "value") settings.sync()