Compare commits

..

20 Commits

Author SHA1 Message Date
semantic-release 1057db9d76 3.9.1
Automatically generated by python-semantic-release
2026-05-12 17:46:15 +00:00
wyzula_j be35e249f9 wip further opt 2026-05-12 19:45:22 +02:00
wyzula_j cdd833dfc2 tests(scan_control): tests extended and optimized 2026-05-12 19:45:22 +02:00
wyzula_j 3c7834b492 fix: logpanel fixture overwriting xread 2026-05-12 19:45:22 +02:00
wyzula_j acd35a2786 fix(scan_control): restore scan parameters from history are fetched on demand with button 2026-05-12 19:45:22 +02:00
semantic-release 108b249f1d 3.9.0
Automatically generated by python-semantic-release
2026-05-12 11:49:32 +00:00
wyzula_j 085f9fa271 fix: test bw-generate-cli 2026-05-12 13:48:43 +02:00
wyzula_j 79931faf55 fix(dock_area): icon fetching for toolbar import optimised 2026-05-12 13:48:43 +02:00
wyzula_j 6b3cebe9cb fix(jupyter_console_widget): widget_handler API fix 2026-05-12 13:48:43 +02:00
wakonig_k 5cc82425f0 feat: move to lazy widget import 2026-05-12 13:48:43 +02:00
wakonig_k bb1544ecb7 test: fix available scans endpoint operation 2026-05-11 13:08:04 +02:00
semantic-release 8ad0e46d98 3.8.1
Automatically generated by python-semantic-release
2026-05-11 09:36:33 +00:00
wakonig_k 9d92f8b53a fix(web_links): update documentation links in BECWebLinksMixin 2026-05-11 11:35:46 +02:00
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
28 changed files with 1322 additions and 822 deletions
+72
View File
@@ -1,6 +1,78 @@
# CHANGELOG # CHANGELOG
## v3.9.1 (2026-05-12)
### Bug Fixes
- Logpanel fixture overwriting xread
([`3c7834b`](https://github.com/bec-project/bec_widgets/commit/3c7834b492a5d2da13689f58b20caf38dda9ac1d))
- **scan_control**: Restore scan parameters from history are fetched on demand with button
([`acd35a2`](https://github.com/bec-project/bec_widgets/commit/acd35a278660ce4962167af6237b5d12007f0774))
## v3.9.0 (2026-05-12)
### Bug Fixes
- Test bw-generate-cli
([`085f9fa`](https://github.com/bec-project/bec_widgets/commit/085f9fa271a0a8e339bff83f235011ac4a9d29ea))
- **dock_area**: Icon fetching for toolbar import optimised
([`79931fa`](https://github.com/bec-project/bec_widgets/commit/79931faf554fd0978c54d6562aa1b5fc4ab823b2))
- **jupyter_console_widget**: Widget_handler API fix
([`6b3cebe`](https://github.com/bec-project/bec_widgets/commit/6b3cebe9cbdb5c02ae2aa14b0f624a51c9c2ca4c))
### Features
- Move to lazy widget import
([`5cc8242`](https://github.com/bec-project/bec_widgets/commit/5cc82425f07d76e881ae59a121a3af77f227bfee))
### Testing
- Fix available scans endpoint operation
([`bb1544e`](https://github.com/bec-project/bec_widgets/commit/bb1544ecb70612267e2b03ba041c6f656789d63c))
## v3.8.1 (2026-05-11)
### Bug Fixes
- **web_links**: Update documentation links in BECWebLinksMixin
([`9d92f8b`](https://github.com/bec-project/bec_widgets/commit/9d92f8b53a6ffe57a9dffad797580228023bf6e1))
## 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
+171
View File
@@ -0,0 +1,171 @@
# This file was automatically generated by generate_cli.py
# type: ignore
from __future__ import annotations
# pylint: skip-file
designer_plugins = {
"AbortButton": ("bec_widgets.widgets.control.buttons.button_abort.button_abort", "AbortButton"),
"BECColorMapWidget": (
"bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget",
"BECColorMapWidget",
),
"BECMainWindow": ("bec_widgets.widgets.containers.main_window.main_window", "BECMainWindow"),
"BECProgressBar": (
"bec_widgets.widgets.progress.bec_progressbar.bec_progressbar",
"BECProgressBar",
),
"BECQueue": ("bec_widgets.widgets.services.bec_queue.bec_queue", "BECQueue"),
"BECShell": ("bec_widgets.widgets.editors.bec_console.bec_console", "BECShell"),
"BECSpinBox": ("bec_widgets.widgets.utility.spinbox.decimal_spinbox", "BECSpinBox"),
"BECStatusBox": ("bec_widgets.widgets.services.bec_status_box.bec_status_box", "BECStatusBox"),
"BecConsole": ("bec_widgets.widgets.editors.bec_console.bec_console", "BecConsole"),
"ColorButton": ("bec_widgets.widgets.utility.visual.color_button.color_button", "ColorButton"),
"ColorButtonNative": (
"bec_widgets.widgets.utility.visual.color_button_native.color_button_native",
"ColorButtonNative",
),
"ColormapSelector": (
"bec_widgets.widgets.utility.visual.colormap_selector.colormap_selector",
"ColormapSelector",
),
"DapComboBox": ("bec_widgets.widgets.dap.dap_combo_box.dap_combo_box", "DapComboBox"),
"DarkModeButton": (
"bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button",
"DarkModeButton",
),
"DeviceBrowser": (
"bec_widgets.widgets.services.device_browser.device_browser",
"DeviceBrowser",
),
"DeviceComboBox": (
"bec_widgets.widgets.control.device_input.device_combobox.device_combobox",
"DeviceComboBox",
),
"DeviceLineEdit": (
"bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit",
"DeviceLineEdit",
),
"Heatmap": ("bec_widgets.widgets.plots.heatmap.heatmap", "Heatmap"),
"IDEExplorer": ("bec_widgets.widgets.utility.ide_explorer.ide_explorer", "IDEExplorer"),
"Image": ("bec_widgets.widgets.plots.image.image", "Image"),
"LMFitDialog": ("bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog", "LMFitDialog"),
"LogPanel": ("bec_widgets.widgets.utility.logpanel.logpanel", "LogPanel"),
"Minesweeper": ("bec_widgets.widgets.games.minesweeper", "Minesweeper"),
"MonacoWidget": ("bec_widgets.widgets.editors.monaco.monaco_widget", "MonacoWidget"),
"MotorMap": ("bec_widgets.widgets.plots.motor_map.motor_map", "MotorMap"),
"MultiWaveform": ("bec_widgets.widgets.plots.multi_waveform.multi_waveform", "MultiWaveform"),
"PdfViewerWidget": ("bec_widgets.widgets.utility.pdf_viewer.pdf_viewer", "PdfViewerWidget"),
"PositionIndicator": (
"bec_widgets.widgets.control.device_control.position_indicator.position_indicator",
"PositionIndicator",
),
"PositionerBox": (
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box",
"PositionerBox",
),
"PositionerBox2D": (
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d",
"PositionerBox2D",
),
"PositionerControlLine": (
"bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line",
"PositionerControlLine",
),
"PositionerGroup": (
"bec_widgets.widgets.control.device_control.positioner_group.positioner_group",
"PositionerGroup",
),
"ResetButton": ("bec_widgets.widgets.control.buttons.button_reset.button_reset", "ResetButton"),
"ResumeButton": (
"bec_widgets.widgets.control.buttons.button_resume.button_resume",
"ResumeButton",
),
"RingProgressBar": (
"bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar",
"RingProgressBar",
),
"SBBMonitor": ("bec_widgets.widgets.editors.sbb_monitor.sbb_monitor", "SBBMonitor"),
"ScanControl": ("bec_widgets.widgets.control.scan_control.scan_control", "ScanControl"),
"ScanMetadata": ("bec_widgets.widgets.editors.scan_metadata.scan_metadata", "ScanMetadata"),
"ScanProgressBar": (
"bec_widgets.widgets.progress.scan_progressbar.scan_progressbar",
"ScanProgressBar",
),
"ScatterWaveform": (
"bec_widgets.widgets.plots.scatter_waveform.scatter_waveform",
"ScatterWaveform",
),
"SignalComboBox": (
"bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox",
"SignalComboBox",
),
"SignalLabel": ("bec_widgets.widgets.utility.signal_label.signal_label", "SignalLabel"),
"SignalLineEdit": (
"bec_widgets.widgets.control.device_input.signal_line_edit.signal_line_edit",
"SignalLineEdit",
),
"SpinnerWidget": ("bec_widgets.widgets.utility.spinner.spinner", "SpinnerWidget"),
"StopButton": ("bec_widgets.widgets.control.buttons.stop_button.stop_button", "StopButton"),
"TextBox": ("bec_widgets.widgets.editors.text_box.text_box", "TextBox"),
"ToggleSwitch": ("bec_widgets.widgets.utility.toggle.toggle", "ToggleSwitch"),
"Waveform": ("bec_widgets.widgets.plots.waveform.waveform", "Waveform"),
"WebsiteWidget": ("bec_widgets.widgets.editors.website.website", "WebsiteWidget"),
"WidgetFinderComboBox": (
"bec_widgets.widgets.utility.widget_finder.widget_finder",
"WidgetFinderComboBox",
),
}
widget_icons = {
"AbortButton": "cancel",
"BECColorMapWidget": "palette",
"BECMainWindow": "widgets",
"BECProgressBar": "page_control",
"BECQueue": "edit_note",
"BECShell": "hub",
"BECSpinBox": "123",
"BECStatusBox": "widgets",
"BecConsole": "terminal",
"ColorButton": "colors",
"ColorButtonNative": "colors",
"ColormapSelector": "palette",
"DapComboBox": "data_exploration",
"DarkModeButton": "dark_mode",
"DeviceBrowser": "lists",
"DeviceComboBox": "list_alt",
"DeviceLineEdit": "edit_note",
"Heatmap": "dataset",
"IDEExplorer": "widgets",
"Image": "image",
"LMFitDialog": "monitoring",
"LogPanel": "browse_activity",
"Minesweeper": "videogame_asset",
"MonacoWidget": "code",
"MotorMap": "my_location",
"MultiWaveform": "ssid_chart",
"PdfViewerWidget": "picture_as_pdf",
"PositionIndicator": "horizontal_distribute",
"PositionerBox": "switch_right",
"PositionerBox2D": "switch_right",
"PositionerControlLine": "switch_left",
"PositionerGroup": "grid_view",
"ResetButton": "restart_alt",
"ResumeButton": "resume",
"RingProgressBar": "track_changes",
"SBBMonitor": "train",
"ScanControl": "tune",
"ScanMetadata": "list_alt",
"ScanProgressBar": "timelapse",
"ScatterWaveform": "scatter_plot",
"SignalComboBox": "list_alt",
"SignalLabel": "scoreboard",
"SignalLineEdit": "vital_signs",
"SpinnerWidget": "progress_activity",
"StopButton": "dangerous",
"TextBox": "chat",
"ToggleSwitch": "toggle_on",
"Waveform": "show_chart",
"WebsiteWidget": "travel_explore",
"WidgetFinderComboBox": "frame_inspect",
}
@@ -206,7 +206,6 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
def _populate_registry_widgets(self): def _populate_registry_widgets(self):
try: try:
widget_handler.update_available_widgets()
items = sorted(widget_handler.widget_classes.keys()) items = sorted(widget_handler.widget_classes.keys())
except Exception as exc: except Exception as exc:
print(f"Failed to load registered widgets: {exc}") print(f"Failed to load registered widgets: {exc}")
@@ -335,20 +334,13 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
If kwargs does not contain `object_name`, it will default to the provided shortcut. If kwargs does not contain `object_name`, it will default to the provided shortcut.
""" """
# Ensure registry is loaded
widget_handler.update_available_widgets()
cls = widget_handler.widget_classes.get(widget_type)
if cls is None:
raise ValueError(f"Unknown registered widget type: {widget_type}")
if kwargs is None: if kwargs is None:
kwargs = {"object_name": shortcut} kwargs = {"object_name": shortcut}
else: else:
kwargs = dict(kwargs) kwargs = dict(kwargs)
kwargs.setdefault("object_name", shortcut) kwargs.setdefault("object_name", shortcut)
# Instantiate and add widget = widget_handler.create_widget(widget_type, **kwargs)
widget = cls(**kwargs)
if not isinstance(widget, QWidget): if not isinstance(widget, QWidget):
raise TypeError( raise TypeError(
f"Instantiated object for type '{widget_type}' is not a QWidget: {type(widget)}" f"Instantiated object for type '{widget_type}' is not a QWidget: {type(widget)}"
+56 -4
View File
@@ -4,6 +4,7 @@ import importlib.metadata
import inspect import inspect
import pkgutil import pkgutil
import traceback import traceback
from functools import lru_cache
from importlib import util as importlib_util from importlib import util as importlib_util
from importlib.machinery import FileFinder, ModuleSpec, SourceFileLoader from importlib.machinery import FileFinder, ModuleSpec, SourceFileLoader
from types import ModuleType from types import ModuleType
@@ -11,7 +12,11 @@ from typing import Generator
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo from bec_widgets.utils.plugin_utils import (
BECClassContainer,
BECClassInfo,
rpc_widget_registry_from_source,
)
logger = bec_logger.logger logger = bec_logger.logger
@@ -53,6 +58,14 @@ def _submodule_by_name(module: ModuleType, name: str):
return None return None
def _submodule_spec_by_name(module: ModuleType, name: str) -> ModuleSpec | None:
for module_info in pkgutil.iter_modules(module.__path__):
if module_info.name != name or not isinstance(module_info.module_finder, FileFinder):
continue
return module_info.module_finder.find_spec(module_info.name)
return None
def _get_widgets_from_module(module: ModuleType) -> BECClassContainer: def _get_widgets_from_module(module: ModuleType) -> BECClassContainer:
"""Find any BECWidget subclasses in the given module and return them with their info.""" """Find any BECWidget subclasses in the given module and return them with their info."""
from bec_widgets.utils.bec_widget import BECWidget # avoid circular import from bec_widgets.utils.bec_widget import BECWidget # avoid circular import
@@ -90,16 +103,55 @@ def get_plugin_client_module() -> ModuleType | None:
return _submodule_by_name(plugin, "client") if (plugin := user_widget_plugin()) else None return _submodule_by_name(plugin, "client") if (plugin := user_widget_plugin()) else None
def get_plugin_designer_module() -> ModuleType | None:
"""If there is a plugin repository installed, return the designer module."""
return (
_submodule_by_name(plugin, "designer_plugins") if (plugin := user_widget_plugin()) else None
)
@lru_cache
def get_plugin_rpc_widget_registry() -> dict[str, tuple[str, str]]:
"""If there is a plugin repository installed, return the RPC widget registry."""
plugin = user_widget_plugin()
if plugin is None:
return {}
client_spec = _submodule_spec_by_name(plugin, "client")
if client_spec is not None and client_spec.origin:
try:
return rpc_widget_registry_from_source(client_spec.origin)
except (OSError, SyntaxError) as exc:
logger.warning(f"Could not parse plugin RPC widget registry: {exc}")
client_module = get_plugin_client_module()
if client_module is None:
return {}
registry = {}
for plugin_name, plugin_class in inspect.getmembers(client_module, inspect.isclass):
if hasattr(plugin_class, "_IMPORT_MODULE"):
registry[plugin_name] = (plugin_class._IMPORT_MODULE, plugin_class.__name__)
return registry
@lru_cache
def get_plugin_designer_registry() -> dict[str, tuple[str, str]]:
"""If there is a plugin repository installed, return the designer plugin registry."""
designer_module = get_plugin_designer_module()
if designer_module and hasattr(designer_module, "designer_plugins"):
return designer_module.designer_plugins
return {}
def get_all_plugin_widgets() -> BECClassContainer: def get_all_plugin_widgets() -> BECClassContainer:
"""If there is a plugin repository installed, load all widgets from it.""" """If there is a plugin repository installed, load all widgets from it."""
if plugin := user_widget_plugin(): if plugin := user_widget_plugin():
return _all_widgets_from_all_submods(plugin) return _all_widgets_from_all_submods(plugin)
else: return BECClassContainer()
return BECClassContainer()
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
widgets = get_plugin_rpc_widget_registry()
client = get_plugin_client_module() client = get_plugin_client_module()
print(get_all_plugin_widgets()) print(get_all_plugin_widgets())
... ...
+69 -2
View File
@@ -14,7 +14,11 @@ import isort
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from qtpy.QtCore import Property as QtProperty from qtpy.QtCore import Property as QtProperty
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator, plugin_filenames from bec_widgets.utils.generate_designer_plugin import (
DesignerPluginGenerator,
DesignerPluginInfo,
plugin_filenames,
)
from bec_widgets.utils.plugin_utils import BECClassContainer, get_custom_classes from bec_widgets.utils.plugin_utils import BECClassContainer, get_custom_classes
logger = bec_logger.logger logger = bec_logger.logger
@@ -250,6 +254,58 @@ class {class_name}(RPCBase):\n"""
file.write(formatted_content) file.write(formatted_content)
def write_designer_plugins(plugin_infos: list[DesignerPluginInfo], file_name: str):
"""
Write a registry of Qt widget classes with designer plugins.
Args:
plugin_infos(list[DesignerPluginInfo]): The designer plugin metadata to write.
file_name(str): The name of the file to write to.
"""
plugin_infos = sorted(plugin_infos, key=lambda info: info.plugin_name_pascal)
content = """# This file was automatically generated by generate_cli.py
# type: ignore
from __future__ import annotations
# pylint: skip-file
designer_plugins = {
"""
for info in plugin_infos:
widget_module = info.plugin_class.__module__
widget_class = info.plugin_name_pascal
content += f' "{info.plugin_name_pascal}": ("{widget_module}", "{widget_class}"),\n'
content += """
}
widget_icons = {
"""
for info in plugin_infos:
content += f' "{info.plugin_name_pascal}": "{info.icon_name}",\n'
content += """
}
"""
try:
formatted_content = black.format_str(content, mode=black.Mode(line_length=100))
except black.NothingChanged:
formatted_content = content
config = isort.Config(
profile="black",
line_length=100,
multi_line_output=3,
include_trailing_comma=False,
known_first_party=["bec_widgets"],
)
formatted_content = isort.code(formatted_content, config=config)
with open(file_name, "w", encoding="utf-8") as file:
file.write(formatted_content)
def main(): def main():
""" """
Main entry point for the script, controlled by command line arguments. Main entry point for the script, controlled by command line arguments.
@@ -303,6 +359,8 @@ def main():
else: else:
non_overwrite_classes = [] non_overwrite_classes = []
designer_plugin_infos = []
for cls in rpc_classes.plugins: for cls in rpc_classes.plugins:
logger.info(f"Writing bec-designer plugin files for {cls.__name__}...") logger.info(f"Writing bec-designer plugin files for {cls.__name__}...")
@@ -310,21 +368,30 @@ def main():
logger.error( logger.error(
f"Not writing plugin files for {cls.__name__} because a built-in widget with that name exists" f"Not writing plugin files for {cls.__name__} because a built-in widget with that name exists"
) )
continue
plugin = DesignerPluginGenerator(cls) plugin = DesignerPluginGenerator(cls)
if not hasattr(plugin, "info"): if not hasattr(plugin, "info") or plugin.excluded:
continue continue
def _exists(file: str): def _exists(file: str):
return os.path.exists(os.path.join(plugin.info.base_path, file)) return os.path.exists(os.path.join(plugin.info.base_path, file))
if any(_exists(file) for file in plugin_filenames(plugin.info.plugin_name_snake)): if any(_exists(file) for file in plugin_filenames(plugin.info.plugin_name_snake)):
if _exists(plugin.filenames.plugin):
designer_plugin_infos.append(plugin.info)
logger.debug( logger.debug(
f"Skipping generation of extra plugin files for {plugin.info.plugin_name_snake} - at least one file out of 'plugin.py', 'pyproject', and 'register_{plugin.info.plugin_name_snake}.py' already exists." f"Skipping generation of extra plugin files for {plugin.info.plugin_name_snake} - at least one file out of 'plugin.py', 'pyproject', and 'register_{plugin.info.plugin_name_snake}.py' already exists."
) )
continue continue
plugin.run() plugin.run()
designer_plugin_infos.append(plugin.info)
# Write designer_plugins.py with plugin import metadata for all widgets with designer plugins.
designer_plugins_path = module_dir / client_subdir / "designer_plugins.py"
logger.info(f"Generating designer plugin registry at {designer_plugins_path}")
write_designer_plugins(designer_plugin_infos, str(designer_plugins_path))
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
@@ -29,6 +29,7 @@ class DesignerPluginInfo:
self.plugin_name_pascal = plugin_class.__name__ self.plugin_name_pascal = plugin_class.__name__
self.plugin_name_snake = pascal_to_snake(self.plugin_name_pascal) self.plugin_name_snake = pascal_to_snake(self.plugin_name_pascal)
self.widget_import = f"from {plugin_class.__module__} import {self.plugin_name_pascal}" self.widget_import = f"from {plugin_class.__module__} import {self.plugin_name_pascal}"
self.icon_name = getattr(plugin_class, "ICON_NAME", "")
plugin_module = ( plugin_module = (
".".join(plugin_class.__module__.split(".")[:-1]) + f".{self.plugin_name_snake}_plugin" ".".join(plugin_class.__module__.split(".")[:-1]) + f".{self.plugin_name_snake}_plugin"
) )
@@ -63,6 +64,10 @@ class DesignerPluginGenerator:
def filenames(self): def filenames(self):
return plugin_filenames(self.info.plugin_name_snake) return plugin_filenames(self.info.plugin_name_snake)
@property
def excluded(self):
return self._excluded
def run(self, validate=True): def run(self, validate=True):
if self._excluded: if self._excluded:
print(f"Plugin {self.widget.__name__} is excluded from generation.") print(f"Plugin {self.widget.__name__} is excluded from generation.")
+110 -50
View File
@@ -1,56 +1,22 @@
from __future__ import annotations from __future__ import annotations
import ast
import importlib import importlib
import inspect import inspect
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import TYPE_CHECKING, Iterable from typing import TYPE_CHECKING, Iterable
from bec_lib.plugin_helper import _get_available_plugins
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
def get_plugin_widgets() -> dict[str, BECConnector]:
"""
Get all available widgets from the plugin directory. Widgets are classes that inherit from BECConnector.
The plugins are provided through python plugins and specified in the respective pyproject.toml file using
the following key:
[project.entry-points."bec.widgets.user_widgets"]
plugin_widgets = "path.to.plugin.module"
e.g.
[project.entry-points."bec.widgets.user_widgets"]
plugin_widgets = "pxiii_bec.bec_widgets.widgets"
assuming that the widgets module for the package pxiii_bec is located at pxiii_bec/bec_widgets/widgets and
contains the widgets to be loaded within the pxiii_bec/bec_widgets/widgets/__init__.py file.
Returns:
dict[str, BECConnector]: A dictionary of widget names and their respective classes.
"""
modules = _get_available_plugins("bec.widgets.user_widgets")
loaded_plugins = {}
print(modules)
for module in modules:
mods = inspect.getmembers(module, predicate=_filter_plugins)
for name, mod_cls in mods:
if name in loaded_plugins:
print(f"Duplicated widgets plugin {name}.")
loaded_plugins[name] = mod_cls
return loaded_plugins
def _filter_plugins(obj):
return inspect.isclass(obj) and issubclass(obj, BECConnector)
def get_plugin_auto_updates() -> dict[str, type[AutoUpdates]]: def get_plugin_auto_updates() -> dict[str, type[AutoUpdates]]:
""" """
Get all available auto update classes from the plugin directory. AutoUpdates must inherit from AutoUpdate and be Get all available auto update classes from the plugin directory. AutoUpdates must inherit from AutoUpdate and be
@@ -66,6 +32,8 @@ def get_plugin_auto_updates() -> dict[str, type[AutoUpdates]]:
Returns: Returns:
dict[str, AutoUpdates]: A dictionary of widget names and their respective classes. dict[str, AutoUpdates]: A dictionary of widget names and their respective classes.
""" """
from bec_lib.plugin_helper import _get_available_plugins
modules = _get_available_plugins("bec.widgets.auto_updates") modules = _get_available_plugins("bec.widgets.auto_updates")
loaded_plugins = {} loaded_plugins = {}
for module in modules: for module in modules:
@@ -168,6 +136,11 @@ class BECClassContainer:
def _collect_classes_from_package(repo_name: str, package: str) -> BECClassContainer: def _collect_classes_from_package(repo_name: str, package: str) -> BECClassContainer:
"""Collect classes from a package subtree (for example ``widgets`` or ``applications``).""" """Collect classes from a package subtree (for example ``widgets`` or ``applications``)."""
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
collection = BECClassContainer() collection = BECClassContainer()
try: try:
anchor_module = importlib.import_module(f"{repo_name}.{package}") anchor_module = importlib.import_module(f"{repo_name}.{package}")
@@ -194,17 +167,18 @@ def _collect_classes_from_package(repo_name: str, package: str) -> BECClassConta
for name in dir(module): for name in dir(module):
obj = getattr(module, name) obj = getattr(module, name)
if not isinstance(obj, type):
continue
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__: if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
continue continue
if isinstance(obj, type): class_info = BECClassInfo(name=name, module=module.__name__, file=path, obj=obj)
class_info = BECClassInfo(name=name, module=module.__name__, file=path, obj=obj) if issubclass(obj, BECConnector):
if issubclass(obj, BECConnector): class_info.is_connector = True
class_info.is_connector = True if issubclass(obj, QWidget) or issubclass(obj, BECWidget):
if issubclass(obj, QWidget) or issubclass(obj, BECWidget): class_info.is_widget = True
class_info.is_widget = True if hasattr(obj, "PLUGIN") and obj.PLUGIN:
if hasattr(obj, "PLUGIN") and obj.PLUGIN: class_info.is_plugin = True
class_info.is_plugin = True collection.add_class(class_info)
collection.add_class(class_info)
return collection return collection
@@ -229,3 +203,89 @@ def get_custom_classes(
for package in selected_packages: for package in selected_packages:
collection += _collect_classes_from_package(repo_name, package) collection += _collect_classes_from_package(repo_name, package)
return collection return collection
def _get_designer_registry() -> dict[str, tuple[str, str]]:
from bec_widgets.cli.designer_plugins import designer_plugins
return designer_plugins
def _resolve_widget_from_registry(import_path: str, widget_name: str) -> type[QWidget]:
widget = importlib.import_module(import_path)
return getattr(widget, widget_name)
def designer_plugin_exists(name: str) -> bool:
from bec_widgets.utils.bec_plugin_helper import get_plugin_designer_registry
internal_registry = _get_designer_registry()
external_registry = get_plugin_designer_registry()
return name in internal_registry or name in external_registry
def get_designer_plugin(name: str, raise_on_missing: bool = True) -> type[QWidget] | None:
from bec_widgets.utils.bec_plugin_helper import get_plugin_designer_registry
internal_registry = _get_designer_registry()
external_registry = get_plugin_designer_registry()
if name in external_registry:
import_path, widget_name = external_registry[name]
return _resolve_widget_from_registry(import_path, widget_name)
if name in internal_registry:
import_path, widget_name = internal_registry[name]
return _resolve_widget_from_registry(import_path, widget_name)
if raise_on_missing:
raise ValueError(
f"Designer plugin {name} not found in either internal or external registry."
)
return None
def rpc_widget_registry_from_source(path: str | Path) -> dict[str, tuple[str, str]]:
"""Parse a generated RPC client module and return its widget registry."""
source_path = Path(path)
module_node = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path))
registry = {}
for node in module_node.body:
if not isinstance(node, ast.ClassDef):
continue
for item in node.body:
if not isinstance(item, ast.Assign):
continue
if not any(
isinstance(target, ast.Name) and target.id == "_IMPORT_MODULE"
for target in item.targets
):
continue
if isinstance(item.value, ast.Constant) and isinstance(item.value.value, str):
registry[node.name] = (item.value.value, node.name)
break
return registry
@lru_cache
def get_rpc_widget_registry() -> dict[str, tuple[str, str]]:
client_path = Path(__file__).resolve().parents[1] / "cli" / "client.py"
return rpc_widget_registry_from_source(client_path)
@lru_cache
def rpc_widget_registry() -> dict[str, tuple[str, str]]:
from bec_widgets.utils.bec_plugin_helper import get_plugin_rpc_widget_registry
internal_registry = get_rpc_widget_registry()
external_registry = get_plugin_rpc_widget_registry()
return {**external_registry, **internal_registry}
def get_rpc_widget(name: str, raise_on_missing: bool = True) -> type[QWidget] | None:
registry = rpc_widget_registry()
if name in registry:
import_path, widget_name = registry[name]
return _resolve_widget_from_registry(import_path, widget_name)
if raise_on_missing:
raise ValueError(f"RPC widget {name} not found in registry.")
return None
+17 -25
View File
@@ -1,42 +1,34 @@
from __future__ import annotations from __future__ import annotations
from bec_widgets.cli.client_utils import IGNORE_WIDGETS from typing import TYPE_CHECKING
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.plugin_utils import get_rpc_widget, rpc_widget_registry
from bec_widgets.utils.plugin_utils import get_custom_classes
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.bec_widget import BECWidget
class RPCWidgetHandler: class RPCWidgetHandler:
"""Handler class for creating widgets from RPC messages.""" """Handler class for creating widgets from RPC messages."""
def __init__(self): def __init__(self):
self._widget_classes = None self._widget_registry = None
@property @property
def widget_classes(self) -> dict[str, type[BECWidget]]: def widget_classes(self) -> dict[str, tuple[str, str]]:
""" """
Get the available widget classes. Get the available widget classes.
Returns: Returns:
dict: The available widget classes. dict: The available widget classes.
""" """
if self._widget_classes is None: registry = rpc_widget_registry()
self.update_available_widgets() if not registry:
return self._widget_classes # type: ignore return {}
return registry
def update_available_widgets(self): @staticmethod
""" def create_widget(widget_type, **kwargs) -> BECWidget:
Update the available widgets.
Returns:
None
"""
self._widget_classes = (
get_custom_classes("bec_widgets", packages=("widgets", "applications"))
+ get_all_plugin_widgets()
).as_dict(IGNORE_WIDGETS)
def create_widget(self, widget_type, **kwargs) -> BECWidget:
""" """
Create a widget from an RPC message. Create a widget from an RPC message.
@@ -48,9 +40,9 @@ class RPCWidgetHandler:
Returns: Returns:
widget(BECWidget): The created widget. widget(BECWidget): The created widget.
""" """
widget_class = self.widget_classes.get(widget_type) # type: ignore widget = get_rpc_widget(widget_type, raise_on_missing=False)
if widget_class: if widget:
return widget_class(**kwargs) return widget(**kwargs)
raise ValueError(f"Unknown widget type: {widget_type}") raise ValueError(f"Unknown widget type: {widget_type}")
+9 -85
View File
@@ -1,10 +1,8 @@
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from qtpy import PYQT6, PYSIDE6 from qtpy import PYSIDE6
from qtpy.QtCore import QFile, QIODevice from qtpy.QtCore import QFile, QIODevice
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets from bec_widgets.utils.plugin_utils import get_designer_plugin
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
from bec_widgets.utils.plugin_utils import get_custom_classes
logger = bec_logger.logger logger = bec_logger.logger
@@ -12,16 +10,14 @@ if PYSIDE6:
from qtpy.QtUiTools import QUiLoader from qtpy.QtUiTools import QUiLoader
class CustomUiLoader(QUiLoader): class CustomUiLoader(QUiLoader):
def __init__(self, baseinstance, custom_widgets: dict | None = None): def __init__(self, baseinstance):
super().__init__(baseinstance) super().__init__(baseinstance)
self.custom_widgets = custom_widgets or {}
self.baseinstance = baseinstance self.baseinstance = baseinstance
def createWidget(self, class_name, parent=None, name=""): def createWidget(self, class_name, parent=None, name=""):
if class_name in self.custom_widgets: widget = get_designer_plugin(class_name, raise_on_missing=False)
widget = self.custom_widgets[class_name](self.baseinstance) if widget is not None:
return widget return widget(self.baseinstance)
return super().createWidget(class_name, self.baseinstance, name) return super().createWidget(class_name, self.baseinstance, name)
@@ -31,16 +27,9 @@ class UILoader:
def __init__(self, parent=None): def __init__(self, parent=None):
self.parent = parent self.parent = parent
self.custom_widgets = ( if not PYSIDE6:
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
).as_dict()
if PYSIDE6:
self.loader = self.load_ui_pyside6
elif PYQT6:
self.loader = self.load_ui_pyqt6
else:
raise ImportError("No compatible Qt bindings found.") raise ImportError("No compatible Qt bindings found.")
self.loader = self.load_ui_pyside6
def load_ui_pyside6(self, ui_file, parent=None): def load_ui_pyside6(self, ui_file, parent=None):
""" """
@@ -53,7 +42,7 @@ class UILoader:
QWidget: The loaded widget. QWidget: The loaded widget.
""" """
parent = parent or self.parent parent = parent or self.parent
loader = CustomUiLoader(parent, self.custom_widgets) loader = CustomUiLoader(parent)
file = QFile(ui_file) file = QFile(ui_file)
if not file.open(QIODevice.ReadOnly): if not file.open(QIODevice.ReadOnly):
raise IOError(f"Cannot open file: {ui_file}") raise IOError(f"Cannot open file: {ui_file}")
@@ -61,71 +50,6 @@ class UILoader:
file.close() file.close()
return widget return widget
def load_ui_pyqt6(self, ui_file, parent=None):
"""
Specific loader for PyQt6 using loadUi.
Args:
ui_file(str): Path to the .ui file.
parent(QWidget): Parent widget.
Returns:
QWidget: The loaded widget.
"""
from PyQt6.uic.Loader.loader import DynamicUILoader
class CustomDynamicUILoader(DynamicUILoader):
def __init__(self, package, custom_widgets: dict = None):
super().__init__(package)
self.custom_widgets = custom_widgets or {}
def _handle_custom_widgets(self, el):
"""Handle the <customwidgets> element."""
def header2module(header):
"""header2module(header) -> string
Convert paths to C++ header files to according Python modules
>>> header2module("foo/bar/baz.h")
'foo.bar.baz'
"""
if header.endswith(".h"):
header = header[:-2]
mpath = []
for part in header.split("/"):
# Ignore any empty parts or those that refer to the current
# directory.
if part not in ("", "."):
if part == "..":
# We should allow this for Python3.
raise SyntaxError(
"custom widget header file name may not contain '..'."
)
mpath.append(part)
return ".".join(mpath)
for custom_widget in el:
classname = custom_widget.findtext("class")
header = custom_widget.findtext("header")
if header:
header = self._translate_bec_widgets_header(header)
self.factory.addCustomWidget(
classname,
custom_widget.findtext("extends") or "QWidget",
header2module(header),
)
def _translate_bec_widgets_header(self, header):
for name, value in self.custom_widgets.items():
if header == DesignerPluginInfo.pascal_to_snake(name):
return value.__module__
return header
return CustomDynamicUILoader("", self.custom_widgets).loadUi(ui_file, parent)
def load_ui(self, ui_file, parent=None): def load_ui(self, ui_file, parent=None):
""" """
Universal UI loader method. Universal UI loader method.
@@ -19,9 +19,7 @@ from qtpy.QtWidgets import (
import bec_widgets.widgets.containers.qt_ads as QtAds 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.cli.designer_plugins import widget_icons
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 +34,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 (
@@ -66,22 +64,7 @@ from bec_widgets.widgets.containers.dock_area.toolbar_components.workspace_actio
WorkspaceConnection, WorkspaceConnection,
workspace_bundle, workspace_bundle,
) )
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindowNoRPC
from bec_widgets.widgets.containers.qt_ads import CDockWidget from bec_widgets.widgets.containers.qt_ads import CDockWidget
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox, PositionerBox2D
from bec_widgets.widgets.control.scan_control import ScanControl
from bec_widgets.widgets.editors.bec_console.bec_console import BecConsole, BECShell
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
logger = bec_logger.logger logger = bec_logger.logger
@@ -109,6 +92,7 @@ class BECDockArea(DockAreaWidget):
"list_profiles", "list_profiles",
"save_profile", "save_profile",
"load_profile", "load_profile",
"restore_baseline_profile",
"delete_profile", "delete_profile",
] ]
@@ -144,6 +128,10 @@ class BECDockArea(DockAreaWidget):
self._mode = mode self._mode = mode
# Toolbar # Toolbar
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import (
DarkModeButton,
)
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True) self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
self.dark_mode_button.setVisible(enable_profile_management) self.dark_mode_button.setVisible(enable_profile_management)
self._setup_toolbar() self._setup_toolbar()
@@ -342,39 +330,42 @@ class BECDockArea(DockAreaWidget):
self.toolbar = ModularToolBar(parent=self) self.toolbar = ModularToolBar(parent=self)
plot_actions = { plot_actions = {
"waveform": (Waveform.ICON_NAME, "Add Waveform", "Waveform"), "waveform": (widget_icons["Waveform"], "Add Waveform", "Waveform"),
"scatter_waveform": ( "scatter_waveform": (
ScatterWaveform.ICON_NAME, widget_icons["ScatterWaveform"],
"Add Scatter Waveform", "Add Scatter Waveform",
"ScatterWaveform", "ScatterWaveform",
), ),
"multi_waveform": (MultiWaveform.ICON_NAME, "Add Multi Waveform", "MultiWaveform"), "multi_waveform": (
"image": (Image.ICON_NAME, "Add Image", "Image"), widget_icons["MultiWaveform"],
"motor_map": (MotorMap.ICON_NAME, "Add Motor Map", "MotorMap"), "Add Multi Waveform",
"heatmap": (Heatmap.ICON_NAME, "Add Heatmap", "Heatmap"), "MultiWaveform",
),
"image": (widget_icons["Image"], "Add Image", "Image"),
"motor_map": (widget_icons["MotorMap"], "Add Motor Map", "MotorMap"),
"heatmap": (widget_icons["Heatmap"], "Add Heatmap", "Heatmap"),
} }
device_actions = { device_actions = {
"scan_control": (ScanControl.ICON_NAME, "Add Scan Control", "ScanControl"), "scan_control": (widget_icons["ScanControl"], "Add Scan Control", "ScanControl"),
"positioner_box": (PositionerBox.ICON_NAME, "Add Device Box", "PositionerBox"), "positioner_box": (widget_icons["PositionerBox"], "Add Device Box", "PositionerBox"),
"positioner_box_2D": ( "positioner_box_2D": (
PositionerBox2D.ICON_NAME, widget_icons["PositionerBox2D"],
"Add Device 2D Box", "Add Device 2D Box",
"PositionerBox2D", "PositionerBox2D",
), ),
} }
util_actions = { util_actions = {
"queue": (BECQueue.ICON_NAME, "Add Scan Queue", "BECQueue"), "queue": (widget_icons["BECQueue"], "Add Scan Queue", "BECQueue"),
"status": (BECStatusBox.ICON_NAME, "Add BEC Status Box", "BECStatusBox"), "status": (widget_icons["BECStatusBox"], "Add BEC Status Box", "BECStatusBox"),
"progress_bar": ( "progress_bar": (
RingProgressBar.ICON_NAME, widget_icons["RingProgressBar"],
"Add Circular ProgressBar", "Add Circular ProgressBar",
"RingProgressBar", "RingProgressBar",
), ),
"terminal": (BecConsole.ICON_NAME, "Add Terminal", "BecConsole"), "terminal": (widget_icons["BecConsole"], "Add Terminal", "BecConsole"),
"bec_shell": (BECShell.ICON_NAME, "Add BEC Shell", "BECShell"), "bec_shell": (widget_icons["BECShell"], "Add BEC Shell", "BECShell"),
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"), "sbb_monitor": (widget_icons["SBBMonitor"], "Add SBB Monitor", "SBBMonitor"),
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"), "log_panel": (widget_icons["LogPanel"], "Add LogPanel", "LogPanel"),
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel", "LogPanel"),
} }
# Create expandable menu actions (original behavior) # Create expandable menu actions (original behavior)
@@ -399,26 +390,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 +460,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 +493,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 +580,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 +612,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 +701,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 +768,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 +777,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 +811,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 +844,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 +896,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
current_pixmap = None if show_dialog:
if self.isVisible(): current_pixmap = None
current_pixmap = QPixmap() if self.isVisible():
ba = bytes(self.screenshot_bytes()) current_pixmap = QPixmap()
current_pixmap.loadFromData(ba) ba = bytes(self.screenshot_bytes())
if current_pixmap is None or current_pixmap.isNull(): current_pixmap.loadFromData(ba)
current_pixmap = load_user_profile_screenshot(target, namespace=namespace) if current_pixmap is None or current_pixmap.isNull():
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 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 +1060,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 +1108,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 +1159,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
@@ -1207,6 +1189,8 @@ class BECDockArea(DockAreaWidget):
) )
step_ids.append(step_id) step_ids.append(step_id)
from bec_widgets.applications.views.view import ViewTourSteps
return ViewTourSteps(view_title="Dock Area Workspace", step_ids=step_ids) return ViewTourSteps(view_title="Dock Area Workspace", step_ids=step_ids)
def cleanup(self): def cleanup(self):
@@ -1227,6 +1211,9 @@ class BECDockArea(DockAreaWidget):
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
import sys import sys
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindowNoRPC
app = QApplication(sys.argv) app = QApplication(sys.argv)
apply_theme("dark") apply_theme("dark")
dispatcher = BECDispatcher(gui_id="ads") dispatcher = BECDispatcher(gui_id="ads")
@@ -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)
@@ -4,11 +4,7 @@ import webbrowser
class BECWebLinksMixin: class BECWebLinksMixin:
@staticmethod @staticmethod
def open_bec_docs(): def open_bec_docs():
webbrowser.open("https://beamline-experiment-control.readthedocs.io/en/latest/") webbrowser.open("https://bec-project.github.io/bec_docs/")
@staticmethod
def open_bec_widgets_docs():
webbrowser.open("https://bec.readthedocs.io/projects/bec-widgets/en/latest/")
@staticmethod @staticmethod
def open_bec_bug_report(): def open_bec_bug_report():
@@ -355,17 +355,13 @@ class BECMainWindow(BECWidget, QMainWindow):
bec_docs = QAction("BEC Docs", self) bec_docs = QAction("BEC Docs", self)
bec_docs.setIcon(help_icon) bec_docs.setIcon(help_icon)
widgets_docs = QAction("BEC Widgets Docs", self)
widgets_docs.setIcon(help_icon)
bug_report = QAction("Bug Report", self) bug_report = QAction("Bug Report", self)
bug_report.setIcon(bug_icon) bug_report.setIcon(bug_icon)
bec_docs.triggered.connect(BECWebLinksMixin.open_bec_docs) bec_docs.triggered.connect(BECWebLinksMixin.open_bec_docs)
widgets_docs.triggered.connect(BECWebLinksMixin.open_bec_widgets_docs)
bug_report.triggered.connect(BECWebLinksMixin.open_bec_bug_report) bug_report.triggered.connect(BECWebLinksMixin.open_bec_bug_report)
help_menu.addAction(bec_docs) help_menu.addAction(bec_docs)
help_menu.addAction(widgets_docs)
help_menu.addAction(bug_report) help_menu.addAction(bug_report)
################################################################################ ################################################################################
@@ -26,7 +26,6 @@ from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
from bec_widgets.widgets.control.scan_control.scan_group_box import ScanGroupBox from bec_widgets.widgets.control.scan_control.scan_group_box import ScanGroupBox
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
class ScanParameterConfig(BaseModel): class ScanParameterConfig(BaseModel):
@@ -84,7 +83,6 @@ class ScanControl(BECWidget, QWidget):
self.kwarg_boxes = [] self.kwarg_boxes = []
self.expert_mode = False # TODO implement in the future versions self.expert_mode = False # TODO implement in the future versions
self.previous_scan = None self.previous_scan = None
self.last_scan_found = None
# Widget Default Parameters # Widget Default Parameters
self.config.default_scan = default_scan self.config.default_scan = default_scan
@@ -123,17 +121,12 @@ class ScanControl(BECWidget, QWidget):
scan_selection_layout.addWidget(self.comboBox_scan_selection, 1) scan_selection_layout.addWidget(self.comboBox_scan_selection, 1)
self.scan_selection_group.layout().addLayout(scan_selection_layout) self.scan_selection_group.layout().addLayout(scan_selection_layout)
# Label to reload the last scan parameters within scan selection group box # Button to reload the last scan parameters on demand.
self.toggle_layout = QHBoxLayout() self.last_scan_button = QPushButton(
self.toggle_layout.addSpacerItem( "Restore last scan parameters", self.scan_selection_group
QSpacerItem(0, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
) )
self.last_scan_label = QLabel("Restore last scan parameters", self.scan_selection_group) self.last_scan_button.clicked.connect(self.request_last_executed_scan_parameters)
self.toggle = ToggleSwitch(parent=self.scan_selection_group, checked=False) self.scan_selection_group.layout().addWidget(self.last_scan_button)
self.toggle.enabled.connect(self.request_last_executed_scan_parameters)
self.toggle_layout.addWidget(self.last_scan_label)
self.toggle_layout.addWidget(self.toggle)
self.scan_selection_group.layout().addLayout(self.toggle_layout)
self.scan_selection_group.setSizePolicy( self.scan_selection_group.setSizePolicy(
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed
) )
@@ -206,7 +199,6 @@ class ScanControl(BECWidget, QWidget):
"""Callback for scan selection combo box""" """Callback for scan selection combo box"""
selected_scan_name = self.comboBox_scan_selection.currentText() selected_scan_name = self.comboBox_scan_selection.currentText()
self.scan_selected.emit(selected_scan_name) self.scan_selected.emit(selected_scan_name)
self.request_last_executed_scan_parameters()
self.restore_scan_parameters(selected_scan_name) self.restore_scan_parameters(selected_scan_name)
@SafeSlot() @SafeSlot()
@@ -215,10 +207,6 @@ class ScanControl(BECWidget, QWidget):
""" """
Requests the last executed scan parameters from BEC and restores them to the scan control widget. Requests the last executed scan parameters from BEC and restores them to the scan control widget.
""" """
self.last_scan_found = False
if not self.toggle.checked:
return
current_scan = self.comboBox_scan_selection.currentText() current_scan = self.comboBox_scan_selection.currentText()
history = ( history = (
self.client.connector.xread( self.client.connector.xread(
@@ -246,8 +234,6 @@ class ScanControl(BECWidget, QWidget):
if merged and self.kwarg_boxes: if merged and self.kwarg_boxes:
for box in self.kwarg_boxes: for box in self.kwarg_boxes:
box.set_parameters(merged) box.set_parameters(merged)
self.last_scan_found = True
break break
@SafeProperty(str) @SafeProperty(str)
@@ -496,8 +482,6 @@ class ScanControl(BECWidget, QWidget):
Args: Args:
scan_name(str): Name of the scan to restore the parameters for. scan_name(str): Name of the scan to restore the parameters for.
""" """
if self.last_scan_found is True:
return
scan_params = self.config.scans.get(scan_name, None) scan_params = self.config.scans.get(scan_name, None)
if scan_params is None and self.previous_scan is None: if scan_params is None and self.previous_scan is None:
return return
+18 -7
View File
@@ -2,21 +2,16 @@ from __future__ import annotations
import json import json
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal from typing import TYPE_CHECKING, Literal
import numpy as np import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
from bec_lib import bec_logger, messages from bec_lib import bec_logger, messages
from bec_lib.endpoints import MessageEndpoints from bec_lib.endpoints import MessageEndpoints
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import QObject, Qt, QThread, QTimer, Signal from qtpy.QtCore import QObject, Qt, QThread, QTimer, Signal
from qtpy.QtGui import QTransform from qtpy.QtGui import QTransform
from scipy.interpolate import (
CloughTocher2DInterpolator,
LinearNDInterpolator,
NearestNDInterpolator,
)
from scipy.spatial import cKDTree
from toolz import partition from toolz import partition
from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils.bec_connector import ConnectionConfig
@@ -32,6 +27,22 @@ from bec_widgets.widgets.plots.plot_base import PlotBase
logger = bec_logger.logger logger = bec_logger.logger
if TYPE_CHECKING:
from scipy.interpolate import (
CloughTocher2DInterpolator,
LinearNDInterpolator,
NearestNDInterpolator,
)
from scipy.spatial import cKDTree
else:
CloughTocher2DInterpolator, LinearNDInterpolator, NearestNDInterpolator = lazy_import_from(
"scipy.interpolate",
["CloughTocher2DInterpolator", "LinearNDInterpolator", "NearestNDInterpolator"],
)
cKDTree = lazy_import_from("scipy.spatial", ["cKDTree"])
class HeatmapDeviceSignal(BaseModel): class HeatmapDeviceSignal(BaseModel):
"""The configuration of a signal in the scatter waveform widget.""" """The configuration of a signal in the scatter waveform widget."""
@@ -10,6 +10,7 @@ from bec_lib.device import Positioner
from bec_lib.endpoints import MessageEndpoints from bec_lib.endpoints import MessageEndpoints
from bec_lib.lmfit_serializer import serialize_lmfit_params, serialize_param_object from bec_lib.lmfit_serializer import serialize_lmfit_params, serialize_param_object
from bec_lib.scan_data_container import ScanDataContainer from bec_lib.scan_data_container import ScanDataContainer
from bec_lib.utils.import_utils import lazy_import
from pydantic import Field, ValidationError, field_validator from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import Qt, QTimer, Signal from qtpy.QtCore import Qt, QTimer, Signal
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
@@ -54,13 +55,7 @@ _DAP_PARAM = object()
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
import lmfit # type: ignore import lmfit # type: ignore
else: else:
try: lmfit = lazy_import("lmfit")
import lmfit # type: ignore
except Exception as e: # pragma: no cover
logger.warning(
f"lmfit could not be imported: {e}. Custom DAP functionality will be unavailable."
)
lmfit = None
# noinspection PyDataclass # noinspection PyDataclass
+4 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "bec_widgets" name = "bec_widgets"
version = "3.7.2" version = "3.9.1"
description = "BEC Widgets" description = "BEC Widgets"
requires-python = ">=3.11" requires-python = ">=3.11"
classifiers = [ classifiers = [
@@ -58,6 +58,9 @@ dev = [
] ]
qtermwidget = ["pyside6_qtermwidget"] qtermwidget = ["pyside6_qtermwidget"]
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
File diff suppressed because it is too large Load Diff
+25 -1
View File
@@ -5,7 +5,8 @@ import black
import isort import isort
import pytest import pytest
from bec_widgets.utils.generate_cli import ClientGenerator from bec_widgets.utils.generate_cli import ClientGenerator, write_designer_plugins
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
# pylint: disable=missing-function-docstring # pylint: disable=missing-function-docstring
@@ -59,6 +60,14 @@ class MockViewWithContent:
"""Activate view.""" """Activate view."""
class MockDesignerWidgetBase:
ICON_NAME = "mock_icon"
class MockDesignerWidget(MockDesignerWidgetBase):
pass
def test_client_generator_with_black_formatting(): def test_client_generator_with_black_formatting():
generator = ClientGenerator(base=True) generator = ClientGenerator(base=True)
container = BECClassContainer() container = BECClassContainer()
@@ -285,3 +294,18 @@ c = a + b"""
content = file.read() content = file.read()
assert corrected in content assert corrected in content
def test_write_designer_plugins(tmp_path):
file_name = tmp_path / "designer_plugins.py"
write_designer_plugins([DesignerPluginInfo(MockDesignerWidget)], str(file_name))
with open(file_name, "r", encoding="utf-8") as file:
content = file.read()
assert '"MockDesignerWidget":' in content
assert '"tests.unit_tests.test_generate_cli_client"' in content
assert '"MockDesignerWidget"' in content
assert '"MockDesignerWidget": "mock_icon"' in content
assert "MockDesignerWidgetPlugin" not in content
+2 -2
View File
@@ -62,8 +62,8 @@ TEST_LOG_MESSAGES = [
@pytest.fixture @pytest.fixture
def log_panel(qtbot, mocked_client): def log_panel(qtbot, mocked_client, monkeypatch):
mocked_client.connector.xread = lambda *_, **__: TEST_LOG_MESSAGES monkeypatch.setattr(mocked_client.connector, "xread", lambda *_, **__: TEST_LOG_MESSAGES)
widget = LogPanel() widget = LogPanel()
qtbot.addWidget(widget) qtbot.addWidget(widget)
qtbot.waitExposed(widget) qtbot.waitExposed(widget)
+1 -3
View File
@@ -247,12 +247,10 @@ def test_bec_weblinks(monkeypatch):
monkeypatch.setattr(webbrowser, "open", fake_open) monkeypatch.setattr(webbrowser, "open", fake_open)
BECWebLinksMixin.open_bec_docs() BECWebLinksMixin.open_bec_docs()
BECWebLinksMixin.open_bec_widgets_docs()
BECWebLinksMixin.open_bec_bug_report() BECWebLinksMixin.open_bec_bug_report()
assert opened_urls == [ assert opened_urls == [
"https://beamline-experiment-control.readthedocs.io/en/latest/", "https://bec-project.github.io/bec_docs/",
"https://bec.readthedocs.io/projects/bec-widgets/en/latest/",
"https://github.com/bec-project/bec_widgets/issues", "https://github.com/bec-project/bec_widgets/issues",
] ]
+6 -2
View File
@@ -14,7 +14,9 @@ from .test_scan_control import available_scans_message
@pytest.fixture @pytest.fixture
def monaco_widget(qtbot, mocked_client): def monaco_widget(qtbot, mocked_client):
widget = MonacoWidget(client=mocked_client) widget = MonacoWidget(client=mocked_client)
mocked_client.connector.set(MessageEndpoints.available_scans(), available_scans_message) mocked_client.connector.set_and_publish(
MessageEndpoints.available_scans(), available_scans_message
)
qtbot.addWidget(widget) qtbot.addWidget(widget)
qtbot.waitExposed(widget) qtbot.waitExposed(widget)
yield widget yield widget
@@ -62,7 +64,9 @@ def test_monaco_widget_get_scan_control_code(monaco_widget: MonacoWidget, qtbot,
""" """
Test that the MonacoWidget can get scan control code from the dialog. Test that the MonacoWidget can get scan control code from the dialog.
""" """
mocked_client.connector.set(MessageEndpoints.available_scans(), available_scans_message) mocked_client.connector.set_and_publish(
MessageEndpoints.available_scans(), available_scans_message
)
scan_control_dialog = ScanControlDialog(client=mocked_client) scan_control_dialog = ScanControlDialog(client=mocked_client)
qtbot.addWidget(scan_control_dialog) qtbot.addWidget(scan_control_dialog)
+15 -15
View File
@@ -1,7 +1,6 @@
from unittest.mock import patch from unittest.mock import patch
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils import plugin_utils
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
from bec_widgets.utils.rpc_widget_handler import RPCWidgetHandler from bec_widgets.utils.rpc_widget_handler import RPCWidgetHandler
@@ -10,21 +9,22 @@ def test_rpc_widget_handler():
assert "Image" in handler.widget_classes assert "Image" in handler.widget_classes
assert "RingProgressBar" in handler.widget_classes assert "RingProgressBar" in handler.widget_classes
assert "BECDockArea" in handler.widget_classes assert "BECDockArea" in handler.widget_classes
assert isinstance(handler.widget_classes["Image"], tuple)
class _TestPluginWidget(BECWidget): ...
@patch( @patch(
"bec_widgets.utils.rpc_widget_handler.get_all_plugin_widgets", "bec_widgets.utils.bec_plugin_helper.get_plugin_rpc_widget_registry",
return_value=BECClassContainer( return_value={
[ "Image": ("plugin.module", "PluginImage"),
BECClassInfo(name="DeviceComboBox", obj=_TestPluginWidget, module="", file=""), "NewPluginWidget": ("plugin.module", "NewPluginWidget"),
BECClassInfo(name="NewPluginWidget", obj=_TestPluginWidget, module="", file=""), },
]
),
) )
def test_duplicate_plugins_not_allowed(_): def test_duplicate_plugins_not_allowed(_):
handler = RPCWidgetHandler() plugin_utils.rpc_widget_registry.cache_clear()
assert handler.widget_classes["DeviceComboBox"] is not _TestPluginWidget
assert handler.widget_classes["NewPluginWidget"] is _TestPluginWidget try:
handler = RPCWidgetHandler()
assert handler.widget_classes["Image"] != ("plugin.module", "PluginImage")
assert handler.widget_classes["NewPluginWidget"] == ("plugin.module", "NewPluginWidget")
finally:
plugin_utils.rpc_widget_registry.cache_clear()
+43 -8
View File
@@ -256,7 +256,9 @@ scan_history = ScanHistoryMessage(
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def scan_control(qtbot, mocked_client): # , mock_dev): def scan_control(qtbot, mocked_client): # , mock_dev):
mocked_client.connector.set(MessageEndpoints.available_scans(), available_scans_message) mocked_client.connector.set_and_publish(
MessageEndpoints.available_scans(), available_scans_message
)
mocked_client.connector.xadd( mocked_client.connector.xadd(
topic=MessageEndpoints.scan_history(), msg_dict={"data": scan_history} topic=MessageEndpoints.scan_history(), msg_dict={"data": scan_history}
) )
@@ -501,12 +503,47 @@ def test_changing_scans_remember_parameters(scan_control, mocked_client):
assert grid_kwargs["burst_at_each_point"] == kwargs["burst_at_each_point"] assert grid_kwargs["burst_at_each_point"] == kwargs["burst_at_each_point"]
@pytest.mark.skip(reason="Unreliable - GH issue #1134") def test_scan_selection_does_not_fetch_last_scan_parameters(
def test_get_scan_parameters_from_redis(scan_control, mocked_client): scan_control, mocked_client, monkeypatch
):
xread = MagicMock(wraps=mocked_client.connector.xread)
monkeypatch.setattr(mocked_client.connector, "xread", xread)
scan_control.comboBox_scan_selection.setCurrentText("line_scan")
assert scan_control.comboBox_scan_selection.currentText() == "line_scan"
scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
xread.assert_not_called()
def test_restore_last_scan_parameters_button_fetches_on_demand(
scan_control, mocked_client, monkeypatch
):
xread = MagicMock(wraps=mocked_client.connector.xread)
monkeypatch.setattr(mocked_client.connector, "xread", xread)
scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
scan_control.comboBox_scan_selection.setCurrentText("line_scan")
xread.assert_not_called()
scan_control.last_scan_button.click()
xread.assert_called_once_with(
MessageEndpoints.scan_history(), from_start=True, user_id=scan_control.object_name
)
args, kwargs = scan_control.get_scan_parameters(bec_object=False)
assert args == ["samx", 0.0, 2.0]
assert kwargs["steps"] == 10
assert kwargs["relative"] is False
assert kwargs["exp_time"] == 2
def test_get_scan_parameters_from_redis(scan_control):
scan_name = "line_scan" scan_name = "line_scan"
scan_control.comboBox_scan_selection.setCurrentText(scan_name) scan_control.comboBox_scan_selection.setCurrentText(scan_name)
scan_control.toggle.checked = True scan_control.last_scan_button.click()
args, kwargs = scan_control.get_scan_parameters(bec_object=False) args, kwargs = scan_control.get_scan_parameters(bec_object=False)
@@ -586,8 +623,7 @@ def test_scan_metadata_is_passed_to_scan_function(scan_control: ScanControl):
scans.grid_scan.assert_called_once_with(metadata=TEST_MD) scans.grid_scan.assert_called_once_with(metadata=TEST_MD)
@pytest.mark.skip(reason="Unreliable - GH issue #1134") def test_restore_parameters_with_fewer_arg_bundles(scan_control):
def test_restore_parameters_with_fewer_arg_bundles(scan_control, qtbot):
""" """
Ensure that when more argument bundles are present than exist in the Ensure that when more argument bundles are present than exist in the
stored history, restoring parameters regenerates the arg box to the stored history, restoring parameters regenerates the arg box to the
@@ -603,8 +639,7 @@ def test_restore_parameters_with_fewer_arg_bundles(scan_control, qtbot):
assert scan_control.arg_box.count_arg_rows() == 3 assert scan_control.arg_box.count_arg_rows() == 3
# Trigger restore of parameters from history # Trigger restore of parameters from history
scan_control.toggle.checked = True scan_control.last_scan_button.click()
qtbot.wait(200)
# After restore, arg_box should have only one bundle (the history size) # After restore, arg_box should have only one bundle (the history size)
assert scan_control.arg_box.count_arg_rows() == 1 assert scan_control.arg_box.count_arg_rows() == 1