diff --git a/datalab/adapters_metadata/common.py b/datalab/adapters_metadata/common.py
index d63052fc5..e334dff9a 100644
--- a/datalab/adapters_metadata/common.py
+++ b/datalab/adapters_metadata/common.py
@@ -55,6 +55,7 @@ class ResultData:
results: list[BaseResultAdapter] | None = None
ylabels: list[str] | None = None
short_ids: list[str] | None = None
+ execution_success: bool = True
def __bool__(self) -> bool:
"""Return True if there are results stored"""
diff --git a/datalab/aiassistant/tools/builtin.py b/datalab/aiassistant/tools/builtin.py
index 89972df12..9a217d724 100644
--- a/datalab/aiassistant/tools/builtin.py
+++ b/datalab/aiassistant/tools/builtin.py
@@ -30,6 +30,7 @@
from datalab.aiassistant.providers.base import ChatMessage
from datalab.aiassistant.tools.registry import Tool, ToolRegistry, ToolResult
from datalab.gui.actionhandler import ActionCategory
+from datalab.objectmodel import get_uuid
if TYPE_CHECKING:
from datalab.control.proxy import LocalProxy
@@ -412,7 +413,7 @@ def _tool_load_file(
return {
"filename": filename,
"panel": panel_widget.PANEL_STR_ID,
- "loaded": [{"uuid": o.uuid, "title": o.title} for o in objs],
+ "loaded": [{"uuid": get_uuid(o), "title": o.title} for o in objs],
}
diff --git a/datalab/config.py b/datalab/config.py
index c754cd6b3..b74e7bf54 100644
--- a/datalab/config.py
+++ b/datalab/config.py
@@ -297,6 +297,11 @@ class ProcSection(conf.Section, metaclass=conf.SectionMeta):
# - False: do not ignore warnings
ignore_warnings = conf.Option()
+ # Automatically start recording history at DataLab launch:
+ # - True: history recording is enabled at startup (default)
+ # - False: user must enable it manually via the History panel toolbar
+ history_auto_record = conf.Option()
+
# X-array compatibility behavior for multi-signal computations:
# - "ask": ask user for confirmation when x-arrays are incompatible (default)
# - "interpolate": automatically interpolate when x-arrays are incompatible
@@ -643,6 +648,7 @@ def initialize():
Conf.proc.keep_results.get(False)
Conf.proc.show_result_dialog.get(True)
Conf.proc.ignore_warnings.get(False)
+ Conf.proc.history_auto_record.get(False)
Conf.proc.xarray_compat_behavior.get("ask")
Conf.proc.small_mono_font.get((configtools.MONOSPACE, 8, False))
# View section
diff --git a/datalab/data/icons/edit_mode.svg b/datalab/data/icons/edit_mode.svg
new file mode 100644
index 000000000..6afff8120
--- /dev/null
+++ b/datalab/data/icons/edit_mode.svg
@@ -0,0 +1,44 @@
+
+
+
+
diff --git a/datalab/data/icons/record.svg b/datalab/data/icons/record.svg
new file mode 100644
index 000000000..5017f50e7
--- /dev/null
+++ b/datalab/data/icons/record.svg
@@ -0,0 +1,43 @@
+
+
+
+
diff --git a/datalab/data/icons/replay.svg b/datalab/data/icons/replay.svg
new file mode 100644
index 000000000..7dd374a1f
--- /dev/null
+++ b/datalab/data/icons/replay.svg
@@ -0,0 +1,51 @@
+
+
diff --git a/datalab/data/icons/restore_and_replay.svg b/datalab/data/icons/restore_and_replay.svg
new file mode 100644
index 000000000..f27f163c3
--- /dev/null
+++ b/datalab/data/icons/restore_and_replay.svg
@@ -0,0 +1,66 @@
+
+
diff --git a/datalab/data/icons/restore_selection.svg b/datalab/data/icons/restore_selection.svg
new file mode 100644
index 000000000..156b7126c
--- /dev/null
+++ b/datalab/data/icons/restore_selection.svg
@@ -0,0 +1,52 @@
+
+
diff --git a/datalab/gui/actionhandler.py b/datalab/gui/actionhandler.py
index 3ca8827d6..322d238da 100644
--- a/datalab/gui/actionhandler.py
+++ b/datalab/gui/actionhandler.py
@@ -370,8 +370,8 @@ def _delete_single_result(
adapter: Adapter for the result to delete
"""
# Check if this result matches the stored analysis parameters
- # If so, clear them to prevent auto-recompute from attempting to
- # recompute the deleted analysis when ROI changes
+ # If so, clear them to prevent a manual recompute from attempting to
+ # recompute the deleted analysis
analysis_params = extract_analysis_parameters(obj)
if (
analysis_params is not None
@@ -829,9 +829,12 @@ def create_first_actions(self):
_("Recompute"),
icon_name="recompute.svg",
shortcut="Ctrl+R",
- tip=_("Recompute selected %s with its processing parameters")
+ tip=_(
+ "Recompute selected %s: refresh both processing and "
+ "analysis results with their stored parameters"
+ )
% self.OBJECT_STR,
- triggered=self.panel.recompute_processing,
+ triggered=self.panel.recompute_selected,
select_condition=SelectCond.at_least_one_group_or_one_object,
context_menu_pos=-1,
toolbar_pos=-1,
diff --git a/datalab/gui/creation.py b/datalab/gui/creation.py
new file mode 100644
index 000000000..24cf249c2
--- /dev/null
+++ b/datalab/gui/creation.py
@@ -0,0 +1,121 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Dependency-neutral object creation services."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import guidata.dataset as gds
+import numpy as np
+from sigima.objects import (
+ CustomSignalParam,
+ Gauss2DParam,
+ ImageDatatypes,
+ ImageObj,
+ NewImageParam,
+ NewSignalParam,
+ SignalObj,
+ create_signal,
+)
+from sigima.objects import create_image_from_param as create_image_headless
+from sigima.objects import create_signal_from_param as create_signal_headless
+from sigima.objects.base import BaseProcParam
+from sigima.objects.signal import DEFAULT_TITLE as SIGNAL_DEFAULT_TITLE
+
+from datalab.config import _
+
+if TYPE_CHECKING:
+ from qtpy import QtWidgets as QW
+
+CREATION_PARAMETERS_OPTION = "creation_param_json"
+
+
+def insert_creation_parameters(obj: SignalObj | ImageObj, param: gds.DataSet) -> None:
+ """Insert creation parameters into object metadata.
+
+ Args:
+ obj: Object receiving the serialized parameters.
+ param: Creation parameters.
+ """
+ obj.set_metadata_option(CREATION_PARAMETERS_OPTION, gds.dataset_to_json(param))
+
+
+def extract_creation_parameters(obj: SignalObj | ImageObj) -> gds.DataSet | None:
+ """Extract creation parameters from object metadata.
+
+ Args:
+ obj: Object containing serialized creation parameters.
+
+ Returns:
+ Creation parameters or None if not found.
+ """
+ try:
+ param_json = obj.get_metadata_option(CREATION_PARAMETERS_OPTION)
+ except ValueError:
+ return None
+ return gds.json_to_dataset(param_json)
+
+
+def create_signal_from_param(param: NewSignalParam) -> SignalObj:
+ """Create a signal from initialized parameters."""
+ if isinstance(param, CustomSignalParam):
+ signal = create_signal(param.title)
+ signal.xydata = param.xyarray.T
+ if signal.title == SIGNAL_DEFAULT_TITLE:
+ signal.title = f"custom(npts={param.size})"
+ return signal
+ signal = create_signal_headless(param)
+ if param.__class__ is not NewSignalParam:
+ insert_creation_parameters(signal, param)
+ return signal
+
+
+def prepare_signal_parameters(
+ param: NewSignalParam | None,
+ edit: bool,
+ parent: QW.QWidget | None = None,
+) -> NewSignalParam | None:
+ """Initialize and optionally edit signal creation parameters."""
+ if param is None:
+ param = NewSignalParam()
+ edit = True
+ if isinstance(param, CustomSignalParam):
+ edit = True
+ if isinstance(param, CustomSignalParam) and edit:
+ initial = NewSignalParam(_("Custom signal"))
+ initial.size = 10
+ if not initial.edit(parent=parent):
+ return None
+ param.setup_array(size=initial.size, xmin=initial.xmin, xmax=initial.xmax)
+ if edit and not param.edit(parent=parent):
+ return None
+ return param
+
+
+def initialize_image_parameters(param: NewImageParam) -> None:
+ """Fill image creation defaults required by the editor and constructor."""
+ if param.height is None:
+ param.height = 500
+ if param.width is None:
+ param.width = 500
+ if param.dtype is None:
+ param.dtype = ImageDatatypes.UINT16
+ numpy_dtype = param.dtype.to_numpy_dtype()
+ if isinstance(param, Gauss2DParam):
+ if param.a is None:
+ try:
+ param.a = np.iinfo(numpy_dtype).max / 2.0
+ except ValueError:
+ param.a = 10.0
+ elif isinstance(param, BaseProcParam):
+ param.set_from_datatype(numpy_dtype)
+
+
+def create_image_from_param(param: NewImageParam) -> ImageObj:
+ """Create an image from initialized parameters."""
+ initialize_image_parameters(param)
+ image = create_image_headless(param)
+ if param.__class__ is not NewImageParam:
+ insert_creation_parameters(image, param)
+ return image
diff --git a/datalab/gui/historysession_ops.py b/datalab/gui/historysession_ops.py
new file mode 100644
index 000000000..3efe207b1
--- /dev/null
+++ b/datalab/gui/historysession_ops.py
@@ -0,0 +1,348 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Helpers for History panel session recording and indexing."""
+
+from __future__ import annotations
+
+from contextlib import contextmanager
+from copy import deepcopy
+from typing import TYPE_CHECKING, Any, Generator
+
+from qtpy import QtWidgets as QW
+
+from datalab.config import _
+from datalab.env import execenv
+from datalab.gui.panel.history import chain as hchain
+from datalab.history import HistoryAction, HistorySession, WorkspaceState
+
+if TYPE_CHECKING:
+ from datalab.gui.panel.history import HistoryPanel
+
+
+def create_new_session(
+ panel: HistoryPanel, panel_str: str | None = None
+) -> HistorySession:
+ """Create a new history session and make it active for its panel.
+
+ Args:
+ panel_str: Panel the new session belongs to ("signal"/"image").
+ Defaults to the current data panel.
+
+ Returns:
+ The newly created session.
+ """
+ pstr = panel_str or panel.navigation.current_panel_str()
+ panel.navigation.session_increment += 1
+ session = HistorySession(number=panel.navigation.session_increment)
+ panel.history_sessions.append(session)
+ panel.navigation.set_active_session(session, pstr)
+ panel.tree.populate_tree(panel.history_sessions)
+ panel.refresh_compatibility_items()
+ return session
+
+
+def start_new_session_after_workspace_reset(panel: HistoryPanel) -> None:
+ """Start a new history session after a workspace reset, when useful."""
+ if panel.history_sessions and panel.history_sessions[-1].actions:
+ panel.create_new_session()
+
+
+def maybe_start_session_for_input(panel: HistoryPanel, *, load: bool = False) -> None:
+ """Offer to start a new history session before a creation/load is recorded.
+
+ When the current session already contains actions, prompt the user to start
+ a fresh session so the new creation/load becomes the root of a clean,
+ self-contained pipeline. A new session is opened *before* the action is
+ recorded when the user accepts.
+
+ Args:
+ load: True when triggered by a file/workspace load, False for an object
+ creation. Only affects the prompt wording.
+ """
+ if not panel.record_mode_enabled or panel.is_replaying():
+ return
+ if panel.runtime.execution.suppress_session_prompt:
+ return
+ # Reuse the current session when there is none yet or it has no actions:
+ # there is nothing to preserve, so no need to prompt.
+ if not panel.history_sessions or not panel.history_sessions[-1].actions:
+ return
+ # Debounce: a synchronous burst of creations (plugin/macro) must prompt only
+ # once. The guard is reset on the next event-loop turn.
+ if not panel.runtime.execution.start_session_input_prompt():
+ return
+ if execenv.unattended:
+ # Headless runs: honor the accept_dialogs flag (default False -> "No"),
+ # so tests can drive the behavior without a real modal dialog.
+ if execenv.accept_dialogs:
+ panel.create_new_session()
+ return
+ if load:
+ message = _("A new object was loaded. Start a new history session?")
+ else:
+ message = _("A new object was created. Start a new history session?")
+ answer = QW.QMessageBox.question(
+ panel.mainwindow,
+ _("New history session"),
+ message,
+ QW.QMessageBox.Yes | QW.QMessageBox.No,
+ )
+ if answer == QW.QMessageBox.Yes:
+ panel.create_new_session()
+
+
+def add_compute_entry(
+ panel: HistoryPanel,
+ action_title: str,
+ panel_str: str,
+ func_name: str,
+ pattern: str,
+ save_state: bool = True,
+ output_uuids: list[str] | None = None,
+ plugin_origin: dict[str, Any] | None = None,
+ **kwargs: Any,
+) -> HistoryAction | None:
+ """Record a *compute* action in the current history session.
+
+ Args:
+ action_title: Title shown in the history tree.
+ panel_str: ``"signal"`` or ``"image"``.
+ func_name: Sigima feature name (resolvable via
+ :meth:`BaseProcessor.get_feature`).
+ pattern: One of ``"1_to_1"``, ``"1_to_0"``, ``"n_to_1"``, ``"2_to_1"``,
+ ``"1_to_n"``, ``"multiple_1_to_1"`` (the latter is recorded for
+ traceability but not replayable).
+ save_state: If True, capture the workspace state for replay.
+ output_uuids: Optional list of UUIDs of the data objects produced by
+ this action. When known at call time, prefer passing it here so the
+ bijective mapping is initialised in one step. Most callers do not
+ know the outputs yet and instead wrap the compute call with
+ :meth:`capture_outputs` (or call :meth:`register_action_outputs`
+ explicitly afterwards) using the returned action.
+ plugin_origin: Optional plugin origin descriptor (see
+ :func:`datalab.gui.processor.base._detect_plugin_origin`). ``None``
+ for built-in Sigima/DataLab features.
+ **kwargs: Extra primitive kwargs (``param``, ``obj2_uuids``,
+ ``obj2_name``, ``pairwise``, ``params`` (list of DataSet),
+ ``func_names`` (list of str), ...). ``DataSet`` instances are
+ serialised as JSON.
+
+ Returns:
+ The created :class:`HistoryAction`, or ``None`` if recording is
+ disabled (record mode off or replay in progress).
+ """
+ if not panel.record_mode_enabled or panel.is_replaying():
+ return None
+ state = WorkspaceState()
+ if save_state:
+ state.save(panel.mainwindow, panel_str=panel_str)
+ # Deep-copy kwargs so each action owns independent parameter
+ # instances. Without this, consecutive applications of the same
+ # function (e.g. two gaussian_filter calls with different sigma)
+ # would share a single DataSet object and editing one action's
+ # parameters would silently mutate the other.
+ action = HistoryAction(
+ title=action_title,
+ kind=HistoryAction.KIND_COMPUTE,
+ panel_str=panel_str,
+ func_name=func_name,
+ pattern=pattern,
+ kwargs=deepcopy(kwargs),
+ state=state,
+ )
+ action.plugin_origin = deepcopy(plugin_origin)
+ panel.add_object(action)
+ if output_uuids is not None:
+ panel.register_action_outputs(action, output_uuids)
+ return action
+
+
+def add_compute_entry_from_pp(
+ panel: HistoryPanel,
+ action_title: str,
+ pp: Any, # ProcessingParameters (avoid circular import)
+ panel_str: str,
+ save_state: bool = True,
+ output_uuids: list[str] | None = None,
+ plugin_origin: dict[str, Any] | None = None,
+ **extras: Any,
+) -> HistoryAction | None:
+ """Record a *compute* action derived from a ``ProcessingParameters``.
+
+ Bridges the dash-form pattern used in object metadata
+ (``"1-to-1"`` …) with the underscore form expected by
+ :class:`HistoryAction` (``"1_to_1"`` …) so that both sides share
+ a single identity (``func_name`` / ``pattern`` / ``param``).
+
+ Args:
+ action_title: Title shown in the history tree.
+ pp: :class:`~datalab.gui.processor.base.ProcessingParameters`
+ instance describing the operation.
+ panel_str: ``"signal"`` or ``"image"``.
+ save_state: If True, capture the workspace state for replay.
+ output_uuids: Optional list of UUIDs of the data objects produced
+ by this action (see :meth:`add_compute_entry`).
+ plugin_origin: Optional plugin origin descriptor (see
+ :meth:`add_compute_entry`).
+ **extras: Additional history-only kwargs (``obj2_uuids``,
+ ``obj2_name``, ``pairwise``, ``params``, ``func_names``…).
+
+ Returns:
+ The created :class:`HistoryAction`, or ``None`` if recording is
+ disabled.
+ """
+ hist_pattern = pp.pattern.replace("-", "_")
+ kwargs: dict[str, Any] = {}
+ if pp.param is not None and "param" not in extras and "params" not in extras:
+ kwargs["param"] = pp.param
+ kwargs.update(extras)
+ return panel.add_compute_entry(
+ action_title,
+ panel_str=panel_str,
+ func_name=pp.func_name,
+ pattern=hist_pattern,
+ save_state=save_state,
+ output_uuids=output_uuids,
+ plugin_origin=plugin_origin,
+ **kwargs,
+ )
+
+
+def register_action_outputs(
+ panel: HistoryPanel, action: HistoryAction, output_uuids: list[str]
+) -> None:
+ """Register the data objects produced by ``action``.
+
+ Maintains the bijective ``action → outputs`` and ``output → action``
+ mappings. May be called multiple times for a given action (later calls
+ replace earlier ones, e.g. after a cascade recompute).
+
+ Args:
+ action: The history action that produced the outputs.
+ output_uuids: UUIDs of the produced data objects (empty for
+ ``1_to_0`` analysis patterns and for UI actions that did not
+ create new objects).
+ """
+ panel.runtime.objects.register_action_outputs(action, output_uuids)
+
+
+@contextmanager
+def capture_outputs(
+ panel: HistoryPanel, action: HistoryAction | None
+) -> Generator[None, None, None]:
+ """Context manager: snapshot panel object IDs and record diffs as outputs.
+
+ Use around any compute call when the produced UUIDs are not known
+ upfront. On exit, every newly-added object (signal or image) is
+ registered as an output of ``action`` via
+ :meth:`register_action_outputs`. No-op when ``action`` is ``None``
+ (recording disabled).
+
+ Args:
+ action: The history action being processed, or ``None``.
+ """
+ if action is None:
+ yield
+ return
+ panels = (panel.mainwindow.signalpanel, panel.mainwindow.imagepanel)
+ before = {p.PANEL_STR_ID: set(p.objmodel.get_object_ids()) for p in panels}
+ try:
+ yield
+ finally:
+ new_uuids: list[str] = []
+ for p in panels:
+ before_p = before[p.PANEL_STR_ID]
+ for uid in p.objmodel.get_object_ids():
+ if uid not in before_p:
+ new_uuids.append(uid)
+ panel.register_action_outputs(action, new_uuids)
+ if (
+ not new_uuids
+ and action.kind == HistoryAction.KIND_COMPUTE
+ and action.pattern in {"1_to_1", "1_to_n", "n_to_1", "2_to_1"}
+ ):
+ # The compute produced no output object for any selected input:
+ # it failed (or was a full no-op). Do not keep a misleading "OK"
+ # entry in the history — remove the just-recorded action and
+ # refresh the tree so the panel stays consistent.
+ hchain.remove_single_action(panel, action)
+ panel.tree.populate_tree(panel.history_sessions)
+ panel.refresh_compatibility_items()
+ panel.ui.update_actions_state()
+
+
+def add_ui_entry(
+ panel: HistoryPanel,
+ action_title: str,
+ target: str,
+ method_name: str,
+ save_state: bool = True,
+ **kwargs: Any,
+) -> HistoryAction | None:
+ """Record a *UI* action in the current history session.
+
+ Args:
+ action_title: Title shown in the history tree.
+ target: One of ``"mainwindow"``, ``"signalpanel"``, ``"imagepanel"``,
+ ``"historypanel"`` -- attribute path on the main window.
+ method_name: Method name to call on ``target`` at replay time.
+ save_state: If True, capture the workspace state for replay.
+ **kwargs: Method keyword arguments. ``DataSet`` instances are
+ serialised as JSON; other values must be HDF5-friendly primitives.
+
+ Returns:
+ The created :class:`HistoryAction`, or ``None`` if recording is
+ disabled (record mode off or replay in progress).
+ """
+ if not panel.record_mode_enabled or panel.is_replaying():
+ return None
+ # When the entry is an object creation, offer to start a fresh history
+ # session first so the creation becomes the root of a clean pipeline.
+ if method_name in HistoryAction.UI_CREATION_METHODS:
+ panel.maybe_start_session_for_input(load=False)
+ # Derive the action's panel from the UI target so the captured state only
+ # constrains the panel the action actually operates on.
+ target_panel_str = {
+ "signalpanel": "signal",
+ "imagepanel": "image",
+ "signalprocessor": "signal",
+ "imageprocessor": "image",
+ }.get(target)
+ state = WorkspaceState()
+ if save_state:
+ state.save(panel.mainwindow, panel_str=target_panel_str)
+ # Deep-copy kwargs to ensure independent parameter ownership
+ # (same rationale as in add_compute_entry).
+ action = HistoryAction(
+ title=action_title,
+ kind=HistoryAction.KIND_UI,
+ target=target,
+ method_name=method_name,
+ kwargs=deepcopy(kwargs),
+ state=state,
+ panel_str=target_panel_str
+ if target in ("signalprocessor", "imageprocessor")
+ else None,
+ )
+ panel.add_object(action)
+ return action
+
+
+def add_object(panel: HistoryPanel, obj: HistoryAction) -> None:
+ """Add an action to the active session of its panel.
+
+ Routes the action to the active recording session for the action's panel
+ ("signal"/"image"), creating a dedicated session on first use, so signal
+ and image pipelines stay in separate sessions and recording resumes in the
+ user-selected session.
+ """
+ pstr = obj.panel_str or panel.navigation.current_panel_str()
+ session = panel.navigation.get_active_session(pstr)
+ if session is None:
+ session = panel.create_new_session(panel_str=pstr)
+ session.add_action(obj)
+ session_index = panel.history_sessions.index(session)
+ panel.tree.rebuild_session(session_index)
+ panel.tree.rearrange_tree()
+ panel.refresh_compatibility_items()
+ panel.ui.update_actions_state()
diff --git a/datalab/gui/historytools_ops.py b/datalab/gui/historytools_ops.py
new file mode 100644
index 000000000..5b77babf6
--- /dev/null
+++ b/datalab/gui/historytools_ops.py
@@ -0,0 +1,651 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Helpers for History panel session tools."""
+
+from __future__ import annotations
+
+from copy import deepcopy
+from typing import TYPE_CHECKING, Any
+from uuid import uuid4
+
+from qtpy import QtWidgets as QW
+
+from datalab.config import _
+from datalab.env import execenv
+from datalab.gui.panel.history import chain as hchain
+from datalab.gui.panel.history.chainmodel import (
+ ChainSelectionPlan,
+ DeletionPlan,
+ DeletionResult,
+ DuplicatedSession,
+ ProcessingChain,
+ UuidCloneRegistry,
+ action_input_uuids,
+ build_session_chains,
+ remap_processing_parameters,
+)
+from datalab.gui.processor.base import (
+ PROCESSING_PARAMETERS_OPTION,
+ ProcessingParameters,
+ extract_processing_parameters,
+ insert_processing_parameters,
+)
+from datalab.history import HistoryAction, HistorySession
+from datalab.history.workspace_state import WorkspaceState
+from datalab.objectmodel import get_uuid
+
+if TYPE_CHECKING:
+ from datalab.gui.panel.base import BaseDataPanel
+ from datalab.gui.panel.history import HistoryPanel
+
+
+def action_panel_str(action: HistoryAction) -> str:
+ """Return the panel an action operates on, using target as fallback."""
+ if action.panel_str:
+ return action.panel_str
+ return {"imagepanel": "image", "signalpanel": "signal"}.get(action.target, "signal")
+
+
+def make_initial_state_head(pstr: str, clone_uuid: str, title: str) -> HistoryAction:
+ """Return a synthetic creation-root action for an operation-rooted chain.
+
+ A *Cas B* chain starts from an operation whose input object was created
+ outside the chain (e.g. imported or added programmatically). To make the
+ duplicated chain self-contained and replayable, a synthetic ``new_object``
+ UI action is prepended, standing in for that missing object creation. Its
+ empty workspace state mirrors a real ``new_object`` recorded with
+ ``save_state=False`` (hence always compatible), and its ``new_object``
+ method name places it in :attr:`HistoryAction.UI_CREATION_METHODS` so
+ :func:`build_session_chains` treats it as a genuine chain root.
+
+ Args:
+ pstr: Panel string of the created object (``"signal"``/``"image"``).
+ clone_uuid: UUID of the cloned source object produced as head output.
+ title: Title shown for the synthetic action in the tree.
+
+ Returns:
+ A new :class:`HistoryAction` describing the synthetic creation root.
+ """
+ target = "signalpanel" if pstr == "signal" else "imagepanel"
+ head = HistoryAction(
+ title=title,
+ kind=HistoryAction.KIND_UI,
+ target=target,
+ method_name="new_object",
+ kwargs={},
+ state=WorkspaceState(),
+ panel_str=None,
+ )
+ head.output_uuids = [clone_uuid]
+ return head
+
+
+def append_action_chain(
+ action: HistoryAction,
+ all_chains: list[ProcessingChain],
+ seen_chains: set[int],
+ chains: list[ProcessingChain],
+) -> None:
+ """Append the unseen processing chain containing the selected action."""
+ for chain in all_chains:
+ if any(item is action or item.uuid == action.uuid for item in chain.actions):
+ if id(chain) not in seen_chains:
+ seen_chains.add(id(chain))
+ chains.append(chain)
+ break
+
+
+def resolve_chain_selection(panel: HistoryPanel) -> list[ChainSelectionPlan]:
+ """Resolve tree selection to ordered processing chains per source session."""
+ selected = panel.tree.get_selected_actions_or_sessions(panel.history_sessions)
+ session_by_id: dict[int, HistorySession] = {}
+ full_session_ids: set[int] = set()
+ actions_by_session: dict[int, list[HistoryAction]] = {}
+ for item in selected:
+ if isinstance(item, HistorySession):
+ session_by_id[id(item)] = item
+ full_session_ids.add(id(item))
+ else:
+ session = hchain.find_parent_session(panel, item)
+ if session is None:
+ continue
+ session_by_id[id(session)] = session
+ actions_by_session.setdefault(id(session), []).append(item)
+
+ # Preserve source-session order (iterate panel.history_sessions).
+ plans: list[ChainSelectionPlan] = []
+ for session in panel.history_sessions:
+ sid = id(session)
+ if sid not in session_by_id:
+ continue
+ all_chains = build_session_chains(session)
+ if sid in full_session_ids:
+ chains = all_chains
+ else:
+ chains = []
+ seen_chains: set[int] = set()
+ for action in actions_by_session.get(sid, []):
+ append_action_chain(action, all_chains, seen_chains, chains)
+ if chains:
+ plans.append(ChainSelectionPlan(session, chains))
+ return plans
+
+
+def collect_referenced_uuids(chains: list[ProcessingChain]) -> dict[str, set[str]]:
+ """Collect object UUIDs referenced or produced by selected chains."""
+ uuids_by_panel: dict[str, set[str]] = {}
+ for chain in chains:
+ for action in chain.actions:
+ for panel_str, uuids in action.state.selection.items():
+ uuids_by_panel.setdefault(panel_str, set()).update(uuids)
+ for panel_str, metadata in action.state.object_metadata.items():
+ uuids_by_panel.setdefault(panel_str, set()).update(metadata)
+ obj2_uuids = action.kwargs.get("obj2_uuids")
+ if obj2_uuids:
+ panel_str = action_panel_str(action)
+ if isinstance(obj2_uuids, str):
+ obj2_uuids = [obj2_uuids]
+ uuids_by_panel.setdefault(panel_str, set()).update(obj2_uuids)
+ if action.output_uuids:
+ panel_str = action_panel_str(action)
+ uuids_by_panel.setdefault(panel_str, set()).update(action.output_uuids)
+ return uuids_by_panel
+
+
+def set_object_uuid(obj: Any, new_uuid: str) -> None:
+ """Set a cloned data object's UUID through its supported storage API."""
+ try:
+ obj.set_metadata_option("uuid", new_uuid)
+ except AttributeError:
+ obj.uuid = new_uuid
+
+
+def clone_referenced_objects(
+ panel: HistoryPanel, plan: ChainSelectionPlan, copy_suffix: str
+) -> UuidCloneRegistry:
+ """Clone a selection plan's objects and register source-to-clone UUIDs."""
+ registry = UuidCloneRegistry()
+ group_title = f"{copy_suffix} - {plan.source_session.title}"
+ for panel_str, referenced in collect_referenced_uuids(plan.chains).items():
+ data_panel = data_panel_for(panel, panel_str)
+ if data_panel is None:
+ continue
+ ordered_uuids = [
+ obj_uuid
+ for obj_uuid in data_panel.objmodel.get_object_ids()
+ if obj_uuid in referenced
+ ]
+ clones: list[Any] = []
+ for old_uuid in ordered_uuids:
+ clone = deepcopy(data_panel.objmodel[old_uuid])
+ new_uuid = str(uuid4())
+ set_object_uuid(clone, new_uuid)
+ registry.register(panel_str, old_uuid, new_uuid, clone)
+ clones.append(clone)
+ if clones:
+ group_id = get_uuid(data_panel.add_group(group_title))
+ for clone in clones:
+ data_panel.add_object(clone, group_id=group_id)
+ return registry
+
+
+def remap_cloned_object_sources(registry: UuidCloneRegistry) -> None:
+ """Rewrite processing-parameter source UUIDs in all cloned objects."""
+ for panel_str, clones in registry.clones_by_panel.items():
+ panel_remap = registry.uuid_remap.get(panel_str, {})
+ for clone in clones:
+ try:
+ parameters_dict = clone.get_metadata_option(
+ PROCESSING_PARAMETERS_OPTION
+ )
+ except (AttributeError, ValueError):
+ continue
+ if not parameters_dict:
+ continue
+ try:
+ parameters = ProcessingParameters.from_dict(parameters_dict)
+ except (TypeError, ValueError, AttributeError):
+ continue
+ remapped = remap_processing_parameters(parameters, panel_remap)
+ if remapped == parameters:
+ continue
+ try:
+ clone.set_metadata_option(
+ PROCESSING_PARAMETERS_OPTION, remapped.to_dict()
+ )
+ except (AttributeError, ValueError):
+ continue
+
+
+def chain_root_inputs(action: HistoryAction) -> list[tuple[str, str]]:
+ """Return panel-qualified source UUIDs consumed by a chain root."""
+ inputs = [
+ (panel_str, old_uuid)
+ for panel_str, uuids in action.state.selection.items()
+ for old_uuid in uuids
+ ]
+ obj2_uuids = action.kwargs.get("obj2_uuids")
+ if isinstance(obj2_uuids, str):
+ obj2_uuids = [obj2_uuids]
+ if obj2_uuids:
+ panel_str = action_panel_str(action)
+ inputs.extend((panel_str, old_uuid) for old_uuid in obj2_uuids)
+ return inputs
+
+
+def make_synthetic_heads(
+ panel: HistoryPanel, chain: ProcessingChain, registry: UuidCloneRegistry
+) -> list[HistoryAction]:
+ """Create one independent creation head per cloned external root input."""
+ heads: list[HistoryAction] = []
+ seen_clones: set[str] = set()
+ for panel_str, old_uuid in chain_root_inputs(chain.root):
+ clone_uuid = registry.resolve(panel_str, old_uuid)
+ if clone_uuid is None or clone_uuid in seen_clones:
+ continue
+ seen_clones.add(clone_uuid)
+ head_title = _("Initial state")
+ data_panel = data_panel_for(panel, panel_str)
+ if data_panel is not None:
+ try:
+ head_title = data_panel.objmodel[clone_uuid].title
+ except (KeyError, AttributeError):
+ pass
+ heads.append(make_initial_state_head(panel_str, clone_uuid, head_title))
+ return heads
+
+
+def assemble_duplicated_actions(
+ panel: HistoryPanel, plan: ChainSelectionPlan, registry: UuidCloneRegistry
+) -> list[HistoryAction]:
+ """Copy selected actions and add heads for operation-rooted chains."""
+ new_actions: list[HistoryAction] = []
+ for chain in plan.chains:
+ is_creation_root = (
+ chain.root.kind == HistoryAction.KIND_UI
+ and chain.root.method_name in HistoryAction.UI_CREATION_METHODS
+ )
+ if not is_creation_root:
+ new_actions.extend(make_synthetic_heads(panel, chain, registry))
+ new_actions.extend(
+ action.copy_with_uuid_remap(registry.uuid_remap) for action in chain.actions
+ )
+ return new_actions
+
+
+def register_session_outputs(panel: HistoryPanel, session: HistorySession) -> None:
+ """Register action-to-output mappings for one assembled session."""
+ for action in session.actions:
+ if not action.output_uuids:
+ continue
+ panel.runtime.objects.register_action_outputs(action, action.output_uuids)
+
+
+def duplicate_chain_plan(
+ panel: HistoryPanel, plan: ChainSelectionPlan, copy_suffix: str
+) -> DuplicatedSession:
+ """Clone objects and assemble one independent duplicated session."""
+ registry = clone_referenced_objects(panel, plan, copy_suffix)
+ remap_cloned_object_sources(registry)
+ panel.navigation.session_increment += 1
+ new_session = HistorySession(
+ title=f"{plan.source_session.title} {copy_suffix}",
+ number=panel.navigation.session_increment,
+ )
+ new_session.actions = assemble_duplicated_actions(panel, plan, registry)
+ register_session_outputs(panel, new_session)
+ return DuplicatedSession(plan.source_session, new_session)
+
+
+def insert_duplicated_sessions(
+ panel: HistoryPanel, duplicated_sessions: list[DuplicatedSession]
+) -> None:
+ """Insert duplicates after their sources and refresh/select the tree."""
+ for duplicated in reversed(duplicated_sessions):
+ source_index = panel.history_sessions.index(duplicated.source_session)
+ panel.history_sessions.insert(source_index + 1, duplicated.new_session)
+ panel.tree.populate_tree(panel.history_sessions)
+ panel.navigation.select_sessions([item.new_session for item in duplicated_sessions])
+ panel.refresh_compatibility_items()
+ panel.ui.update_actions_state()
+
+
+def duplicate_selected_entries(panel: HistoryPanel) -> None:
+ """Duplicate selected processing chains into new independent sessions."""
+ selection_plans = resolve_chain_selection(panel)
+ if not selection_plans:
+ return
+
+ copy_suffix = _("Copy")
+ duplicated_sessions = [
+ duplicate_chain_plan(panel, plan, copy_suffix) for plan in selection_plans
+ ]
+ insert_duplicated_sessions(panel, duplicated_sessions)
+
+
+def data_panel_for(panel: HistoryPanel, panel_str: str) -> BaseDataPanel | None:
+ """Return the data panel matching ``panel_str`` (``"signal"``/``"image"``)."""
+ if panel_str == "signal":
+ return panel.mainwindow.signalpanel
+ if panel_str == "image":
+ return panel.mainwindow.imagepanel
+ return None
+
+
+def strip_source_links(obj) -> None:
+ """Turn ``obj`` into a parentless creation root (drop source references)."""
+ pp = extract_processing_parameters(obj)
+ if pp is None:
+ return
+ insert_processing_parameters(
+ obj,
+ remap_processing_parameters(pp, {}, clear_sources=True),
+ )
+
+
+def remap_object_source(obj, old_uuid: str, new_uuid: str) -> None:
+ """Replace ``old_uuid`` with ``new_uuid`` in ``obj``'s source references."""
+ pp = extract_processing_parameters(obj)
+ if pp is None:
+ return
+ changed = False
+ if pp.source_uuid == old_uuid:
+ pp.source_uuid = new_uuid
+ changed = True
+ if pp.source_uuids and old_uuid in pp.source_uuids:
+ pp.source_uuids = [new_uuid if u == old_uuid else u for u in pp.source_uuids]
+ changed = True
+ if changed:
+ insert_processing_parameters(obj, pp)
+
+
+def first_alive_output(
+ panel: HistoryPanel, panel_str: str, output_uuids: list[str]
+) -> str | None:
+ """Return the first ``output_uuids`` entry still present in its data panel."""
+ data_panel = data_panel_for(panel, panel_str)
+ if data_panel is None:
+ return None
+ for out_uuid in output_uuids:
+ if data_panel.objmodel.has_uuid(out_uuid):
+ return out_uuid
+ return None
+
+
+def split_chain_on_action_delete(
+ panel: HistoryPanel, action: HistoryAction
+) -> str | None:
+ """Splice ``action`` out of its session and split its processing chain.
+
+ The action is removed from its session (splice, not truncate). If it had
+ downstream compute steps, the first downstream action becomes the head of a
+ new, independent chain: the deleted action's now-orphaned output object is
+ deep-copied into a new ``Chain copy`` group as a parentless creation root,
+ and the downstream head is rewired to consume that copy.
+
+ Args:
+ panel: The history panel owning sessions and the output registry.
+ action: The action to delete.
+
+ Returns:
+ The UUID of the deleted action's output object if it remains present
+ (now truly orphaned) in its data panel, otherwise ``None``.
+ """
+ panel_str = action.panel_str or ""
+ # Compute downstream + captured output UUIDs BEFORE removing the action.
+ downstream = hchain.get_downstream_actions(panel, action)
+ output_uuids = list(panel.runtime.objects.action_output_uuids.get(action.uuid, []))
+ # Splice the action out (does not truncate the rest of the session).
+ hchain.remove_single_action(panel, action)
+ if not downstream:
+ return first_alive_output(panel, panel_str, output_uuids)
+ first = downstream[0]
+ data_panel = data_panel_for(panel, first.panel_str or "")
+ if data_panel is None:
+ return first_alive_output(panel, panel_str, output_uuids)
+ # Locate the orphaned output object that ``first`` still consumes.
+ first_inputs = action_input_uuids(first)
+ orphan_uuid = next((u for u in output_uuids if u in first_inputs), None)
+ if orphan_uuid is None or not data_panel.objmodel.has_uuid(orphan_uuid):
+ return first_alive_output(panel, panel_str, output_uuids)
+ # §8.2 — autonomy via COPY: clone the orphan as a parentless creation root.
+ orphan_obj = data_panel.objmodel[orphan_uuid]
+ clone = deepcopy(orphan_obj)
+ new_uuid = str(uuid4())
+ try:
+ clone.set_metadata_option("uuid", new_uuid)
+ except AttributeError:
+ clone.uuid = new_uuid
+ strip_source_links(clone)
+ group_id = get_uuid(data_panel.add_group(_("Chain copy")))
+ data_panel.add_object(clone, group_id=group_id)
+ # Rewire ALL downstream actions that directly consume the orphan onto the copy.
+ for d in downstream:
+ if orphan_uuid not in action_input_uuids(d):
+ continue
+ hchain.rewrite_action_source(d, d.panel_str or "", orphan_uuid, new_uuid)
+ for out_uuid in panel.runtime.objects.action_output_uuids.get(d.uuid, []):
+ if data_panel.objmodel.has_uuid(out_uuid):
+ remap_object_source(
+ data_panel.objmodel[out_uuid], orphan_uuid, new_uuid
+ )
+ # The orphan is now consumed by no surviving action: report it as orphaned.
+ return orphan_uuid if data_panel.objmodel.has_uuid(orphan_uuid) else None
+
+
+def remove_data_object(data_panel: BaseDataPanel, obj_uuid: str) -> None:
+ """Remove a single object from ``data_panel`` without recording history."""
+ obj = data_panel.objmodel[obj_uuid]
+ data_panel.plothandler.remove_item(obj_uuid)
+ data_panel.objview.remove_item(obj_uuid, refresh=False)
+ data_panel.objmodel.remove_object(obj)
+
+
+def plan_deletion(
+ panel: HistoryPanel, selected: list[HistoryAction | HistorySession]
+) -> DeletionPlan:
+ """Classify selected history entities without mutating panel state."""
+ plan = DeletionPlan()
+ for item in selected:
+ if isinstance(item, HistorySession):
+ plan.session_ids.add(id(item))
+ continue
+ plan.actions.append(item)
+ if plan.affected_session is None:
+ plan.affected_session = hchain.find_parent_session(panel, item)
+ return plan
+
+
+def confirm_deletion(panel: HistoryPanel, plan: DeletionPlan) -> bool:
+ """Ask the user to confirm a planned history deletion."""
+ if plan.actions:
+ msg = _(
+ "Do you really want to delete the selected items?\n\n"
+ "Note: deleting an intermediate action splits its processing "
+ "chain; downstream steps become an independent chain."
+ )
+ else:
+ msg = _("Do you really want to delete the selected items?")
+ reply = (
+ QW.QMessageBox.Yes
+ if execenv.unattended
+ else QW.QMessageBox.question(
+ panel.mainwindow,
+ _("Delete"),
+ msg,
+ QW.QMessageBox.Yes | QW.QMessageBox.No,
+ QW.QMessageBox.No,
+ )
+ )
+ return reply == QW.QMessageBox.Yes
+
+
+def apply_deletion(panel: HistoryPanel, plan: DeletionPlan) -> DeletionResult:
+ """Delete planned actions/sessions and return orphan cleanup state."""
+ orphan_refs: list[tuple[str, str]] = []
+ for action in plan.actions:
+ orphan_uuid = split_chain_on_action_delete(panel, action)
+ if orphan_uuid is not None:
+ orphan_refs.append((action.panel_str or "", orphan_uuid))
+ for session in panel.history_sessions:
+ if id(session) in plan.session_ids:
+ for action in session.actions:
+ panel.runtime.objects.remove_action_outputs(action)
+ panel.history_sessions = [
+ session
+ for session in panel.history_sessions
+ if id(session) not in plan.session_ids
+ ]
+ return DeletionResult(
+ plan.affected_session,
+ plan.session_ids,
+ orphan_refs,
+ )
+
+
+def collect_alive_orphans(
+ panel: HistoryPanel, orphan_refs: list[tuple[str, str]]
+) -> list[tuple[BaseDataPanel, str]]:
+ """Resolve orphan references that are still present in data panels."""
+ alive_orphans: list[tuple[BaseDataPanel, str]] = []
+ for panel_str, orphan_uuid in orphan_refs:
+ data_panel = data_panel_for(panel, panel_str)
+ if data_panel is not None and data_panel.objmodel.has_uuid(orphan_uuid):
+ alive_orphans.append((data_panel, orphan_uuid))
+ return alive_orphans
+
+
+def confirm_orphan_removal(
+ panel: HistoryPanel, alive_orphans: list[tuple[BaseDataPanel, str]]
+) -> bool:
+ """Ask whether surviving output objects should also be removed."""
+ if not alive_orphans or execenv.unattended:
+ return False
+ answer = QW.QMessageBox.question(
+ panel.mainwindow,
+ _("Delete"),
+ _(
+ "The deleted action(s) produced object(s) still present in the "
+ "workspace. Do you want to remove the associated object(s) as well?"
+ ),
+ QW.QMessageBox.Yes | QW.QMessageBox.No,
+ QW.QMessageBox.No,
+ )
+ return answer == QW.QMessageBox.Yes
+
+
+def remove_orphan_objects(
+ panel: HistoryPanel, alive_orphans: list[tuple[BaseDataPanel, str]]
+) -> None:
+ """Remove confirmed orphan objects and refresh affected data panels."""
+ touched: dict[int, BaseDataPanel] = {}
+ for data_panel, orphan_uuid in alive_orphans:
+ if data_panel.objmodel.has_uuid(orphan_uuid):
+ remove_data_object(data_panel, orphan_uuid)
+ touched[id(data_panel)] = data_panel
+ for data_panel in touched.values():
+ data_panel.objview.update_tree()
+ data_panel.selection_changed(update_items=True)
+ panel.runtime.objects.refresh_obj_ids_snapshot()
+
+
+def refresh_history_after_deletion(panel: HistoryPanel) -> None:
+ """Refresh history presentation after applying a deletion."""
+ panel.tree.populate_tree(panel.history_sessions)
+ panel.refresh_compatibility_items()
+ panel.ui.update_actions_state()
+
+
+def select_after_deletion(panel: HistoryPanel, result: DeletionResult) -> None:
+ """Select the last action of the affected surviving session, or a fallback."""
+ target_item = None
+ affected_session = result.affected_session
+ if (
+ affected_session is not None
+ and id(affected_session) not in result.removed_session_ids
+ ):
+ try:
+ session_idx = panel.history_sessions.index(affected_session)
+ except ValueError:
+ session_idx = -1
+ if session_idx >= 0:
+ top = panel.tree.topLevelItem(session_idx)
+ if top is not None:
+ last_action_item = None
+ iterator = QW.QTreeWidgetItemIterator(top)
+ while iterator.value():
+ node = iterator.value()
+ if (
+ node.data(0, panel.tree.ITEM_KIND_ROLE)
+ == panel.tree.ITEM_ACTION
+ ):
+ last_action_item = node
+ iterator += 1
+ target_item = last_action_item if last_action_item is not None else top
+ if target_item is None and panel.tree.topLevelItemCount() > 0:
+ target_item = panel.tree.topLevelItem(panel.tree.topLevelItemCount() - 1)
+ if target_item is not None:
+ panel.tree.setCurrentItem(target_item)
+ target_item.setSelected(True)
+
+
+def delete_selected(panel: HistoryPanel) -> None:
+ """Delete selected actions or sessions through explicit GUI/mutation phases."""
+ selected = panel.tree.get_selected_actions_or_sessions(panel.history_sessions)
+ if not selected:
+ return
+ plan = plan_deletion(panel, selected)
+ if not confirm_deletion(panel, plan):
+ return
+ result = apply_deletion(panel, plan)
+ alive_orphans = collect_alive_orphans(panel, result.orphan_refs)
+ if confirm_orphan_removal(panel, alive_orphans):
+ remove_orphan_objects(panel, alive_orphans)
+ refresh_history_after_deletion(panel)
+ select_after_deletion(panel, result)
+
+
+def remove_incompatible_actions(panel: HistoryPanel) -> None:
+ """Remove all actions whose workspace state is incompatible.
+
+ Shows a confirmation dialog listing how many actions will be removed,
+ then purges them from their sessions. Empty sessions are also removed.
+ """
+ incompatible: list[tuple[HistorySession, HistoryAction]] = []
+ for session in panel.history_sessions:
+ for action in session.actions:
+ if not action.is_current_state_compatible(
+ panel.mainwindow, restore_selection=True
+ ):
+ incompatible.append((session, action))
+ if not incompatible:
+ if not execenv.unattended:
+ QW.QMessageBox.information(
+ panel.mainwindow,
+ _("Remove incompatible"),
+ _("All actions are compatible with the current workspace."),
+ )
+ return
+ reply = (
+ QW.QMessageBox.Yes
+ if execenv.unattended
+ else QW.QMessageBox.question(
+ panel.mainwindow,
+ _("Remove incompatible"),
+ _("%d incompatible action(s) will be removed. Continue?")
+ % len(incompatible),
+ QW.QMessageBox.Yes | QW.QMessageBox.No,
+ QW.QMessageBox.No,
+ )
+ )
+ if reply != QW.QMessageBox.Yes:
+ return
+ for session, action in incompatible:
+ if action in session.actions:
+ panel.runtime.objects.remove_action_outputs(action)
+ session.actions.remove(action)
+ # Remove empty sessions
+ panel.history_sessions = [s for s in panel.history_sessions if s.actions]
+ panel.tree.populate_tree(panel.history_sessions)
+ panel.refresh_compatibility_items()
+ panel.ui.update_actions_state()
diff --git a/datalab/gui/main.py b/datalab/gui/main.py
index 8c44cca12..a6a86783d 100644
--- a/datalab/gui/main.py
+++ b/datalab/gui/main.py
@@ -75,10 +75,10 @@
)
from datalab.gui.docks import DockablePlotWidget
from datalab.gui.h5io import H5InputOutput
-from datalab.gui.panel import base, image, macro, signal
+from datalab.gui.panel import base, history, image, macro, signal
from datalab.gui.pluginconfig import PluginConfigDialog
from datalab.gui.settings import AI_OPTION_NAMES, edit_settings
-from datalab.objectmodel import ObjectGroup
+from datalab.objectmodel import ObjectGroup, get_uuid
from datalab.plugins import PluginRegistry, discover_plugins, discover_v020_plugins
from datalab.utils import qthelpers as qth
from datalab.utils.qthelpers import (
@@ -178,6 +178,7 @@ def __init__(self, console=None, hide_on_close=False): # pylint: disable=too-ma
self.console: DockableConsole | None = None
self._startup_errors: list[str] = []
self.macropanel: MacroPanel | None = None
+ self.historypanel: history.HistoryPanel | None = None
self.aiassistantpanel = None # type: ignore[assignment]
self.main_toolbar: QW.QToolBar | None = None
@@ -197,6 +198,7 @@ def __init__(self, console=None, hide_on_close=False): # pylint: disable=too-ma
self.saveh5_action: QW.QAction | None = None
self.browseh5_action: QW.QAction | None = None
self.settings_action: QW.QAction | None = None
+ self.command_palette_action: QW.QAction | None = None
self.quit_action: QW.QAction | None = None
self.autorefresh_action: QW.QAction | None = None
self.showfirstonly_action: QW.QAction | None = None
@@ -735,12 +737,17 @@ def get_webapi_status(self) -> dict:
# ------Misc.
@property
def panels(self) -> tuple[AbstractPanel, ...]:
- """Return the tuple of implemented panels (signal, image)
+ """Return the tuple of implemented panels (signal, image, macro, history)
Returns:
Tuple of panels
"""
- return (self.signalpanel, self.imagepanel, self.macropanel)
+ return (
+ self.signalpanel,
+ self.imagepanel,
+ self.macropanel,
+ self.historypanel,
+ )
def __set_low_memory_state(self, state: bool) -> None:
"""Set memory warning state"""
@@ -893,8 +900,7 @@ def execute_post_show_actions(self) -> None:
self.check_stable_release()
self.check_for_previous_crash()
self.check_for_v020_plugins()
- tour = Conf.main.tour_enabled.get()
- if tour:
+ if not execenv.unattended and Conf.main.tour_enabled.get():
Conf.main.tour_enabled.set(False)
self.show_tour()
# Auto-start WebAPI server if environment variable is set
@@ -1020,6 +1026,7 @@ def setup(self, console: bool = False) -> None:
self.__flush_startup_errors()
self.__update_actions(update_other_data_panel=True)
self.__add_macro_panel()
+ self.__add_history_panel()
self.__add_aiassistant_panel()
self.__configure_panels()
# Now that everything is set up, we can restore the window state:
@@ -1733,6 +1740,14 @@ def __add_macro_panel(self) -> None:
self.tabifyDockWidget(self.docks[self.imagepanel], mdock)
self.docks[self.signalpanel].raise_()
+ def __add_history_panel(self) -> None:
+ """Add history panel"""
+ self.historypanel = history.HistoryPanel(self)
+ hdock = self.__add_dockwidget(self.historypanel, _("History Panel"))
+ self.docks[self.historypanel] = hdock
+ self.tabifyDockWidget(self.docks[self.macropanel], hdock)
+ self.docks[self.signalpanel].raise_()
+
def __add_aiassistant_panel(self) -> None:
"""Add AI Assistant panel"""
# Local import to keep AI assistant fully optional/loadable on demand
@@ -1927,6 +1942,12 @@ def __tab_index_changed(self, index: int) -> None:
dock = self.docks[self.tabwidget.widget(index)]
dock.raise_()
self.__update_actions()
+ if self.historypanel is not None:
+ widget = self.tabwidget.widget(index)
+ if widget is self.signalpanel:
+ self.historypanel.on_current_panel_changed("signal")
+ elif widget is self.imagepanel:
+ self.historypanel.on_current_panel_changed("image")
def __update_generic_menu(self, menu: QW.QMenu | None = None) -> None:
"""Update menu before showing up -- Generic method"""
@@ -2066,8 +2087,10 @@ def toggle_show_first_only(self, state: bool) -> None:
def reset_all(self) -> None:
"""Reset all application data"""
for panel in self.panels:
- if panel is not None:
+ if panel is not None and panel is not self.historypanel:
panel.remove_all_objects()
+ if self.historypanel is not None:
+ self.historypanel.start_new_session_after_workspace_reset()
@remote_controlled
def remove_object(self, force: bool = False) -> None:
@@ -2110,6 +2133,13 @@ def save_to_h5_file(self, filename=None) -> None:
)
if not filename:
return
+ self.historypanel.add_ui_entry(
+ _("Save to HDF5 file"),
+ target="mainwindow",
+ method_name="save_to_h5_file",
+ save_state=False,
+ filename=filename,
+ )
with qth.qt_try_loadsave_file(self, filename, "save"):
self.save_h5_workspace(filename)
@@ -2182,6 +2212,19 @@ def open_h5_files(
)
if not h5files:
return
+ if len(h5files) > 1:
+ entry_title = _("Open %d HDF5 files") % len(h5files)
+ else:
+ entry_title = _("Open HDF5 file")
+ self.historypanel.add_ui_entry(
+ entry_title,
+ target="mainwindow",
+ method_name="open_h5_files",
+ save_state=False,
+ h5files=h5files,
+ import_all=import_all,
+ reset_all=reset_all,
+ )
filenames, dsetnames = [], []
for fname_with_dset in h5files:
if "," in fname_with_dset:
@@ -2238,17 +2281,20 @@ def load_h5_workspace(self, h5files: list[str], reset_all: bool = False) -> None
Raises:
ValueError: If a file is not a valid native DataLab HDF5 file
"""
- for idx, filename in enumerate(h5files):
- filename = self.__check_h5file(filename, "load")
- success = self.h5inputoutput.open_file_headless(
- filename, reset_all=(reset_all and idx == 0)
- )
- if not success:
- raise ValueError(
- f"File '{filename}' is not a native DataLab HDF5 file. "
- f"Use the GUI menu or a macro with RemoteProxy to import "
- f"arbitrary HDF5 files."
+ # Offer a fresh history session for this load *before* recording anything.
+ self.historypanel.maybe_start_session_for_input(load=True)
+ with self.historypanel.session_prompt_suppressed():
+ for idx, filename in enumerate(h5files):
+ filename = self.__check_h5file(filename, "load")
+ success = self.h5inputoutput.open_file_headless(
+ filename, reset_all=(reset_all and idx == 0)
)
+ if not success:
+ raise ValueError(
+ f"File '{filename}' is not a native DataLab HDF5 file. "
+ f"Use the GUI menu or a macro with RemoteProxy to import "
+ f"arbitrary HDF5 files."
+ )
# Refresh panel trees after loading
self.repopulate_panel_trees()
@@ -2279,9 +2325,12 @@ def import_h5_file(self, filename: str, reset_all: bool | None = None) -> None:
separated by ":")
reset_all: Delete all DataLab signals/images before importing data
"""
- with qth.qt_try_loadsave_file(self, filename, "load"):
- filename = self.__check_h5file(filename, "load")
- self.h5inputoutput.import_files([filename], False, reset_all)
+ # Offer a fresh history session for this load *before* importing anything.
+ self.historypanel.maybe_start_session_for_input(load=True)
+ with self.historypanel.session_prompt_suppressed():
+ with qth.qt_try_loadsave_file(self, filename, "load"):
+ filename = self.__check_h5file(filename, "load")
+ self.h5inputoutput.import_files([filename], False, reset_all)
# This method is intentionally *not* remote controlled
# (see TODO regarding RemoteClient.add_object method)
@@ -2299,10 +2348,24 @@ def add_object(
if self.confirm_memory_state():
if isinstance(obj, SignalObj):
self.signalpanel.add_object(obj, group_id, set_current)
+ panel_str = "signal"
elif isinstance(obj, ImageObj):
self.imagepanel.add_object(obj, group_id, set_current)
+ panel_str = "image"
else:
raise TypeError(f"Unsupported object type {type(obj)}")
+ # Record a creation entry so objects added programmatically (plugins,
+ # macros, remote control) appear in the history. ``panel.add_object``
+ # deliberately does not record, so creations entering through this
+ # proxy boundary would otherwise be lost (notably the very first one).
+ action = self.historypanel.add_ui_entry(
+ _("New %s") % panel_str,
+ target=panel_str + "panel",
+ method_name="new_object",
+ save_state=False,
+ )
+ if action is not None:
+ self.historypanel.register_action_outputs(action, [get_uuid(obj)])
@remote_controlled
def set_object(self, obj: SignalObj | ImageObj) -> None:
@@ -2699,11 +2762,12 @@ def close_properly(self) -> bool:
if self.webapi_actions is not None:
self.webapi_actions.cleanup()
self.reset_all()
- self.__save_pos_size_and_state()
+ if not env.execenv.unattended:
+ self.__save_pos_size_and_state()
self.__unregister_plugins()
# Saving current tab for next session
- if self.tabwidget is not None:
+ if not env.execenv.unattended and self.tabwidget is not None:
Conf.main.current_tab.set(self.tabwidget.currentIndex())
execenv.log(self, "closed properly")
diff --git a/datalab/gui/newobject.py b/datalab/gui/newobject.py
index 1b646856f..2a6c3c0bd 100644
--- a/datalab/gui/newobject.py
+++ b/datalab/gui/newobject.py
@@ -10,8 +10,6 @@
"""
-# pylint: disable=invalid-name # Allows short reference names like x, y, ...
-
from __future__ import annotations
import guidata.dataset as gds
@@ -23,52 +21,39 @@
from qtpy import QtWidgets as QW
from sigima.objects import CustomSignalParam as OrigCustomSignalParam
from sigima.objects import (
- Gauss2DParam,
- ImageDatatypes,
ImageObj,
NewImageParam,
NewSignalParam,
SignalObj,
- create_signal,
)
-from sigima.objects import create_image_from_param as create_image_headless
-from sigima.objects import create_signal_from_param as create_signal_headless
-from sigima.objects.base import BaseProcParam
-from sigima.objects.signal import DEFAULT_TITLE as SIGNAL_DEFAULT_TITLE
from datalab.config import _
+from datalab.gui.creation import (
+ CREATION_PARAMETERS_OPTION,
+ create_image_from_param,
+ create_signal_from_param,
+ extract_creation_parameters,
+ initialize_image_parameters,
+ insert_creation_parameters,
+ prepare_signal_parameters,
+)
-CREATION_PARAMETERS_OPTION = "creation_param_json"
-
-
-def insert_creation_parameters(obj: SignalObj | ImageObj, param: gds.DataSet) -> None:
- """Insert creation parameters into object metadata.
-
- Args:
- param: creation parameters
- """
- obj.set_metadata_option(CREATION_PARAMETERS_OPTION, gds.dataset_to_json(param))
-
-
-def extract_creation_parameters(obj: SignalObj | ImageObj) -> gds.DataSet | None:
- """Extract creation parameters from object metadata.
-
- Returns:
- Creation parameters or None if not found
- """
- try:
- param_json = obj.get_metadata_option(CREATION_PARAMETERS_OPTION)
- except ValueError:
- return None
- return gds.json_to_dataset(param_json)
+__all__ = [
+ "CREATION_PARAMETERS_OPTION",
+ "create_image_gui",
+ "create_signal_gui",
+ "extract_creation_parameters",
+ "insert_creation_parameters",
+]
class CustomSignalParam(OrigCustomSignalParam):
"""Parameters for custom signal (e.g. manually defined experimental data)"""
- def edit_curve(self, *args) -> None: # pylint: disable=unused-argument
+ def edit_curve(self, parent: QW.QWidget | None = None) -> None:
"""Edit custom curve"""
win: PlotDialog = make.dialog(
+ parent=parent,
wintitle=_("Select one point then press OK to accept"),
edit=True,
type="curve",
@@ -78,8 +63,8 @@ def edit_curve(self, *args) -> None: # pylint: disable=unused-argument
)
edit_tool.activate()
plot = win.manager.get_plot()
- x, y = self.xyarray[:, 0], self.xyarray[:, 1]
- curve = make.mcurve(x, y, "-+")
+ x_values, y_values = self.xyarray[:, 0], self.xyarray[:, 1]
+ curve = make.mcurve(x_values, y_values, "-+")
plot.add_item(curve)
plot.set_active_item(curve)
@@ -89,14 +74,26 @@ def edit_curve(self, *args) -> None: # pylint: disable=unused-argument
exec_dialog(win)
- new_x, new_y = curve.get_data()
- self.xmax = new_x.max()
- self.xmin = new_x.min()
- self.size = new_x.size
- self.xyarray = np.vstack((new_x, new_y)).T
+ new_x_values, new_y_values = curve.get_data()
+ self.xmax = new_x_values.max()
+ self.xmin = new_x_values.min()
+ self.size = new_x_values.size
+ self.xyarray = np.vstack((new_x_values, new_y_values)).T
+
+ def edit_curve_callback(
+ self,
+ button_item: gds.ButtonItem,
+ current_value: object,
+ parent: QW.QWidget,
+ ) -> object:
+ """Handle the curve edit button callback."""
+ if button_item.get_name() != "btn_curve_edit":
+ raise ValueError(f"Unexpected button item: {button_item.get_name()}")
+ self.edit_curve(parent)
+ return current_value
btn_curve_edit = gds.ButtonItem(
- "Edit curve", callback=edit_curve, icon="signal.svg"
+ "Edit curve", callback=edit_curve_callback, icon="signal.svg"
)
@@ -118,49 +115,19 @@ def create_signal_gui(
Raises:
ValueError: if base_param is None and edit is False
"""
+ param = prepare_signal_parameters(param, edit, parent)
if param is None:
- param = NewSignalParam()
- edit = True # Default to editing if no parameters provided
-
- # CustomSignalParam requires edit mode to initialize the xyarray.
- # Without this, if edit=False (the default in new_object), the setup_array
- # call would be skipped, leaving xyarray as None, which would cause an
- # AttributeError when trying to access param.xyarray.T later.
- if isinstance(param, OrigCustomSignalParam):
- edit = True
-
- if isinstance(param, OrigCustomSignalParam) and edit:
- p_init = NewSignalParam(_("Custom signal"))
- p_init.size = 10 # Set smaller default size for initial input
- if not p_init.edit(parent=parent):
- return None
- param.setup_array(size=p_init.size, xmin=p_init.xmin, xmax=p_init.xmax)
-
- if edit:
- if not param.edit(parent=parent):
- return None
-
- if isinstance(param, OrigCustomSignalParam):
- signal = create_signal(param.title)
- signal.xydata = param.xyarray.T
- if signal.title == SIGNAL_DEFAULT_TITLE:
- signal.title = f"custom(npts={param.size})"
- return signal
+ return None
try:
- signal = create_signal_headless(param)
- except Exception as exc: # pylint: disable=broad-except
+ signal = create_signal_from_param(param)
+ except (ValueError, TypeError, RuntimeError, ArithmeticError) as exc:
if parent is not None:
QW.QMessageBox.warning(parent, _("Error"), str(exc))
else:
raise ValueError(f"Error creating signal: {exc}") from exc
signal = None
- # Insert creation parameters into metadata, only if `param` is an instance of a
- # class deriving from `NewSignalParam` (not an instance of `NewSignalParam` itself):
- # pylint: disable=unidiomatic-typecheck
- if isinstance(param, NewSignalParam) and type(param) is not NewSignalParam:
- insert_creation_parameters(signal, param)
return signal
@@ -186,39 +153,19 @@ def create_image_gui(
param = NewImageParam()
edit = True # Default to editing if no parameters provided
- if param.height is None:
- param.height = 500
- if param.width is None:
- param.width = 500
- if param.dtype is None:
- param.dtype = ImageDatatypes.UINT16
- dtype: ImageDatatypes = param.dtype
- numpy_dtype = dtype.to_numpy_dtype()
- if isinstance(param, Gauss2DParam):
- if param.a is None:
- try:
- param.a = np.iinfo(numpy_dtype).max / 2.0
- except ValueError:
- param.a = 10.0
- elif isinstance(param, BaseProcParam):
- param.set_from_datatype(numpy_dtype)
+ initialize_image_parameters(param)
if edit:
if not param.edit(parent=parent):
return None
try:
- image = create_image_headless(param)
- except Exception as exc: # pylint: disable=broad-except
+ image = create_image_from_param(param)
+ except (ValueError, TypeError, RuntimeError, ArithmeticError) as exc:
if parent is not None:
QW.QMessageBox.warning(parent, _("Error"), str(exc))
else:
raise ValueError(f"Error creating image: {exc}") from exc
return None
- # Insert creation parameters into metadata, only if `param` is an instance of a
- # class deriving from `NewImageParam` (not an instance of `NewImageParam` itself):
- # pylint: disable=unidiomatic-typecheck
- if isinstance(param, NewImageParam) and type(param) is not NewImageParam:
- insert_creation_parameters(image, param)
return image
diff --git a/datalab/gui/panel/base.py b/datalab/gui/panel/base.py
index 9fde16514..2c26a6e89 100644
--- a/datalab/gui/panel/base.py
+++ b/datalab/gui/panel/base.py
@@ -9,12 +9,12 @@
from __future__ import annotations
import abc
+import copy
import glob
import os
import os.path as osp
import re
import warnings
-from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Generator, Generic, Literal, Type
import guidata.dataset as gds
@@ -80,7 +80,9 @@
from datalab.gui.processor.base import (
PROCESSING_PARAMETERS_OPTION,
ProcessingParameters,
+ ProcessingReport,
clear_analysis_parameters,
+ extract_analysis_parameters,
extract_processing_parameters,
insert_processing_parameters,
)
@@ -90,6 +92,7 @@
get_number,
get_short_id,
get_uuid,
+ patch_title_with_ids,
set_number,
set_uuid,
)
@@ -164,21 +167,6 @@ def is_hdf5_file(filename: str, check_content: bool = False) -> bool:
return False
-@dataclass
-class ProcessingReport:
- """Report of processing operation
-
- Args:
- success: True if processing succeeded
- obj_uuid: UUID of the processed object
- message: Optional message (error or info)
- """
-
- success: bool
- obj_uuid: str | None = None
- message: str | None = None
-
-
class ObjectProp(QW.QWidget):
"""Object handling panel properties
@@ -207,6 +195,15 @@ def __init__(self, panel: BaseDataPanel, objclass: SignalObj | ImageObj) -> None
self.processing_param_editor: gdq.DataSetEditGroupBox | None = None
self.current_processing_obj: SignalObj | ImageObj | None = None
self.processing_scroll: QW.QScrollArea | None = None
+ # Object analysis tab (editable 1-to-0 analysis parameters)
+ self.analysis_param_editor: gdq.DataSetEditGroupBox | None = None
+ self.current_analysis_obj: SignalObj | ImageObj | None = None
+ self.analysis_scroll: QW.QScrollArea | None = None
+ # Auto-recompute toggle (session-only state, not persisted to Conf).
+ self.__auto_recompute_enabled: bool = False
+ self.__auto_recompute_timer = QC.QTimer(self)
+ self.__auto_recompute_timer.setSingleShot(True)
+ self.__auto_recompute_timer.timeout.connect(self.__auto_recompute_trigger)
# Properties tab
self.properties = gdq.DataSetEditGroupBox("", objclass)
@@ -422,6 +419,10 @@ def update_properties_from(
index = self.tabwidget.indexOf(self.processing_scroll)
if index >= 0:
self.tabwidget.removeTab(index)
+ if self.analysis_scroll is not None:
+ index = self.tabwidget.indexOf(self.analysis_scroll)
+ if index >= 0:
+ self.tabwidget.removeTab(index)
# Reset references for dynamic tabs
self.creation_param_editor = None
@@ -430,13 +431,18 @@ def update_properties_from(
self.processing_param_editor = None
self.current_processing_obj = None
self.processing_scroll = None
+ self.analysis_param_editor = None
+ self.current_analysis_obj = None
+ self.analysis_scroll = None
# Setup Creation and Processing tabs (if applicable)
has_creation_tab = False
has_processing_tab = False
+ has_analysis_tab = False
if obj is not None:
has_creation_tab = self.setup_creation_tab(obj)
has_processing_tab = self.setup_processing_tab(obj) # Processing tab setup
+ has_analysis_tab = self.setup_analysis_tab(obj) # Analysis tab setup
# Trigger visibility update for History and Analysis parameters tabs
# (will be called via textChanged signals, but we call explicitly
@@ -452,6 +458,8 @@ def update_properties_from(
self.tabwidget.setCurrentWidget(self.creation_scroll)
elif force_tab == "processing" and has_processing_tab:
self.tabwidget.setCurrentWidget(self.processing_scroll)
+ elif force_tab == "analysis" and has_analysis_tab:
+ self.tabwidget.setCurrentWidget(self.analysis_scroll)
elif force_tab == "analysis" and has_analysis_parameters:
self.tabwidget.setCurrentWidget(self.analysis_parameters)
else:
@@ -590,16 +598,6 @@ def apply_creation_parameters(self) -> None:
editor = self.creation_param_editor
if editor is None or self.current_creation_obj is None:
return
- if isinstance(self.current_creation_obj, SignalObj):
- otext = _("Signal was modified in-place.")
- else:
- otext = _("Image was modified in-place.")
- text = f"⚠️ {otext} ⚠️ "
- text += _(
- "If computation were performed based on this object, "
- "they may need to be redone."
- )
- self.panel.SIG_STATUS_MESSAGE.emit(text, 20000)
# Recreate object with new parameters
# (serialization is done automatically in create_signal/image_from_param)
@@ -632,11 +630,19 @@ def apply_creation_parameters(self) -> None:
# Update metadata with new creation parameters
insert_creation_parameters(self.current_creation_obj, param)
- # Auto-recompute analysis if the object had analysis parameters
- # Since the data has changed, any analysis results are now invalid
- # Use the processor for the current object's type
- obj_processor = self.__get_processor_associated_to(self.current_creation_obj)
- obj_processor.auto_recompute_analysis(self.current_creation_obj)
+ # Propagate the edited param to the History panel: mutate the matching
+ # creation action (snapshot originals first), refresh its tree display,
+ # then cascade recompute to downstream actions so the chain stays
+ # consistent with the new creation parameters. Creation actions are
+ # KIND_UI without a func_name, so look them up via output_to_action.
+ hpanel = getattr(self.panel.mainwindow, "historypanel", None)
+ if hpanel is not None:
+ action = hpanel.find_creation_action_for_output(obj_uuid)
+ if action is not None:
+ action.snapshot_kwargs()
+ action.kwargs["param"] = copy.deepcopy(param)
+ hpanel.refresh_action(action)
+ hpanel.recompute_cascade(action)
# Update the tree view item (to show new title if it changed)
self.panel.objview.update_item(obj_uuid)
@@ -650,6 +656,12 @@ def apply_creation_parameters(self) -> None:
# (e.g., data type, dimensions, etc.)
self.__update_properties_dataset(self.current_creation_obj)
+ if isinstance(self.current_creation_obj, SignalObj):
+ text = _("Signal was recreated.")
+ else:
+ text = _("Image was recreated.")
+ self.panel.SIG_STATUS_MESSAGE.emit("✅ " + text, 5000)
+
# Refresh the Creation tab with the new parameters
# Use QTimer to defer this until after the current event is processed
# Set the Creation tab as current to keep it visible after refresh
@@ -725,6 +737,23 @@ def setup_processing_tab(
editor.SIG_APPLY_BUTTON_CLICKED.connect(self.apply_processing_parameters)
editor.set_apply_button_state(False)
+ # Hook into the per-edit change callback to support auto-recompute.
+ # ``DataSetEditLayout.change_callback`` is called whenever any widget
+ # value changes; wrap it so we can also (re)start the debounce timer.
+ try:
+ inner_layout = editor.edit # DataSetEditLayout instance
+ original_change_cb = inner_layout.change_callback
+
+ def _wrapped_change_cb() -> None:
+ if original_change_cb is not None:
+ original_change_cb()
+ if self.__auto_recompute_enabled:
+ self.__auto_recompute_timer.start(300)
+
+ inner_layout.change_callback = _wrapped_change_cb
+ except AttributeError:
+ pass
+
# Store reference to be able to retrieve it later
self.processing_param_editor = editor
@@ -751,7 +780,21 @@ def setup_processing_tab(
QW.QSizePolicy.Expanding, QW.QSizePolicy.Preferred
)
- self.processing_scroll.setWidget(editor)
+ # Build the tab content: editor + "Auto-recompute" checkbox.
+ container = QW.QWidget()
+ vbox = QW.QVBoxLayout(container)
+ vbox.setContentsMargins(0, 0, 0, 0)
+ vbox.addWidget(editor)
+ auto_cb = QW.QCheckBox(_("Auto-recompute on edit"), container)
+ auto_cb.setToolTip(
+ _("Automatically re-run processing when parameters are modified")
+ )
+ auto_cb.setChecked(self.__auto_recompute_enabled)
+ auto_cb.toggled.connect(self.__set_auto_recompute_enabled)
+ vbox.addWidget(auto_cb)
+ vbox.addStretch(1)
+
+ self.processing_scroll.setWidget(container)
self.tabwidget.insertTab(
insert_index,
self.processing_scroll,
@@ -765,6 +808,190 @@ def setup_processing_tab(
return True
+ def setup_analysis_tab(
+ self, obj: SignalObj | ImageObj, set_current: bool = False
+ ) -> bool:
+ """Setup the Analysis tab with parameter editor for re-running analysis.
+
+ This tab lets the user edit the parameters of a 1-to-0 analysis
+ operation (peak detection, FWHM, segments, etc.) and re-run it in place
+ with the modified parameters.
+
+ Args:
+ obj: Signal or Image object
+ set_current: If True, set the Analysis tab as current after creation
+
+ Returns:
+ True if Analysis tab was set up, False otherwise
+ """
+ # Extract analysis parameters (1-to-0 pattern only)
+ proc_params = extract_analysis_parameters(obj)
+ if proc_params is None or proc_params.pattern != "1-to-0":
+ return False
+
+ param = proc_params.param
+ if param is None or isinstance(param, list):
+ return False
+
+ # Store reference to be able to retrieve it later
+ self.current_analysis_obj = obj
+
+ # Create parameter editor widget
+ editor = gdq.DataSetEditGroupBox(
+ _("Analysis Parameters"), param.__class__, wordwrap=True
+ )
+ update_dataset(editor.dataset, param)
+ editor.get()
+
+ # Connect Apply button to re-analysis handler
+ editor.SIG_APPLY_BUTTON_CLICKED.connect(self.apply_analysis_parameters)
+ editor.set_apply_button_state(False)
+
+ # Store reference to be able to retrieve it later
+ self.analysis_param_editor = editor
+
+ # Remove existing Analysis tab if it exists
+ if self.analysis_scroll is not None:
+ index = self.tabwidget.indexOf(self.analysis_scroll)
+ if index >= 0:
+ self.tabwidget.removeTab(index)
+
+ # Analysis tab comes after Creation and Processing tabs (if they exist)
+ insert_index = 0
+ if (
+ self.creation_scroll is not None
+ and self.tabwidget.indexOf(self.creation_scroll) >= 0
+ ):
+ insert_index += 1
+ if (
+ self.processing_scroll is not None
+ and self.tabwidget.indexOf(self.processing_scroll) >= 0
+ ):
+ insert_index += 1
+
+ # Create new analysis scroll area and tab
+ self.analysis_scroll = QW.QScrollArea()
+ self.analysis_scroll.setWidgetResizable(True)
+ self.analysis_scroll.setHorizontalScrollBarPolicy(QC.Qt.ScrollBarAlwaysOff)
+ self.analysis_scroll.setSizePolicy(
+ QW.QSizePolicy.Expanding, QW.QSizePolicy.Preferred
+ )
+ self.analysis_scroll.setWidget(editor)
+ self.tabwidget.insertTab(
+ insert_index,
+ self.analysis_scroll,
+ get_icon("analysis.svg"),
+ _("Analysis"),
+ )
+
+ # Set as current tab if requested
+ if set_current:
+ self.tabwidget.setCurrentWidget(self.analysis_scroll)
+
+ return True
+
+ def apply_analysis_parameters(
+ self,
+ obj: SignalObj | ImageObj | None = None,
+ interactive: bool = True,
+ param: gds.DataSet | None = None,
+ ) -> bool:
+ """Apply analysis parameters: re-run the 1-to-0 analysis in place.
+
+ Args:
+ obj: Signal or Image object to re-analyze. If None, uses the current
+ analysis object.
+ interactive: If True, show error messages in the UI.
+ param: Explicit analysis parameters to apply. When None (default),
+ fall back to the editor dataset or the stored analysis parameters.
+ """
+ if execenv.unattended:
+ interactive = False
+
+ editor = self.analysis_param_editor
+ obj = obj or self.current_analysis_obj
+ if obj is None:
+ return False
+
+ # Extract analysis parameters
+ proc_params = extract_analysis_parameters(obj)
+ if proc_params is None:
+ if interactive:
+ QW.QMessageBox.warning(
+ self, _("Error"), _("Analysis metadata is incomplete.")
+ )
+ return False
+
+ func_name = proc_params.func_name
+
+ # Resolve the parameters to apply. An explicit ``param`` argument takes
+ # precedence; otherwise fall back to the editor (interactive Apply) or
+ # the stored analysis parameters.
+ if param is None:
+ param = editor.dataset if editor is not None else proc_params.param
+ recompute_param = copy.deepcopy(param)
+
+ # Disable ROI creation during re-analysis: detection functions store
+ # create_rois=True in their parameters, but re-running should only
+ # update analysis results, not recreate ROIs (which would make them
+ # impossible to delete or modify).
+ if hasattr(recompute_param, "create_rois"):
+ recompute_param.create_rois = False
+
+ # Re-run the analysis in place (no history entry: runs under replaying)
+ processor = self.__get_processor_associated_to(obj)
+ try:
+ success = processor.recompute_1_to_0(
+ func_name,
+ obj,
+ recompute_param,
+ plugin_origin=proc_params.plugin_origin,
+ )
+ except Exception as exc: # pylint: disable=broad-exception-caught
+ if execenv.unattended:
+ raise exc
+ QW.QMessageBox.warning(
+ self,
+ _("Error"),
+ _("Failed to recompute analysis:\n%s") % str(exc),
+ )
+ return False
+ if not success:
+ if interactive:
+ QW.QMessageBox.warning(
+ self, _("Error"), _("Failed to recompute analysis.")
+ )
+ return False
+
+ # Propagate the edited param to the History panel: mutate the matching
+ # analysis action (snapshot originals first) and refresh its tree
+ # display. Analysis is a leaf operation (1-to-0), so no cascade is
+ # needed.
+ hpanel = getattr(self.panel.mainwindow, "historypanel", None)
+ if hpanel is not None:
+ action = hpanel.find_analysis_action(get_uuid(obj), func_name)
+ if action is not None:
+ action.snapshot_kwargs()
+ action.kwargs["param"] = copy.deepcopy(recompute_param)
+ hpanel.refresh_action(action)
+
+ # Refresh the object display after re-analysis
+ obj_uuid = get_uuid(obj)
+ self.display_analysis_parameters(obj)
+ self.panel.objview.update_item(obj_uuid)
+ # Analysis results are plot shapes, so force a plot refresh
+ self.panel.refresh_plot(obj_uuid, update_items=True, force=True)
+ self.panel.SIG_STATUS_MESSAGE.emit("✅ " + _("Analysis was recomputed."), 5000)
+
+ # Refresh the Analysis tab with the new parameters (defer, keep visible)
+ QC.QTimer.singleShot(
+ 0,
+ lambda: self.setup_analysis_tab(
+ self.current_analysis_obj, set_current=True
+ ),
+ )
+ return True
+
def __get_processor_associated_to(
self, obj: SignalObj | ImageObj
) -> SignalProcessor | ImageProcessor:
@@ -781,14 +1008,40 @@ def __get_processor_associated_to(
return self.panel.mainwindow.signalpanel.processor
return self.panel.mainwindow.imagepanel.processor
+ def __set_auto_recompute_enabled(self, enabled: bool) -> None:
+ """Toggle auto-recompute mode (session-only, not persisted)."""
+ self.__auto_recompute_enabled = bool(enabled)
+ if not self.__auto_recompute_enabled:
+ self.__auto_recompute_timer.stop()
+
+ def __auto_recompute_trigger(self) -> None:
+ """Debounced callback: push widget values then re-run processing."""
+ if not self.__auto_recompute_enabled:
+ return
+ editor = self.processing_param_editor
+ if editor is None:
+ return
+ # ``editor.set()`` synchronises widget values to the dataset and emits
+ # ``SIG_APPLY_BUTTON_CLICKED`` which is already wired to
+ # ``apply_processing_parameters``.
+ editor.set(check=False)
+
def apply_processing_parameters(
- self, obj: SignalObj | ImageObj | None = None, interactive: bool = True
+ self,
+ obj: SignalObj | ImageObj | None = None,
+ interactive: bool = True,
+ param: gds.DataSet | None = None,
) -> ProcessingReport:
"""Apply processing parameters: re-run processing with updated parameters.
Args:
obj: Signal or Image object to reprocess. If None, uses the current object.
interactive: If True, show progress and error messages in the UI.
+ param: Explicit processing parameters to apply. When provided, this
+ takes precedence and makes the call independent of the Processing
+ tab editor state (used e.g. by programmatic recompute paths).
+ When None (default), fall back to the editor dataset or the
+ stored processing parameters.
Returns:
ProcessingReport with success status, object UUID, and optional message.
@@ -796,35 +1049,25 @@ def apply_processing_parameters(
if execenv.unattended:
interactive = False
- report = ProcessingReport(success=False)
editor = self.processing_param_editor
obj = obj or self.current_processing_obj
if obj is None:
- report.message = _("No processing object available.")
- return report
-
- report.obj_uuid = get_uuid(obj)
+ return ProcessingReport(
+ success=False, message=_("No processing object available.")
+ )
- # Extract processing parameters
proc_params = extract_processing_parameters(obj)
- if proc_params is None:
- report.message = _("Processing metadata is incomplete.")
- if interactive:
- QW.QMessageBox.critical(self, _("Error"), report.message)
- return report
-
- # Check if source object still exists
- if proc_params.source_uuid is None:
- report.message = _(
- "Processing metadata is incomplete (missing source UUID)."
+ if proc_params is None or proc_params.pattern != "1-to-1":
+ return ProcessingReport(
+ success=False,
+ obj_uuid=get_uuid(obj),
+ message=_("Processing metadata is incomplete."),
)
- if interactive:
- QW.QMessageBox.critical(self, _("Error"), report.message)
- return report
# Find source object
source_obj = self.panel.mainwindow.find_object_by_uuid(proc_params.source_uuid)
if source_obj is None:
+ report = ProcessingReport(success=False, obj_uuid=get_uuid(obj))
report.message = _("Source object no longer exists.")
if interactive:
QW.QMessageBox.critical(
@@ -839,83 +1082,144 @@ def apply_processing_parameters(
)
return report
- # Get updated parameters from editor
- param = editor.dataset if editor is not None else proc_params.param
+ # Resolve the parameters to apply. An explicit ``param`` argument takes
+ # precedence and makes this method independent of the editor state;
+ # otherwise fall back to the editor (interactive Apply) or the stored
+ # processing parameters.
+ if param is None:
+ if editor is not None and obj is self.current_processing_obj:
+ param = editor.dataset
+ else:
+ param = proc_params.param
- # For cross-panel computations, we need to use the processor from the panel
- # that owns the source object (e.g., radial_profile is in ImageProcessor)
- source_processor = self.__get_processor_associated_to(source_obj)
+ hpanel = getattr(self.panel.mainwindow, "historypanel", None)
+ is_edit_mode = hpanel is not None and hpanel.is_edit_mode()
- # Recompute using the dedicated method (with multiprocessing support)
- try:
- new_obj = source_processor.recompute_1_to_1(
- proc_params.func_name, source_obj, param
+ if is_edit_mode:
+ report = self.panel.processor.recompute_processing(
+ obj=obj,
+ param=param,
+ interactive=interactive,
)
- except Exception as exc: # pylint: disable=broad-exception-caught
- report.message = _("Failed to reprocess object:\n%s") % str(exc)
- if interactive:
- QW.QMessageBox.warning(self, _("Error"), report.message)
- return report
-
- if new_obj is None:
- # User cancelled the operation
- report.message = _("Processing was cancelled.")
-
+ if report.success:
+ # Propagate the edited param to the History panel:
+ # Mutate the matching existing action (snapshot originals
+ # first), refresh its tree display, then cascade recompute
+ # to downstream actions so the chain stays consistent with
+ # the new parameters.
+ action = hpanel.find_action_for_output(
+ get_uuid(obj), proc_params.func_name
+ )
+ if action is not None:
+ action.snapshot_kwargs()
+ action.kwargs["param"] = copy.deepcopy(param)
+ hpanel.refresh_action(action)
+ hpanel.recompute_cascade(action)
+
+ # Update the tree view item and refresh plot
+ obj_uuid = get_uuid(obj)
+ self.panel.objview.update_item(obj_uuid)
+ self.panel.refresh_plot(obj_uuid, update_items=True, force=True)
+
+ # Update the Properties tab to reflect the new object
+ self.__update_properties_dataset(obj)
+ # Refresh the displayed processing history (Properties tab
+ # description) so the parameter change is visible immediately
+ self.display_processing_history(obj)
+
+ # Refresh the Processing tab with the new parameters
+ QC.QTimer.singleShot(
+ 0,
+ lambda: self.setup_processing_tab(
+ obj, reset_params=False, set_current=True
+ ),
+ )
else:
+ source_processor = self.__get_processor_associated_to(source_obj)
+ try:
+ compout = source_processor.recompute_1_to_1(
+ proc_params.func_name,
+ source_obj,
+ param,
+ plugin_origin=proc_params.plugin_origin,
+ )
+ except Exception as exc: # pylint: disable=broad-exception-caught
+ report = ProcessingReport(success=False, obj_uuid=get_uuid(obj))
+ report.message = _("Failed to reprocess object:\n%s") % str(exc)
+ if interactive:
+ QW.QMessageBox.warning(self, _("Error"), report.message)
+ return report
+
+ report = ProcessingReport(success=False, obj_uuid=get_uuid(obj))
+ if compout.cancelled:
+ report.cancelled = True
+ report.message = _("Processing was cancelled.")
+ return report
+ new_obj = compout.result
+ if new_obj is None:
+ report.message = compout.error_msg or _("Failed to reprocess object.")
+ return report
report.success = True
- # Update the current object in-place with data from new object
- obj.title = new_obj.title
- if isinstance(obj, SignalObj):
- obj.xydata = new_obj.xydata
- else: # ImageObj
- obj.data = new_obj.data
- # Invalidate ROI mask cache when image dimensions may have changed
- # (the mask is computed based on image shape, so it must be recomputed)
- obj.invalidate_maskdata_cache()
-
- # Update metadata with new processing parameters
- updated_proc_params = ProcessingParameters(
- func_name=proc_params.func_name,
- pattern=proc_params.pattern,
- param=param,
+ # --- Non-edit mode: create a new independent object ---
+ patch_title_with_ids(new_obj, [obj], get_short_id)
+
+ # Store processing metadata on the new object
+ # pylint: disable=import-outside-toplevel
+ from datalab.gui.processor.base import build_processing_parameters
+
+ new_pp = build_processing_parameters(
+ proc_params.func_name,
+ proc_params.pattern,
+ param=copy.deepcopy(param),
source_uuid=proc_params.source_uuid,
+ plugin_origin=proc_params.plugin_origin,
)
- insert_processing_parameters(obj, updated_proc_params)
-
- # Auto-recompute analysis if the object had analysis parameters
- # Since the data has changed, any analysis results are now invalid
- # Use the processor for the current object's type (not source object's type)
- obj_processor = self.__get_processor_associated_to(obj)
- obj_processor.auto_recompute_analysis(obj)
-
- # Update the tree view item and refresh plot
- obj_uuid = get_uuid(obj)
- self.panel.objview.update_item(obj_uuid)
- self.panel.refresh_plot(obj_uuid, update_items=True, force=True)
-
- # Update the Properties tab to reflect the new object properties
- # (e.g., data type, dimensions, etc.)
- self.__update_properties_dataset(obj)
-
- # Refresh the Processing tab with the new parameters
- # Don't reset parameters from source object - keep the user's values
- # Set the Processing tab as current to keep it visible after refresh
- QC.QTimer.singleShot(
- 0,
- lambda: self.setup_processing_tab(
- obj, reset_params=False, set_current=True
- ),
- )
-
- if isinstance(obj, SignalObj):
- report.message = _("Signal was reprocessed.")
- else:
- report.message = _("Image was reprocessed.")
- self.panel.SIG_STATUS_MESSAGE.emit("✅ " + report.message, 5000)
+ insert_processing_parameters(new_obj, new_pp)
+
+ # Mark as freshly processed so the Processing tab is shown
+ self.mark_as_freshly_processed(new_obj)
+
+ # Add the new object to the same group as the source object
+ group_id = self.panel.objmodel.get_object_group_id(obj)
+ self.panel.add_object(new_obj, group_id=group_id, set_current=True)
+
+ # Record a brand-new history entry with the new object UUID
+ if hpanel is not None:
+ hpanel.add_compute_entry_from_pp(
+ new_obj.title,
+ new_pp,
+ panel_str=self.panel.PANEL_STR_ID,
+ output_uuids=[get_uuid(new_obj)],
+ plugin_origin=proc_params.plugin_origin,
+ )
return report
+ def apply_recomputed_object_in_place(
+ self,
+ obj: SignalObj | ImageObj,
+ new_obj: SignalObj | ImageObj,
+ proc_params: ProcessingParameters,
+ ) -> None:
+ """Apply a freshly recomputed object onto ``obj`` in place.
+
+ Copies title + data from ``new_obj`` while preserving ``obj``'s own
+ metadata (only the processing parameters are refreshed).
+
+ Args:
+ obj: Existing object to update in place (identity preserved).
+ new_obj: Freshly recomputed object providing title + data.
+ proc_params: Updated processing parameters to store on ``obj``.
+ """
+ obj.title = new_obj.title
+ if isinstance(obj, SignalObj):
+ obj.xydata = new_obj.xydata
+ else: # ImageObj
+ obj.data = new_obj.data
+ obj.invalidate_maskdata_cache()
+ insert_processing_parameters(obj, proc_params)
+
class AbstractPanelMeta(type(QW.QSplitter), abc.ABCMeta):
"""Mixed metaclass to avoid conflicts"""
@@ -932,6 +1236,7 @@ class AbstractPanel(QW.QSplitter, metaclass=AbstractPanelMeta):
H5_PREFIX = ""
SIG_OBJECT_ADDED = QC.Signal()
SIG_OBJECT_REMOVED = QC.Signal()
+ SIG_OBJECT_MODIFIED = QC.Signal()
@abc.abstractmethod
def __init__(self, parent):
@@ -1117,7 +1422,7 @@ def on_button_click(
""",
]
)
- NonModalInfoDialog(parent, "Pattern help", text).show()
+ NonModalInfoDialog(parent, _("Pattern help"), text).show()
def get_extension_choices(self, _item=None, _value=None):
"""Return list of available extensions for choice item."""
@@ -1258,7 +1563,7 @@ def on_help_button_click(
""",
]
)
- NonModalInfoDialog(parent, "Pattern help", text).show()
+ NonModalInfoDialog(parent, _("Pattern help"), text).show()
def get_conversion_choices(self, _item=None, _value=None):
"""Return list of available conversion choices."""
@@ -1592,6 +1897,7 @@ def set_object(self, obj: TypeObj) -> None:
# immediately if the modified object is currently selected.
self.objview.item_selection_changed()
self.refresh_plot("selected", update_items=True, force=True)
+ self.SIG_OBJECT_MODIFIED.emit()
def remove_all_objects(self) -> None:
"""Remove all objects"""
@@ -1718,20 +2024,33 @@ def duplicate_object(self) -> None:
"""Duplication signal/image object"""
if not self.mainwindow.confirm_memory_state():
return
- # Duplicate individual objects (exclusive with respect to groups)
- for oid in self.objview.get_sel_object_uuids():
- self.__duplicate_individual_obj(oid, set_current=False)
- # Duplicate groups (exclusive with respect to individual objects)
- for group in self.objview.get_sel_groups():
- new_group = self.add_group(group.title)
- for oid in self.objmodel.get_group_object_ids(get_uuid(group)):
- self.__duplicate_individual_obj(
- oid, get_uuid(new_group), set_current=False
- )
+ action = self.mainwindow.historypanel.add_ui_entry(
+ _("Duplicate object or group"),
+ target=self.PANEL_STR_ID + "panel",
+ method_name="duplicate_object",
+ save_state=False,
+ )
+ with self.mainwindow.historypanel.capture_outputs(action):
+ # Duplicate individual objects (exclusive with respect to groups)
+ for oid in self.objview.get_sel_object_uuids():
+ self.__duplicate_individual_obj(oid, set_current=False)
+ # Duplicate groups (exclusive with respect to individual objects)
+ for group in self.objview.get_sel_groups():
+ new_group = self.add_group(group.title)
+ for oid in self.objmodel.get_group_object_ids(get_uuid(group)):
+ self.__duplicate_individual_obj(
+ oid, get_uuid(new_group), set_current=False
+ )
self.selection_changed(update_items=True)
def copy_metadata(self) -> None:
"""Copy object metadata"""
+ self.mainwindow.historypanel.add_ui_entry(
+ _("Copy metadata"),
+ target=self.PANEL_STR_ID + "panel",
+ method_name="copy_metadata",
+ save_state=False,
+ )
obj = self.objview.get_sel_objects()[0]
self.metadata_clipboard = obj.metadata.copy()
@@ -1788,6 +2107,13 @@ def paste_metadata(self, param: PasteMetadataParam | None = None) -> None:
)
if not param.edit(parent=self.parentWidget()):
return
+ self.mainwindow.historypanel.add_ui_entry(
+ _("Paste metadata"),
+ target=self.PANEL_STR_ID + "panel",
+ method_name="paste_metadata",
+ save_state=False,
+ param=param,
+ )
metadata = {}
if param.keep_roi and ROI_KEY in self.metadata_clipboard:
metadata[ROI_KEY] = self.metadata_clipboard[ROI_KEY]
@@ -1840,6 +2166,14 @@ def add_metadata(self, param: AddMetadataParam | None = None) -> None:
# Save settings to config
Conf.io.add_metadata_settings.set(param)
+ self.mainwindow.historypanel.add_ui_entry(
+ _("Add metadata"),
+ target=self.PANEL_STR_ID + "panel",
+ method_name="add_metadata",
+ save_state=True,
+ param=param,
+ )
+
# Build values for all selected objects
values = param.build_values(sel_objects)
@@ -1852,19 +2186,49 @@ def add_metadata(self, param: AddMetadataParam | None = None) -> None:
"selected", update_items=True, only_visible=False, only_existing=True
)
- def copy_roi(self) -> None:
- """Copy regions of interest"""
- obj = self.objview.get_sel_objects()[0]
- self.__roi_clipboard = obj.roi.copy()
+ def copy_roi(self, roi_data=None) -> None:
+ """Copy regions of interest
+
+ Args:
+ roi_data: ROI snapshot for replay. When ``None`` (interactive use),
+ the ROI is read from the currently selected object.
+ """
+ if roi_data is None:
+ obj = self.objview.get_sel_objects()[0]
+ roi_data = obj.roi.copy()
+ self.__roi_clipboard = roi_data.copy()
+ self.mainwindow.historypanel.add_ui_entry(
+ _("Copy regions of interest from selected %s")
+ % (_("signal") if self.PANEL_STR_ID == "signal" else _("image")),
+ target=self.PANEL_STR_ID + "panel",
+ method_name="copy_roi",
+ save_state=True,
+ roi_data=roi_data,
+ )
- def paste_roi(self) -> None:
- """Paste regions of interest"""
+ def paste_roi(self, roi_data=None) -> None:
+ """Paste regions of interest
+
+ Args:
+ roi_data: ROI snapshot for replay. When ``None`` (interactive use),
+ the clipboard populated by :meth:`copy_roi` is used.
+ """
+ if roi_data is None:
+ roi_data = self.__roi_clipboard
+ self.mainwindow.historypanel.add_ui_entry(
+ _("Paste regions of interest into selected %s")
+ % (_("signal") if self.PANEL_STR_ID == "signal" else _("image")),
+ target=self.PANEL_STR_ID + "panel",
+ method_name="paste_roi",
+ save_state=True,
+ roi_data=roi_data,
+ )
sel_objects = self.objview.get_sel_objects(include_groups=True)
for obj in sel_objects:
if obj.roi is None:
- obj.roi = self.__roi_clipboard.copy()
+ obj.roi = roi_data.copy()
else:
- obj.roi = obj.roi.combine_with(self.__roi_clipboard)
+ obj.roi = obj.roi.combine_with(roi_data)
self.selection_changed(update_items=True)
self.refresh_plot(
"selected", update_items=True, only_visible=False, only_existing=True
@@ -1886,6 +2250,17 @@ def remove_object(self, force: bool = False) -> None:
)
if answer == QW.QMessageBox.No:
return
+ # IMPORTANT: save_state=True is required so that the selection of objects
+ # being deleted is captured. On replay, the captured selection (translated
+ # through uuid_remap) is restored before remove_object runs, ensuring that
+ # the correct object is removed instead of whatever is currently selected.
+ self.mainwindow.historypanel.add_ui_entry(
+ _("Remove selected objects"),
+ target=self.PANEL_STR_ID + "panel",
+ method_name="remove_object",
+ save_state=True,
+ force=force,
+ )
sel_objects = self.objview.get_sel_objects(include_groups=True)
for obj in sorted(sel_objects, key=get_short_id, reverse=True):
dlg_list: list[QW.QDialog] = []
@@ -1957,7 +2332,9 @@ def delete_metadata(
# Delete metadata:
for index, obj in enumerate(sel_objs):
+ uuid = get_uuid(obj)
obj.reset_metadata_to_defaults()
+ obj.set_metadata_option("uuid", uuid)
if not keep_roi:
obj.mark_roi_as_changed()
if obj in roi_backup:
@@ -2011,6 +2388,14 @@ def new_group(self) -> None:
# Open a message box to enter the group name
group_name, ok = QW.QInputDialog.getText(self, _("New group"), _("Group name:"))
if ok:
+ self.mainwindow.historypanel.add_ui_entry(
+ _('New group "%s"') % group_name,
+ target=self.PANEL_STR_ID + "panel",
+ method_name="add_group",
+ save_state=False,
+ title=group_name,
+ select=False,
+ )
self.add_group(group_name)
def rename_selected_object_or_group(self, new_name: str | None = None) -> None:
@@ -2019,6 +2404,13 @@ def rename_selected_object_or_group(self, new_name: str | None = None) -> None:
Args:
new_name: new name (default: None, i.e. ask user)
"""
+ self.mainwindow.historypanel.add_ui_entry(
+ _("Rename selected object or group"),
+ target=self.PANEL_STR_ID + "panel",
+ method_name="rename_selected_object_or_group",
+ save_state=False,
+ new_name=new_name,
+ )
sel_objects = self.objview.get_sel_objects(include_groups=False)
sel_groups = self.objview.get_sel_groups()
if (not sel_objects and not sel_groups) or len(sel_objects) + len(
@@ -2095,6 +2487,13 @@ def set_current_object_title(self, title: str) -> None:
obj = self.objview.get_current_object()
obj.title = title
self.objview.update_item(get_uuid(obj))
+ self.mainwindow.historypanel.add_ui_entry(
+ _('Set current object title to "%s"') % title,
+ target=self.PANEL_STR_ID + "panel",
+ method_name="set_current_object_title",
+ save_state=False,
+ title=title,
+ )
def __load_from_file(
self, filename: str, create_group: bool = True, add_objects: bool = True
@@ -2159,42 +2558,47 @@ def load_from_directory(self, directory: str | None = None) -> list[TypeObj]:
directory = getexistingdirectory(self, _("Open"), basedir)
if not directory:
return []
+ # Offer a fresh history session for this batch *before* loading anything.
+ self.mainwindow.historypanel.maybe_start_session_for_input(load=True)
folders = [
path
for path in glob.glob(osp.join(directory, "**"), recursive=True)
if osp.isdir(path) and len(os.listdir(path)) > 0
]
objs = []
- with create_progress_bar(
- self, _("Scanning directory"), max_=len(folders) - 1
- ) as progress:
- # Iterate over all subfolders in the directory:
- for i_path, path in enumerate(folders):
- progress.setValue(i_path + 1)
- if progress.wasCanceled():
- break
- path = osp.normpath(path)
- fnames = sorted(
- [
- osp.join(path, fname)
- for fname in os.listdir(path)
- if osp.isfile(osp.join(path, fname))
- ]
- )
- new_objs = self.load_from_files(
- fnames,
- create_group=False,
- add_objects=False,
- ignore_errors=True,
- )
- if new_objs:
- objs += new_objs
- grp_name = osp.relpath(path, directory)
- if grp_name == ".":
- grp_name = osp.basename(path)
- grp = self.add_group(grp_name)
- for obj in new_objs:
- self.add_object(obj, group_id=get_uuid(grp), set_current=False)
+ with self.mainwindow.historypanel.session_prompt_suppressed():
+ with create_progress_bar(
+ self, _("Scanning directory"), max_=len(folders) - 1
+ ) as progress:
+ # Iterate over all subfolders in the directory:
+ for i_path, path in enumerate(folders):
+ progress.setValue(i_path + 1)
+ if progress.wasCanceled():
+ break
+ path = osp.normpath(path)
+ fnames = sorted(
+ [
+ osp.join(path, fname)
+ for fname in os.listdir(path)
+ if osp.isfile(osp.join(path, fname))
+ ]
+ )
+ new_objs = self.load_from_files(
+ fnames,
+ create_group=False,
+ add_objects=False,
+ ignore_errors=True,
+ )
+ if new_objs:
+ objs += new_objs
+ grp_name = osp.relpath(path, directory)
+ if grp_name == ".":
+ grp_name = osp.basename(path)
+ grp = self.add_group(grp_name)
+ for obj in new_objs:
+ self.add_object(
+ obj, group_id=get_uuid(grp), set_current=False
+ )
return objs
def load_from_files(
@@ -2226,20 +2630,41 @@ def load_from_files(
filenames, _filt = getopenfilenames(self, _("Open"), basedir, filters)
# Sort filenames to ensure consistent alphabetical order across all platforms
filenames = sorted(filenames)
+ nbf = len(filenames)
+ if nbf > 1:
+ entry_title = _("Load from %d files") % nbf
+ else:
+ entry_title = _('Load "%s"') % osp.basename(filenames[0])
+ # Offer a fresh history session for this batch *before* recording any entry.
+ self.mainwindow.historypanel.maybe_start_session_for_input(load=True)
+ action = self.mainwindow.historypanel.add_ui_entry(
+ entry_title,
+ target=self.PANEL_STR_ID + "panel",
+ method_name="load_from_files",
+ save_state=False,
+ filenames=filenames,
+ create_group=create_group,
+ add_objects=add_objects,
+ ignore_errors=ignore_errors,
+ )
objs = []
- for filename in filenames:
- with qt_try_loadsave_file(self.parentWidget(), filename, "load"):
- Conf.main.base_dir.set(filename)
- try:
- objs += self.__load_from_file(
- filename, create_group=create_group, add_objects=add_objects
- )
- except Exception as exc: # pylint: disable=broad-exception-caught
- if ignore_errors:
- # Ignore unknown file types
- pass
- else:
- raise exc
+ with self.mainwindow.historypanel.session_prompt_suppressed():
+ with self.mainwindow.historypanel.capture_outputs(action):
+ for filename in filenames:
+ with qt_try_loadsave_file(self.parentWidget(), filename, "load"):
+ Conf.main.base_dir.set(filename)
+ try:
+ objs += self.__load_from_file(
+ filename,
+ create_group=create_group,
+ add_objects=add_objects,
+ )
+ except Exception as exc: # pylint: disable=broad-exception-caught
+ if ignore_errors:
+ # Ignore unknown file types
+ pass
+ else:
+ raise exc
return objs
def save_to_files(self, filenames: list[str] | str | None = None) -> None:
@@ -2254,6 +2679,18 @@ def save_to_files(self, filenames: list[str] | str | None = None) -> None:
assert len(filenames) == len(objs), (
"Number of filenames must match number of objects"
)
+ nbf = len(filenames)
+ if nbf > 1:
+ entry_title = _("Save to %d different files") % nbf
+ else:
+ entry_title = _('Save to "%s"') % osp.basename(filenames[0])
+ self.mainwindow.historypanel.add_ui_entry(
+ entry_title,
+ target=self.PANEL_STR_ID + "panel",
+ method_name="save_to_files",
+ save_state=False,
+ filenames=filenames,
+ )
for index, obj in enumerate(objs):
filename = filenames[index]
if filename is None:
@@ -2307,6 +2744,14 @@ def save_to_directory(self, param: SaveToDirectoryParam | None = None) -> None:
Conf.main.base_dir.set(param.directory)
+ self.mainwindow.historypanel.add_ui_entry(
+ _("Save to directory"),
+ target=self.PANEL_STR_ID + "panel",
+ method_name="save_to_directory",
+ save_state=True,
+ param=param,
+ )
+
with create_progress_bar(self, _("Saving..."), max_=len(objs)) as progress:
for i, (path, obj) in enumerate(param.generate_filepath_obj_pairs(objs)):
progress.setValue(i + 1)
@@ -2540,16 +2985,14 @@ def properties_changed(self) -> None:
# Get only the properties that have changed from the original values
changed_props = self.objprop.get_changed_properties()
- # Apply only the changed properties to all selected objects
- for obj in self.objview.get_sel_objects(include_groups=True):
- obj.mark_roi_as_changed()
- # Update only the changed properties instead of all properties
- update_dataset(obj, changed_props)
- self.objview.update_item(get_uuid(obj))
-
- # Auto-recompute analysis if the object had analysis parameters
- # Since properties have changed, any analysis results may now be invalid
- self.processor.auto_recompute_analysis(obj)
+ # Apply only the changed properties to all selected objects.
+ # The ``replaying()`` guard suppresses synthetic history capture.
+ with self.mainwindow.historypanel.replaying():
+ for obj in self.objview.get_sel_objects(include_groups=True):
+ obj.mark_roi_as_changed()
+ # Update only the changed properties instead of all properties
+ update_dataset(obj, changed_props)
+ self.objview.update_item(get_uuid(obj))
# Refresh all selected items, including non-visible ones (only_visible=False)
# This ensures that plot items are updated for all selected objects, even if
@@ -2561,71 +3004,169 @@ def properties_changed(self) -> None:
# Update the stored original values to reflect the new state
# This ensures subsequent changes are compared against the current values
self.objprop.update_original_values()
+ self.SIG_OBJECT_MODIFIED.emit()
- def recompute_processing(self) -> None:
- """Recompute/rerun selected objects or group with stored processing parameters.
+ def recompute_selected(self) -> None:
+ """Recompute/rerun selected objects or group with stored parameters.
- This method handles both single objects and groups. For each object, it checks
- if it has 1-to-1 processing parameters that can be recomputed. Objects without
- recomputable parameters are skipped.
+ This method handles both single objects and groups. For each object, it
+ recomputes, on demand:
+
+ - 1-to-1 processing operations (in-place data transformations), and
+ - 1-to-0 analysis operations (statistics, measurements, detections, etc.).
+
+ Analysis results are *not* recomputed automatically when data, ROIs or
+ object properties change: this manual action is the single, explicit entry
+ point to refresh them. Objects without recomputable parameters are skipped.
"""
# Get selected objects (handles both individual selection and groups)
objects = self.objview.get_sel_objects(include_groups=True)
if not objects:
return
- # Filter objects that have recomputable processing parameters
+ # Filter objects that have recomputable 1-to-1 processing parameters
+ # and/or 1-to-0 analysis parameters (an object may have both)
recomputable_objects: list[SignalObj | ImageObj] = []
+ reanalyzable_objects: list[SignalObj | ImageObj] = []
for obj in objects:
proc_params = extract_processing_parameters(obj)
if proc_params is not None and proc_params.pattern == "1-to-1":
recomputable_objects.append(obj)
+ analysis_params = extract_analysis_parameters(obj)
+ if analysis_params is not None and analysis_params.pattern == "1-to-0":
+ reanalyzable_objects.append(obj)
- if not recomputable_objects:
+ if not recomputable_objects and not reanalyzable_objects:
if not execenv.unattended:
QW.QMessageBox.information(
self,
_("Recompute"),
_(
- "Selected object(s) do not have processing parameters "
- "that can be recomputed."
+ "Selected object(s) do not have processing or analysis "
+ "parameters that can be recomputed."
),
)
return
- # Recompute each object
+ # Silence history capture while explicitly recomputing the current state.
+ with self.mainwindow.historypanel.replaying():
+ # Recompute 1-to-1 operations first so analyses use updated data.
+ recomputed_uuids, was_interrupted = self.recompute_1_to_1_objects(
+ recomputable_objects
+ )
+ if was_interrupted:
+ return
+
+ analysis_targets = []
+ for obj in reanalyzable_objects:
+ obj_uuid = get_uuid(obj)
+ if obj in recomputable_objects and obj_uuid not in recomputed_uuids:
+ continue
+ analysis_targets.append(obj)
+ self.recompute_1_to_0_objects(analysis_targets)
+
+ def recompute_1_to_1_objects(
+ self, objects: list[SignalObj | ImageObj]
+ ) -> tuple[set[str], bool]:
+ """Recompute in-place 1-to-1 processing operations for the given objects.
+
+ Args:
+ objects: Objects with stored 1-to-1 processing parameters
+
+ Returns:
+ Tuple containing:
+ - Set of object UUIDs successfully recomputed
+ - True if operation was interrupted (progress canceled or user chose
+ to stop after a failure), False otherwise
+ """
+ if not objects:
+ return set(), False
+ recomputed_uuids: set[str] = set()
with create_progress_bar(
- self, _("Recomputing objects"), max_=len(recomputable_objects)
+ self, _("Recomputing objects"), max_=len(objects)
) as progress:
- for index, obj in enumerate(recomputable_objects):
+ for index, obj in enumerate(objects):
progress.setValue(index + 1)
QW.QApplication.processEvents()
if progress.wasCanceled():
- break
+ return recomputed_uuids, True
- # Temporarily set this object as current to use existing infrastructure
self.objview.set_current_object(obj)
- report = self.objprop.apply_processing_parameters(
- obj=obj, interactive=False
- )
- if not report.success and not execenv.unattended:
- failtxt = _("Failed to recompute object")
- if index == len(recomputable_objects) - 1:
- QW.QMessageBox.warning(
- self,
- _("Recompute"),
- f"{failtxt} '{obj.title}':\n{report.message}",
- )
- else:
- conttxt = _("Do you want to continue with the next object?")
- answer = QW.QMessageBox.warning(
- self,
- _("Recompute"),
- f"{failtxt} '{obj.title}':\n{report.message}\n\n{conttxt}",
- QW.QMessageBox.Yes | QW.QMessageBox.No,
- )
- if answer == QW.QMessageBox.No:
- break
+ report = self.processor.recompute_processing(obj, interactive=False)
+ if report.success:
+ recomputed_uuids.add(get_uuid(obj))
+ continue
+ if report.cancelled:
+ return recomputed_uuids, True
+ if execenv.unattended:
+ continue
+ failtxt = _("Failed to recompute object")
+ if index == len(objects) - 1:
+ QW.QMessageBox.warning(
+ self,
+ _("Recompute"),
+ f"{failtxt} '{obj.title}':\n{report.message}",
+ )
+ else:
+ conttxt = _("Do you want to continue with the next object?")
+ answer = QW.QMessageBox.warning(
+ self,
+ _("Recompute"),
+ f"{failtxt} '{obj.title}':\n{report.message}\n\n{conttxt}",
+ QW.QMessageBox.Yes | QW.QMessageBox.No,
+ )
+ if answer == QW.QMessageBox.No:
+ return recomputed_uuids, True
+ return recomputed_uuids, False
+
+ def recompute_1_to_0_objects(
+ self, objects: list[SignalObj | ImageObj]
+ ) -> tuple[set[str], bool]:
+ """Recompute 1-to-0 analysis operations for the given objects.
+
+ Args:
+ objects: Objects with stored 1-to-0 analysis parameters
+ """
+ if not objects:
+ return set(), False
+ recomputed_uuids: set[str] = set()
+ with create_progress_bar(
+ self, _("Recomputing analyses"), max_=len(objects)
+ ) as progress:
+ for index, obj in enumerate(objects):
+ progress.setValue(index + 1)
+ QW.QApplication.processEvents()
+ if progress.wasCanceled():
+ return recomputed_uuids, True
+ try:
+ success = self.processor.recompute_analysis(obj)
+ message = _("Analysis computation failed.")
+ except Exception as exc: # pylint: disable=broad-exception-caught
+ success = False
+ message = str(exc)
+ if success:
+ recomputed_uuids.add(get_uuid(obj))
+ continue
+ if execenv.unattended:
+ continue
+ failtxt = _("Failed to recompute analysis")
+ if index == len(objects) - 1:
+ QW.QMessageBox.warning(
+ self,
+ _("Recompute"),
+ f"{failtxt} '{obj.title}':\n{message}",
+ )
+ else:
+ conttxt = _("Do you want to continue with the next object?")
+ answer = QW.QMessageBox.warning(
+ self,
+ _("Recompute"),
+ f"{failtxt} '{obj.title}':\n{message}\n\n{conttxt}",
+ QW.QMessageBox.Yes | QW.QMessageBox.No,
+ )
+ if answer == QW.QMessageBox.No:
+ return recomputed_uuids, True
+ return recomputed_uuids, False
def select_source_objects(self) -> None:
"""Select source objects associated with the selected object's processing.
@@ -2736,8 +3277,9 @@ def add_plot_items_to_dialog(self, dlg: PlotDialog, oids: list[str]) -> None:
QW.QApplication.processEvents()
if progress.wasCanceled():
return None
+ existing_item = self.plothandler.get(get_uuid(obj))
item = create_adapter_from_object(obj).make_item(
- update_from=self.plothandler[get_uuid(obj)]
+ update_from=existing_item
)
item.set_readonly(True)
plot.add_item(item, z=0)
@@ -2763,18 +3305,6 @@ def open_separate_view(
oids = self.objview.get_sel_object_uuids(include_groups=True)
obj = self.objmodel[oids[-1]] # last selected object
- if not all(oid in self.plothandler for oid in oids):
- # This happens for example when opening an already saved workspace with
- # multiple images, and if the user tries to view in a new window a group of
- # images without having selected any object yet. In this case, only the
- # last image is actually plotted (because if the other have the same size
- # and position, they are hidden), and the plot item of every other image is
- # not created yet. So we need to refresh the plot to create the plot item of
- # those images.
- self.plothandler.refresh_plot(
- "selected", update_items=True, force=True, only_visible=False
- )
-
# Create a new dialog and add plot items to it
dlg = self.create_new_dialog(
title=obj.title if len(oids) == 1 else None,
@@ -3308,6 +3838,12 @@ def plot_results(
def delete_results(self) -> None:
"""Delete results"""
+ self.mainwindow.historypanel.add_ui_entry(
+ _("Delete results"),
+ target=self.PANEL_STR_ID + "panel",
+ method_name="delete_results",
+ save_state=False,
+ )
objs = self.objview.get_sel_objects(include_groups=True)
rdatadict = create_resultdata_dict(objs)
if rdatadict:
@@ -3330,8 +3866,8 @@ def delete_results(self) -> None:
# Remove all table and geometry results using adapter methods
TableAdapter.remove_all_from(obj)
GeometryAdapter.remove_all_from(obj)
- # Clear analysis parameters to prevent auto-recompute from
- # attempting to recompute deleted analyses when ROI changes
+ # Clear analysis parameters to prevent a manual recompute
+ # from attempting to recompute deleted analyses
clear_analysis_parameters(obj)
if obj is self.objview.get_current_object():
self.objprop.update_properties_from(obj)
@@ -3355,6 +3891,17 @@ def add_label_with_title(
added as an annotation, and that it can be edited or removed using the
annotation editing window.
"""
+ if title is None:
+ action_title = _("Add object title to plot")
+ else:
+ action_title = _("Add label with title")
+ self.mainwindow.historypanel.add_ui_entry(
+ action_title,
+ target=self.PANEL_STR_ID + "panel",
+ method_name="add_label_with_title",
+ save_state=False,
+ title=title,
+ )
objs = self.objview.get_sel_objects(include_groups=True)
for obj in objs:
create_adapter_from_object(obj).add_label_with_title(title=title)
diff --git a/datalab/gui/panel/history/__init__.py b/datalab/gui/panel/history/__init__.py
new file mode 100644
index 000000000..4cce7abf8
--- /dev/null
+++ b/datalab/gui/panel/history/__init__.py
@@ -0,0 +1,21 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""History panel subpackage — re-exports public history symbols."""
+
+from datalab.gui.panel.history.panel import HistoryPanel
+from datalab.history import HistoryAction, HistorySession, WorkspaceState
+from datalab.history.core import (
+ HISTORY_ACTION_SCHEMA_VERSION,
+ HISTORY_SCHEMA_VERSION,
+)
+from datalab.widgets.historytree import HistoryTree
+
+__all__ = [
+ "HISTORY_ACTION_SCHEMA_VERSION",
+ "HISTORY_SCHEMA_VERSION",
+ "HistoryAction",
+ "HistoryPanel",
+ "HistorySession",
+ "HistoryTree",
+ "WorkspaceState",
+]
diff --git a/datalab/gui/panel/history/chain.py b/datalab/gui/panel/history/chain.py
new file mode 100644
index 000000000..fbea141b1
--- /dev/null
+++ b/datalab/gui/panel/history/chain.py
@@ -0,0 +1,437 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Action↔output chain helpers for the History panel."""
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING
+
+from qtpy import QtWidgets as QW
+
+from datalab.config import _
+from datalab.env import execenv
+from datalab.gui.panel.history.chainmodel import (
+ ReconnectionPlan,
+ ReconnectionTarget,
+ action_input_uuids,
+ remap_processing_parameters,
+)
+from datalab.gui.processor.base import (
+ extract_processing_parameters,
+ insert_processing_parameters,
+)
+from datalab.history import HistoryAction, HistorySession
+from datalab.objectmodel import get_uuid
+
+if TYPE_CHECKING:
+ from datalab.gui.panel.base import BaseDataPanel
+ from datalab.gui.panel.history.panel import HistoryPanel
+
+_logger = logging.getLogger(__name__)
+
+
+def find_parent_session(
+ panel: HistoryPanel, action: HistoryAction
+) -> HistorySession | None:
+ """Return the session that contains ``action``, or None."""
+ for session in panel.history_sessions:
+ if action in session.actions:
+ return session
+ return None
+
+
+def action_panel_target(action: HistoryAction) -> str | None:
+ """Return the main-window data-panel attribute targeted by ``action``."""
+ if action.kind == HistoryAction.KIND_UI:
+ if action.method_name in HistoryAction.UI_CREATION_METHODS:
+ return action.target
+ return None
+ return {"signal": "signalpanel", "image": "imagepanel"}.get(action.panel_str)
+
+
+def resolve_panel_for_action(
+ panel: HistoryPanel, action: HistoryAction
+) -> BaseDataPanel | None:
+ """Return the data panel targeted by ``action``, or ``None``."""
+ panels = {
+ "signalpanel": panel.mainwindow.signalpanel,
+ "imagepanel": panel.mainwindow.imagepanel,
+ }
+ return panels.get(action_panel_target(action))
+
+
+def find_output_object_uuid(
+ panel: HistoryPanel, panel_data: BaseDataPanel, action: HistoryAction
+) -> str | None:
+ """Find the UUID of the output object produced by ``action`` in ``panel_data``.
+
+ Primary path: consult the bijective ``action_output_uuids`` mapping.
+ Fallback path: legacy heuristic on ``processing_parameters`` metadata.
+ """
+ registered = panel.runtime.objects.action_output_uuids.get(action.uuid)
+ if registered:
+ existing_ids = set(panel_data.objmodel.get_object_ids())
+ for out_uuid in registered:
+ if out_uuid in existing_ids:
+ return out_uuid
+ if action.func_name is None:
+ return None
+ recorded_uuids = set(action.state.selection.get(panel_data.PANEL_STR_ID, []))
+ if not recorded_uuids:
+ return None
+ for obj in panel_data.objmodel:
+ pp = extract_processing_parameters(obj)
+ if pp is None or pp.func_name != action.func_name:
+ continue
+ if pp.source_uuid is not None and pp.source_uuid in recorded_uuids:
+ return get_uuid(obj)
+ if pp.source_uuids is not None and recorded_uuids.intersection(pp.source_uuids):
+ return get_uuid(obj)
+ return None
+
+
+def find_action_for_output(
+ panel: HistoryPanel, output_uuid: str, func_name: str
+) -> HistoryAction | None:
+ """Find the :class:`HistoryAction` that produced ``output_uuid``."""
+ if not panel.history_sessions:
+ return None
+ action_uuid = panel.runtime.objects.output_to_action.get(output_uuid)
+ if action_uuid is not None:
+ mapped = next(
+ (
+ action
+ for session in panel.history_sessions
+ for action in session.actions
+ if action.uuid == action_uuid
+ ),
+ None,
+ )
+ if mapped is not None:
+ return mapped if mapped.func_name == func_name else None
+ panel_data: BaseDataPanel | None = None
+ output_obj = None
+ for p in (panel.mainwindow.signalpanel, panel.mainwindow.imagepanel):
+ if p.objmodel.has_uuid(output_uuid):
+ output_obj = p.objmodel[output_uuid]
+ panel_data = p
+ break
+ if panel_data is None or output_obj is None:
+ return None
+ pp = extract_processing_parameters(output_obj)
+ if pp is None or pp.func_name != func_name or pp.source_uuid is None:
+ return None
+ target_source_uuid = pp.source_uuid
+ for current_session in reversed(panel.history_sessions):
+ for action in reversed(current_session.actions):
+ if action.kind != HistoryAction.KIND_COMPUTE:
+ continue
+ if action.func_name != func_name:
+ continue
+ if action.panel_str != panel_data.PANEL_STR_ID:
+ continue
+ captured = action.state.selection.get(panel_data.PANEL_STR_ID, [])
+ if captured and captured[0] == target_source_uuid:
+ return action
+ return None
+
+
+def find_creation_action_for_output(
+ panel: HistoryPanel, output_uuid: str
+) -> HistoryAction | None:
+ """Find the creation (``new_object``) action that produced ``output_uuid``.
+
+ Creation actions are ``KIND_UI`` entries without a ``func_name`` so the
+ standard :func:`find_action_for_output` lookup cannot match them. The
+ bijective ``output_to_action`` mapping is consulted first; if no mapping
+ exists, a fallback scan looks for a creation action whose registered
+ output UUIDs include ``output_uuid``.
+ """
+ if not panel.history_sessions:
+ return None
+ action_uuid = panel.runtime.objects.output_to_action.get(output_uuid)
+ if action_uuid is not None:
+ mapped = next(
+ (
+ action
+ for session in panel.history_sessions
+ for action in session.actions
+ if action.uuid == action_uuid
+ ),
+ None,
+ )
+ if mapped is not None and mapped.kind == HistoryAction.KIND_UI:
+ return mapped
+ for session in reversed(panel.history_sessions):
+ for action in reversed(session.actions):
+ if (
+ action.kind == HistoryAction.KIND_UI
+ and action.method_name in HistoryAction.UI_CREATION_METHODS
+ and output_uuid
+ in panel.runtime.objects.action_output_uuids.get(action.uuid, [])
+ ):
+ return action
+ return None
+
+
+def find_analysis_action(
+ panel: HistoryPanel, obj_uuid: str, func_name: str
+) -> HistoryAction | None:
+ """Find the 1-to-0 analysis action for ``obj_uuid`` with ``func_name``.
+
+ Analysis operations (1-to-0) do not produce a new output object: they
+ write their result to the input object's metadata. The matching action is
+ therefore identified by its input UUID and function name.
+
+ Args:
+ panel: The history panel providing the sessions.
+ obj_uuid: UUID of the analyzed object.
+ func_name: Sigima analysis feature name.
+
+ Returns:
+ The matching :class:`HistoryAction`, or ``None`` if not found.
+ """
+ for session in reversed(panel.history_sessions):
+ for action in reversed(session.actions):
+ if action.kind != HistoryAction.KIND_COMPUTE:
+ continue
+ if action.func_name != func_name:
+ continue
+ if obj_uuid in action_input_uuids(action):
+ return action
+ return None
+
+
+def get_session_of(panel: HistoryPanel, action: HistoryAction) -> HistorySession | None:
+ """Return the session that contains ``action``, or None."""
+ for session in panel.history_sessions:
+ if action in session.actions:
+ return session
+ return None
+
+
+def action_output_uuid(panel: HistoryPanel, action: HistoryAction) -> str | None:
+ """Return the UUID of the object produced by ``action``, or ``None``."""
+ panel_data = resolve_panel_for_action(panel, action)
+ if panel_data is None:
+ return None
+ return find_output_object_uuid(panel, panel_data, action)
+
+
+def action_consumes_any(action: HistoryAction, uuids: set[str]) -> bool:
+ """Return True if ``action``'s input UUIDs intersect ``uuids``."""
+ if action.kind != HistoryAction.KIND_COMPUTE:
+ return False
+ return bool(action_input_uuids(action) & uuids)
+
+
+def get_downstream_actions(
+ panel: HistoryPanel, action: HistoryAction
+) -> list[HistoryAction]:
+ """Return the actions of the current session that depend on ``action``."""
+ if not panel.history_sessions:
+ return []
+ current = get_session_of(panel, action)
+ if current is None:
+ return []
+ root_out = action_output_uuid(panel, action)
+ if root_out is None:
+ return []
+ closure: set[str] = {root_out}
+ downstream: list[HistoryAction] = []
+ idx = current.actions.index(action)
+ for candidate in current.actions[idx + 1 :]:
+ if candidate.kind != HistoryAction.KIND_COMPUTE:
+ continue
+ if not action_consumes_any(candidate, closure):
+ continue
+ downstream.append(candidate)
+ out_uuid = action_output_uuid(panel, candidate)
+ if out_uuid is not None:
+ closure.add(out_uuid)
+ return downstream
+
+
+def resolve_target_outputs(
+ panel: HistoryPanel, panel_data: BaseDataPanel, action: HistoryAction
+) -> tuple[list[str], list[str]]:
+ """Return ``(existing, missing)`` UUIDs registered for ``action``."""
+ registered = list(panel.runtime.objects.action_output_uuids.get(action.uuid, []))
+ existing_ids = set(panel_data.objmodel.get_object_ids())
+ existing: list[str] = [u for u in registered if u in existing_ids]
+ missing: list[str] = [u for u in registered if u not in existing_ids]
+ return existing, missing
+
+
+def existing_input_uuids(panel_data: BaseDataPanel, action: HistoryAction) -> list[str]:
+ """Return recorded input UUIDs that still exist in ``panel_data``."""
+ recorded = action.state.selection.get(panel_data.PANEL_STR_ID, [])
+ return [uuid for uuid in recorded if panel_data.objmodel.has_uuid(uuid)]
+
+
+def prune_output_mapping(panel: HistoryPanel) -> None:
+ """Drop entries of :attr:`output_to_action` whose object no longer exists."""
+ panel.runtime.objects.prune_output_mapping()
+
+
+def rewrite_action_source(
+ action: HistoryAction,
+ pstr: str,
+ old_uuid: str,
+ new_uuid: str,
+) -> None:
+ """Replace ``old_uuid`` with ``new_uuid`` in an action's recorded inputs."""
+ sel = action.state.selection.get(pstr)
+ if sel:
+ action.state.selection[pstr] = [new_uuid if u == old_uuid else u for u in sel]
+ obj2 = action.kwargs.get("obj2_uuids")
+ if isinstance(obj2, str):
+ if obj2 == old_uuid:
+ action.kwargs["obj2_uuids"] = new_uuid
+ elif obj2:
+ action.kwargs["obj2_uuids"] = [new_uuid if u == old_uuid else u for u in obj2]
+
+
+def remove_single_action(panel: HistoryPanel, action: HistoryAction) -> None:
+ """Remove a single action from its session (splice, not truncate)."""
+ for session in panel.history_sessions:
+ if action in session.actions:
+ session.actions.remove(action)
+ panel.runtime.objects.remove_action_outputs(action)
+ if not session.actions:
+ panel.history_sessions.remove(session)
+ break
+
+
+def find_reconnection_source(
+ panel: HistoryPanel, panel_str: str, output_uuid: str
+) -> tuple[HistoryAction | None, str | None]:
+ """Return the action and source UUID behind a removed output."""
+ action_uuid = panel.runtime.objects.output_to_action.get(output_uuid)
+ if action_uuid is None:
+ return None, None
+ for session in panel.history_sessions:
+ for action in session.actions:
+ if action.uuid == action_uuid:
+ selection = action.state.selection.get(panel_str, [])
+ source_uuid = selection[0] if selection else None
+ return action, source_uuid
+ return None, None
+
+
+def plan_reconnection(
+ panel: HistoryPanel,
+ panel_data: BaseDataPanel,
+ removed_uuid: str,
+) -> ReconnectionPlan:
+ """Build a reconnection plan without mutating history or data objects."""
+ panel_str = panel_data.PANEL_STR_ID
+ producer_action, source_uuid = find_reconnection_source(
+ panel, panel_str, removed_uuid
+ )
+ plan = ReconnectionPlan(
+ panel_str=panel_str,
+ removed_uuid=removed_uuid,
+ source_uuid=source_uuid,
+ producer_action=producer_action,
+ )
+ for obj in panel_data.objmodel:
+ parameters = extract_processing_parameters(obj)
+ if parameters is None:
+ continue
+ consumes_removed = parameters.source_uuid == removed_uuid or (
+ parameters.source_uuids and removed_uuid in parameters.source_uuids
+ )
+ if not consumes_removed:
+ continue
+ action = None
+ if parameters.func_name:
+ action = find_action_for_output(panel, get_uuid(obj), parameters.func_name)
+ plan.targets.append(ReconnectionTarget(get_uuid(obj), parameters, action))
+ if not plan.targets:
+ return plan
+ alive_ids = set(panel_data.objmodel.get_object_ids())
+ if source_uuid is None or source_uuid not in alive_ids:
+ label = removed_uuid
+ if producer_action is not None:
+ label = producer_action.title or producer_action.func_name or removed_uuid
+ plan.warning = (
+ _(
+ "“%s” has dependent operations but no valid source to "
+ "reconnect to — downstream results are left unchanged."
+ )
+ % label
+ )
+ return plan
+ if producer_action is not None:
+ outputs = panel.runtime.objects.action_output_uuids.get(
+ producer_action.uuid, []
+ )
+ plan.remove_producer = not any(output in alive_ids for output in outputs)
+ return plan
+
+
+def apply_reconnection_plan(
+ panel: HistoryPanel,
+ panel_data: BaseDataPanel,
+ plan: ReconnectionPlan,
+ roots_to_recompute: list[HistoryAction],
+) -> None:
+ """Apply object and action source rewrites described by ``plan``."""
+ if plan.warning is not None or plan.source_uuid is None:
+ return
+ for target in plan.targets:
+ if not panel_data.objmodel.has_uuid(target.object_uuid):
+ continue
+ obj = panel_data.objmodel[target.object_uuid]
+ insert_processing_parameters(
+ obj,
+ remap_processing_parameters(
+ target.parameters, {plan.removed_uuid: plan.source_uuid}
+ ),
+ )
+ if target.action is not None:
+ rewrite_action_source(
+ target.action,
+ plan.panel_str,
+ plan.removed_uuid,
+ plan.source_uuid,
+ )
+ if target.action not in roots_to_recompute:
+ roots_to_recompute.append(target.action)
+ if plan.remove_producer and plan.producer_action is not None:
+ remove_single_action(panel, plan.producer_action)
+
+
+def reconnect_single_removed(
+ panel: HistoryPanel,
+ panel_data: BaseDataPanel,
+ removed_uuid: str,
+ warnings: list[str],
+ roots_to_recompute: list[HistoryAction],
+) -> None:
+ """Plan and reconnect consumers of one deleted object."""
+ plan = plan_reconnection(panel, panel_data, removed_uuid)
+ apply_reconnection_plan(panel, panel_data, plan, roots_to_recompute)
+ if plan.warning is not None:
+ warnings.append(plan.warning)
+
+
+def show_reconnection_warnings(panel: HistoryPanel, warnings: list[str]) -> None:
+ """Show reconnection warnings at the GUI boundary."""
+ if warnings and not execenv.unattended:
+ QW.QMessageBox.warning(
+ panel.mainwindow,
+ _("Delete"),
+ _("Some operations could not be reconnected after deletion:")
+ + "\n\n• "
+ + "\n• ".join(warnings),
+ )
+
+
+def refresh_reconnected_history(panel: HistoryPanel) -> None:
+ """Refresh the history tree after applying reconnection plans."""
+ panel.tree.populate_tree(panel.history_sessions)
+ panel.refresh_compatibility_items()
+ panel.ui.update_actions_state()
diff --git a/datalab/gui/panel/history/chainmodel.py b/datalab/gui/panel/history/chainmodel.py
new file mode 100644
index 000000000..801243989
--- /dev/null
+++ b/datalab/gui/panel/history/chainmodel.py
@@ -0,0 +1,193 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Derived processing-chain read-model for the History panel."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any
+
+from datalab.gui.processor.base import ProcessingParameters
+from datalab.history import HistoryAction, HistorySession
+
+if TYPE_CHECKING:
+ from datalab.gui.panel.history.panel import HistoryPanel
+
+
+@dataclass
+class ProcessingChain:
+ """A processing chain: a creation/external root action and its descendants.
+
+ Attributes:
+ root: The action that starts the chain (creation action or external
+ root compute/UI action).
+ actions: Ordered actions belonging to the chain, ``root`` first,
+ followed by descendants in session order.
+ session: The :class:`HistorySession` that contains the chain.
+ """
+
+ root: HistoryAction
+ session: HistorySession
+ actions: list[HistoryAction] = field(default_factory=list)
+
+
+@dataclass
+class ChainSelectionPlan:
+ """Processing chains selected from one source session."""
+
+ source_session: HistorySession
+ chains: list[ProcessingChain]
+
+
+@dataclass
+class UuidCloneRegistry:
+ """Objects cloned for duplication and their UUID remapping by panel."""
+
+ uuid_remap: dict[str, dict[str, str]] = field(default_factory=dict)
+ clones_by_panel: dict[str, list[Any]] = field(default_factory=dict)
+
+ def register(
+ self,
+ panel_str: str,
+ old_uuid: str,
+ new_uuid: str,
+ clone: Any,
+ ) -> None:
+ """Register a cloned object and its source-to-clone UUID mapping."""
+ self.uuid_remap.setdefault(panel_str, {})[old_uuid] = new_uuid
+ self.clones_by_panel.setdefault(panel_str, []).append(clone)
+
+ def resolve(self, panel_str: str, old_uuid: str) -> str | None:
+ """Return the cloned UUID corresponding to a source UUID."""
+ return self.uuid_remap.get(panel_str, {}).get(old_uuid)
+
+
+@dataclass
+class DuplicatedSession:
+ """A duplicated session paired with the source that determines insertion."""
+
+ source_session: HistorySession
+ new_session: HistorySession
+
+
+@dataclass
+class DeletionPlan:
+ """Selected history entities grouped by their deletion behavior."""
+
+ actions: list[HistoryAction] = field(default_factory=list)
+ session_ids: set[int] = field(default_factory=set)
+ affected_session: HistorySession | None = None
+
+
+@dataclass
+class DeletionResult:
+ """State needed for orphan cleanup and post-deletion selection."""
+
+ affected_session: HistorySession | None
+ removed_session_ids: set[int]
+ orphan_refs: list[tuple[str, str]] = field(default_factory=list)
+
+
+@dataclass
+class ReconnectionTarget:
+ """A surviving object and history action consuming a removed UUID."""
+
+ object_uuid: str
+ parameters: ProcessingParameters
+ action: HistoryAction | None
+
+
+@dataclass
+class ReconnectionPlan:
+ """Planned source rewrites after one data object has been removed."""
+
+ panel_str: str
+ removed_uuid: str
+ source_uuid: str | None
+ producer_action: HistoryAction | None
+ targets: list[ReconnectionTarget] = field(default_factory=list)
+ warning: str | None = None
+ remove_producer: bool = False
+
+
+def action_input_uuids(action: HistoryAction) -> set[str]:
+ """Return the set of input object UUIDs captured by ``action``.
+
+ Combines the recorded selection for the action's panel with any
+ ``obj2_uuids`` second-operand references (2-to-1 pattern).
+
+ Args:
+ action: The history action whose inputs are extracted.
+
+ Returns:
+ The set of object UUIDs that the action consumed as inputs.
+ """
+ captured: set[str] = set(action.state.selection.get(action.panel_str or "", []))
+ obj2 = action.kwargs.get("obj2_uuids")
+ if obj2:
+ if isinstance(obj2, str):
+ captured.add(obj2)
+ else:
+ captured.update(obj2)
+ return captured
+
+
+def remap_processing_parameters(
+ parameters: ProcessingParameters,
+ uuid_remap: dict[str, str],
+ clear_sources: bool = False,
+) -> ProcessingParameters:
+ """Rebuild processing parameters with remapped source UUIDs."""
+ source_uuid = None if clear_sources else parameters.source_uuid
+ if source_uuid is not None:
+ source_uuid = uuid_remap.get(source_uuid, source_uuid)
+ source_uuids = None if clear_sources else parameters.source_uuids
+ if source_uuids is not None:
+ source_uuids = [uuid_remap.get(uuid, uuid) for uuid in source_uuids]
+ return ProcessingParameters(
+ func_name=parameters.func_name,
+ pattern=parameters.pattern,
+ param=parameters.param,
+ source_uuid=source_uuid,
+ source_uuids=source_uuids,
+ plugin_origin=parameters.plugin_origin,
+ )
+
+
+def build_session_chains(session: HistorySession) -> list[ProcessingChain]:
+ """Return the session's single processing chain.
+
+ In DataLab's history model a session **is** a single linear processing
+ chain: every action of the session belongs to one chain, the first action
+ being its root. Session boundaries are decided at recording time (the
+ "start a new history session?" prompt shown on object creation), so no
+ per-creation splitting is performed here.
+
+ Args:
+ session: The session whose actions form the chain.
+
+ Returns:
+ A single-element list with the session's chain, or an empty list when
+ the session has no actions.
+ """
+ if not session.actions:
+ return []
+ chain = ProcessingChain(root=session.actions[0], session=session)
+ chain.actions = list(session.actions)
+ return [chain]
+
+
+def build_processing_chains(
+ panel: HistoryPanel,
+) -> list[tuple[HistorySession, list[ProcessingChain]]]:
+ """Return, for each session (in order), its ordered list of processing chains.
+
+ Args:
+ panel: The history panel owning sessions and output registry.
+
+ Returns:
+ A list of (session, chains) tuples in session order.
+ """
+ return [
+ (session, build_session_chains(session)) for session in panel.history_sessions
+ ]
diff --git a/datalab/gui/panel/history/facade.py b/datalab/gui/panel/history/facade.py
new file mode 100644
index 000000000..714f03b29
--- /dev/null
+++ b/datalab/gui/panel/history/facade.py
@@ -0,0 +1,285 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Public History panel facade facets backed by cohesive components."""
+
+from __future__ import annotations
+
+from contextlib import contextmanager
+from typing import TYPE_CHECKING, Any, Generator
+
+from qtpy import QtWidgets as QW
+
+from datalab.config import _
+from datalab.env import execenv
+from datalab.gui import historysession_ops as hsess
+from datalab.gui.panel.history import chain as hchain
+from datalab.gui.panel.history import interactive_replay as hreplay
+from datalab.gui.panel.history import recompute as hrec
+from datalab.gui.panel.history import reconnection as hconnect
+from datalab.h5 import history as hio
+from datalab.history import HistoryAction, HistorySession
+
+if TYPE_CHECKING:
+ from datalab.h5.native import NativeH5Reader, NativeH5Writer
+
+
+class HistoryRuntimeFacadeMixin:
+ """Expose runtime controls required by panels, processors, and tests."""
+
+ def reconnect_chain_after_removal(self, data_panel: Any) -> None:
+ """Reconnect chains after objects are removed from a data panel."""
+ hconnect.reconnect_chain_after_removal(self, data_panel)
+
+ def set_tracking_enabled(self, enabled: bool) -> None:
+ """Enable or disable synchronization with data panel object changes."""
+ self.runtime.objects.set_tracking_enabled(enabled)
+
+ @property
+ def record_mode_enabled(self) -> bool:
+ """Return whether record mode is enabled."""
+ return self.runtime.execution.record_mode
+
+ def toggle_edit_mode(self, checked: bool) -> None:
+ """Toggle edit mode, committing pending edits when it is disabled."""
+ has_pending_edits = any(
+ action.has_pending_edits
+ for session in self.history_sessions
+ for action in session.actions
+ )
+ if not checked and has_pending_edits:
+ reply = (
+ QW.QMessageBox.Yes
+ if execenv.unattended
+ else QW.QMessageBox.question(
+ self.mainwindow,
+ _("Commit edit mode changes?"),
+ _(
+ "You are about to exit Edit mode.\n\n"
+ "All parameter changes made during this session will be "
+ "permanently kept.\n"
+ "This action cannot be undone — Restore will no longer "
+ "be available.\n\n"
+ "Do you want to continue?"
+ ),
+ QW.QMessageBox.Yes | QW.QMessageBox.No,
+ QW.QMessageBox.No,
+ )
+ )
+ if reply != QW.QMessageBox.Yes:
+ return
+ self.runtime.execution.edit_mode = checked
+ if not checked:
+ for session in self.history_sessions:
+ for action in session.actions:
+ action.discard_snapshot()
+ self.ui.update_actions_state()
+
+ def toggle_record_mode(self, checked: bool) -> None:
+ """Toggle record mode."""
+ self.runtime.execution.record_mode = checked
+
+ def is_edit_mode(self) -> bool:
+ """Return whether the History panel is in edit mode."""
+ return self.runtime.execution.edit_mode
+
+ @contextmanager
+ def replaying(self) -> Generator[None, None, None]:
+ """Suppress history capture during the context scope."""
+ with self.runtime.execution.replaying():
+ yield
+
+ def is_replaying(self) -> bool:
+ """Return whether an external replay or recompute is in progress."""
+ return self.runtime.execution.replaying_active
+
+ @contextmanager
+ def output_suppressed(self) -> Generator[None, None, None]:
+ """Suppress compute outputs during the context scope."""
+ with self.runtime.execution.output_suppressed():
+ yield
+
+ def is_output_suppressed(self) -> bool:
+ """Return whether compute outputs must not be added to panels."""
+ return self.runtime.execution.output_suppressed_active
+
+
+class HistoryReplayFacadeMixin:
+ """Expose replay and chain lookups consumed outside the history package."""
+
+ def replay_restore_actions(
+ self, replay: bool = True, restore_selection: bool = True
+ ) -> None:
+ """Replay and/or restore selection for selected actions."""
+ hreplay.replay_restore_actions(self, replay, restore_selection)
+
+ def replay_step_by_step(self) -> None:
+ """Replay the current selection with parameter dialogs."""
+ previous = self.runtime.execution.edit_mode
+ self.runtime.execution.edit_mode = True
+ try:
+ self.replay_restore_actions(replay=True, restore_selection=False)
+ finally:
+ self.runtime.execution.edit_mode = previous
+ for session in self.history_sessions:
+ for action in session.actions:
+ action.discard_snapshot()
+ self.ui.update_actions_state()
+
+ def find_action_for_output(
+ self, output_uuid: str, func_name: str
+ ) -> HistoryAction | None:
+ """Return the action that produced an output UUID."""
+ return hchain.find_action_for_output(self, output_uuid, func_name)
+
+ def find_creation_action_for_output(self, output_uuid: str) -> HistoryAction | None:
+ """Return the creation action that produced an output UUID."""
+ return hchain.find_creation_action_for_output(self, output_uuid)
+
+ def find_analysis_action(
+ self, obj_uuid: str, func_name: str
+ ) -> HistoryAction | None:
+ """Return the matching analysis action for an object UUID."""
+ return hchain.find_analysis_action(self, obj_uuid, func_name)
+
+ def action_output_uuid(self, action: HistoryAction) -> str | None:
+ """Return the UUID of the object produced by an action."""
+ return hchain.action_output_uuid(self, action)
+
+ def refresh_action(self, action: HistoryAction) -> None:
+ """Refresh an action after its arguments are mutated."""
+ hrec.refresh_action(self, action)
+
+ def recompute_cascade(
+ self,
+ root_action: HistoryAction,
+ descendants: list[HistoryAction] | None = None,
+ ) -> None:
+ """Recompute descendants of a root action in place."""
+ hrec.recompute_cascade(self, root_action, descendants)
+
+ def on_current_panel_changed(self, panel_str: str) -> None:
+ """React to a Signal/Image panel switch."""
+ self.navigation.on_current_panel_changed(panel_str)
+
+
+class HistoryRecordingFacadeMixin:
+ """Expose history-session recording operations used by application code."""
+
+ def create_new_session(self, panel_str: str | None = None) -> HistorySession:
+ """Create a new history session for a data panel."""
+ return hsess.create_new_session(self, panel_str=panel_str)
+
+ def start_new_session_after_workspace_reset(self) -> None:
+ """Start a history session after a workspace reset when useful."""
+ hsess.start_new_session_after_workspace_reset(self)
+
+ def maybe_start_session_for_input(self, *, load: bool = False) -> None:
+ """Offer to start a new session before recording an input."""
+ hsess.maybe_start_session_for_input(self, load=load)
+
+ @contextmanager
+ def session_prompt_suppressed(self) -> Generator[None, None, None]:
+ """Suppress the new-session prompt during a batch load."""
+ with self.runtime.execution.session_prompt_suppressed():
+ yield
+
+ def add_compute_entry(
+ self,
+ action_title: str,
+ panel_str: str,
+ func_name: str,
+ pattern: str,
+ save_state: bool = True,
+ output_uuids: list[str] | None = None,
+ plugin_origin: dict[str, Any] | None = None,
+ **kwargs: Any,
+ ) -> HistoryAction | None:
+ """Add a compute entry to history."""
+ return hsess.add_compute_entry(
+ self,
+ action_title,
+ panel_str,
+ func_name,
+ pattern,
+ save_state,
+ output_uuids,
+ plugin_origin,
+ **kwargs,
+ )
+
+ def add_compute_entry_from_pp(
+ self,
+ action_title: str,
+ pp: Any,
+ panel_str: str,
+ save_state: bool = True,
+ output_uuids: list[str] | None = None,
+ plugin_origin: dict[str, Any] | None = None,
+ **extras: Any,
+ ) -> HistoryAction | None:
+ """Add a compute entry built from processing parameters."""
+ return hsess.add_compute_entry_from_pp(
+ self,
+ action_title,
+ pp,
+ panel_str,
+ save_state,
+ output_uuids,
+ plugin_origin,
+ **extras,
+ )
+
+ def register_action_outputs(
+ self, action: HistoryAction, output_uuids: list[str]
+ ) -> None:
+ """Register output UUIDs produced by an action."""
+ hsess.register_action_outputs(self, action, output_uuids)
+
+ def capture_outputs(
+ self, action: HistoryAction | None
+ ) -> Generator[None, None, None]:
+ """Return a context manager capturing outputs produced by an action."""
+ return hsess.capture_outputs(self, action)
+
+ def add_ui_entry(
+ self,
+ action_title: str,
+ target: str,
+ method_name: str,
+ save_state: bool = True,
+ **kwargs: Any,
+ ) -> HistoryAction | None:
+ """Add a UI entry to history."""
+ return hsess.add_ui_entry(
+ self, action_title, target, method_name, save_state, **kwargs
+ )
+
+
+class HistoryPersistenceFacadeMixin:
+ """Expose standalone and workspace HDF5 persistence operations."""
+
+ def save_to_dlhist_file(self, filename: str | None = None) -> bool:
+ """Save history to a standalone history file."""
+ return hio.save_to_dlhist_file(self, filename)
+
+ def open_dlhist_file(self, filename: str | None = None) -> bool:
+ """Open history from a standalone history file."""
+ return hio.open_dlhist_file(self, filename)
+
+ def import_dlhist_into_new_session(self, reader: NativeH5Reader) -> None:
+ """Import standalone history into a new session."""
+ hio.import_dlhist_into_new_session(self, reader)
+
+ def refresh_compatibility_items(self, *args: Any) -> None:
+ """Refresh compatibility icons in the history tree."""
+ hio.refresh_compatibility_items(self, *args)
+
+ def serialize_to_hdf5(self, writer: NativeH5Writer) -> None:
+ """Serialize history sessions to HDF5."""
+ hio.serialize_to_hdf5(self, writer)
+
+ def deserialize_from_hdf5(
+ self, reader: NativeH5Reader, reset_all: bool = False
+ ) -> None:
+ """Deserialize history sessions from HDF5."""
+ hio.deserialize_from_hdf5(self, reader, reset_all)
diff --git a/datalab/gui/panel/history/interactive_replay.py b/datalab/gui/panel/history/interactive_replay.py
new file mode 100644
index 000000000..7ee0f8bfc
--- /dev/null
+++ b/datalab/gui/panel/history/interactive_replay.py
@@ -0,0 +1,264 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Interactive (dialog-driven) replay helpers for the History panel."""
+
+from __future__ import annotations
+
+import copy
+import logging
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+import guidata.dataset as gds
+from qtpy import QtWidgets as QW
+
+from datalab.config import _
+from datalab.env import execenv
+from datalab.gui.panel.history import chain as hchain
+from datalab.gui.panel.history import recompute as hrec
+from datalab.history import HistoryAction, HistorySession
+from datalab.history.core import copy_history_value
+
+if TYPE_CHECKING:
+ from datalab.gui.panel.history.panel import HistoryPanel
+
+_logger = logging.getLogger(__name__)
+
+
+@dataclass
+class ActionParamEdit:
+ """Parameter dialog target and action kwargs to update after acceptance."""
+
+ dialog_target: gds.DataSet | gds.DataSetGroup
+ new_kwargs: dict[str, Any]
+
+
+def replay_restore_actions(
+ panel: HistoryPanel, replay: bool = True, restore_selection: bool = True
+) -> None:
+ """Replay and/or restore selection for the selected actions."""
+ panel.refresh_compatibility_items()
+ selected = panel.tree.get_selected_actions_or_sessions(panel.history_sessions)
+ if not selected:
+ if not panel.history_sessions:
+ return
+ selected = [panel.history_sessions[-1]]
+ edit_actions: list[HistoryAction] = []
+ for session_or_action in selected:
+ if isinstance(session_or_action, HistoryAction) and session_or_action.is_stale:
+ hrec.recompute_cascade(panel, session_or_action)
+ continue
+ if not session_or_action.is_current_state_compatible(
+ panel.mainwindow, restore_selection=restore_selection
+ ):
+ if not execenv.unattended:
+ QW.QMessageBox.critical(
+ panel.mainwindow,
+ _("Error"),
+ _("The current workspace state is not compatible with the action."),
+ )
+ return
+ if replay:
+ if panel.runtime.execution.edit_mode and isinstance(
+ session_or_action, HistoryAction
+ ):
+ # Defer: edit only the selected actions, no automatic cascade
+ edit_actions.append(session_or_action)
+ else:
+ # Scope decision: clicking a session in edit mode now replays it
+ # WITH parameter dialogs (view-only session replay disabled).
+ with panel.replaying(), panel.output_suppressed():
+ session_or_action.replay(
+ panel.mainwindow,
+ restore_selection=restore_selection,
+ edit=panel.runtime.execution.edit_mode,
+ )
+ elif restore_selection:
+ if panel.runtime.execution.edit_mode or any(
+ action.has_pending_edits
+ for session in panel.history_sessions
+ for action in session.actions
+ ):
+ restore_action_params(panel, session_or_action)
+ else:
+ session_or_action.restore(panel.mainwindow)
+ if edit_actions:
+ edit_mode_replay_actions(panel, edit_actions)
+
+
+def prepare_action_param_edit(action: HistoryAction) -> ActionParamEdit | None:
+ """Prepare the editable parameter copy for ``action``."""
+ result = None
+ if (
+ action.kind == HistoryAction.KIND_UI
+ and action.method_name in HistoryAction.UI_CREATION_METHODS
+ ):
+ param = action.kwargs.get("param")
+ if param is not None:
+ edited = copy.deepcopy(param)
+ result = ActionParamEdit(edited, {"param": edited})
+ elif action.pattern in {"1_to_1", "1_to_0", "n_to_1", "2_to_1"}:
+ param = action.kwargs.get("param")
+ if param is not None:
+ edited = copy.deepcopy(param)
+ result = ActionParamEdit(edited, {"param": edited})
+ elif action.pattern == "1_to_n":
+ params = action.kwargs.get("params") or []
+ if params:
+ edited_params = [copy.deepcopy(p) for p in params]
+ dialog_target = gds.DataSetGroup(edited_params, title=_("Parameters"))
+ result = ActionParamEdit(dialog_target, {"params": edited_params})
+ return result
+
+
+def prompt_edit_action_params(
+ panel: HistoryPanel, action: HistoryAction
+) -> bool | None:
+ """Open the parameter dialog for *action* according to its pattern."""
+ edit = prepare_action_param_edit(action)
+ if edit is None:
+ return None
+ if not edit.dialog_target.edit(parent=panel.mainwindow):
+ return False
+ action.snapshot_kwargs()
+ action.kwargs.update(edit.new_kwargs)
+ return True
+
+
+def edit_mode_replay_actions(panel: HistoryPanel, actions: list[HistoryAction]) -> None:
+ """Edit selected actions and recompute their affected branches once.
+
+ Each selected action gets exactly one parameter dialog. Recomputable
+ selected actions are always included, while accepted parameter edits also
+ include all downstream dependent actions. The resulting global plan is
+ deduplicated and executed in session order. A re-entrance guard prevents
+ nested prompt loops.
+ """
+ # Deduplicate and sort the selected actions in their session order
+ ordered = order_selected_actions(panel, actions)
+ if not ordered:
+ return
+ with panel.runtime.execution.replaying_edits() as started:
+ if not started:
+ return
+ entry_states = {
+ action.uuid: (
+ copy_history_value(action.kwargs),
+ copy_history_value(action.saved_kwargs),
+ )
+ for action in ordered
+ }
+ edited_actions: list[HistoryAction] = []
+ recomputable: list[HistoryAction] = []
+ deferred_actions: list[HistoryAction] = []
+ for action in ordered:
+ is_creation = (
+ action.kind == HistoryAction.KIND_UI
+ and action.method_name in HistoryAction.UI_CREATION_METHODS
+ )
+ is_compute = (
+ action.kind == HistoryAction.KIND_COMPUTE and action.pattern is not None
+ )
+ if not is_creation and not is_compute:
+ deferred_actions.append(action)
+ continue
+ result = prompt_edit_action_params(panel, action)
+ if result is False:
+ for selected_action in ordered:
+ kwargs, saved_kwargs = entry_states[selected_action.uuid]
+ selected_action.kwargs = kwargs
+ selected_action.saved_kwargs = saved_kwargs
+ panel.tree.refresh_action_item(selected_action)
+ return
+ if result is True:
+ edited_actions.append(action)
+ recomputable.append(action)
+
+ for action in edited_actions:
+ panel.tree.refresh_action_item(action)
+ planned = list(recomputable)
+ for action in edited_actions:
+ planned.extend(hchain.get_downstream_actions(panel, action))
+ planned = order_selected_actions(panel, planned)
+ execution_plan = order_selected_actions(panel, deferred_actions + planned)
+ for action in planned:
+ action.is_stale = True
+ panel.tree.refresh_action_item(action)
+ QW.QApplication.processEvents()
+ blocked_outputs: set[str] = set()
+ try:
+ for action in execution_plan:
+ if action in deferred_actions:
+ with panel.replaying(), panel.output_suppressed():
+ action.replay(
+ panel.mainwindow, restore_selection=True, edit=True
+ )
+ continue
+ if hchain.action_consumes_any(action, blocked_outputs):
+ blocked_outputs.update(
+ panel.runtime.objects.action_output_uuids.get(action.uuid, [])
+ )
+ continue
+ success = hrec.recompute_action_in_place(panel, action)
+ action.is_stale = not success
+ panel.tree.refresh_action_item(action)
+ if not success:
+ blocked_outputs.update(
+ panel.runtime.objects.action_output_uuids.get(action.uuid, [])
+ )
+ finally:
+ hrec.flush_cascade_warnings(panel)
+ QW.QApplication.processEvents()
+
+
+def order_selected_actions(
+ panel: HistoryPanel, actions: list[HistoryAction]
+) -> list[HistoryAction]:
+ """Deduplicate ``actions`` and sort them by (session, position) order."""
+ rank: dict[str, int] = {}
+ pos = 0
+ for session in panel.history_sessions:
+ for action in session.actions:
+ rank[action.uuid] = pos
+ pos += 1
+ seen: set[str] = set()
+ unique: list[HistoryAction] = []
+ for action in actions:
+ if action.uuid in seen:
+ continue
+ seen.add(action.uuid)
+ unique.append(action)
+ unique.sort(key=lambda a: rank.get(a.uuid, 0))
+ return unique
+
+
+def restore_action_params(
+ panel: HistoryPanel, item: HistoryAction | HistorySession
+) -> None:
+ """Restore original kwargs from snapshot and recompute in-place."""
+ actions: list[HistoryAction]
+ if isinstance(item, HistorySession):
+ actions = [
+ a
+ for a in item.actions
+ if a.kind == HistoryAction.KIND_COMPUTE
+ or (
+ a.kind == HistoryAction.KIND_UI
+ and a.method_name in HistoryAction.UI_CREATION_METHODS
+ )
+ ]
+ else:
+ actions = [item]
+ for action in actions:
+ if not action.has_pending_edits:
+ continue
+ action.restore_kwargs()
+ panel.tree.refresh_action_item(action)
+ success = hrec.recompute_action_in_place(panel, action)
+ action.is_stale = not success
+ panel.tree.refresh_action_item(action)
+ if not success:
+ break
+ if not isinstance(item, HistorySession):
+ hrec.recompute_cascade(panel, action)
+ panel.ui.update_actions_state()
diff --git a/datalab/gui/panel/history/navigation.py b/datalab/gui/panel/history/navigation.py
new file mode 100644
index 000000000..1661d6a3a
--- /dev/null
+++ b/datalab/gui/panel/history/navigation.py
@@ -0,0 +1,241 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Selection, active-session, and step navigation for the History panel."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from qtpy import QtCore as QC
+from qtpy import QtWidgets as QW
+
+from datalab.gui.panel.history import chain as hchain
+from datalab.history import HistoryAction, HistorySession
+
+if TYPE_CHECKING:
+ from datalab.gui.panel.history.panel import HistoryPanel
+
+
+class HistoryNavigation:
+ """Coordinate history selection, active sessions, and step navigation."""
+
+ def __init__(self, panel: HistoryPanel) -> None:
+ self.panel = panel
+ self.syncing = False
+ self.active_session_by_panel: dict[str, HistorySession] = {}
+ self.session_increment = 0
+
+ def current_action(self) -> HistoryAction | None:
+ """Return the action currently selected in the tree."""
+ item = self.panel.tree.currentItem()
+ if item is None or item.parent() is None:
+ return None
+ uuid = item.data(0, QC.Qt.UserRole)
+ try:
+ return self.panel.tree.get_action_from_uuid(
+ uuid, self.panel.history_sessions
+ )
+ except ValueError:
+ return None
+
+ def current_panel_str(self) -> str:
+ """Return the current data panel identifier, defaulting to signal."""
+ panel_str = self.panel.mainwindow.get_current_panel()
+ return panel_str if panel_str in ("signal", "image") else "signal"
+
+ def sync_panel_selection(self) -> None:
+ """Synchronize data-panel selection from the selected history item."""
+ if self.panel.runtime.execution.replaying_active or self.syncing:
+ return
+ item = self.panel.tree.currentItem()
+ if item is None or not item.isSelected():
+ return
+ if item.parent() is None:
+ index = self.panel.tree.indexOfTopLevelItem(item)
+ if index < 0 or index >= len(self.panel.history_sessions):
+ return
+ session = self.panel.history_sessions[index]
+ action = next(
+ (
+ candidate
+ for candidate in session.actions
+ if candidate.kind == HistoryAction.KIND_COMPUTE
+ ),
+ None,
+ )
+ else:
+ action = self.current_action()
+ if action is None:
+ return
+ data_panel = hchain.resolve_panel_for_action(self.panel, action)
+ if data_panel is None:
+ return
+ output_uuid = hchain.find_output_object_uuid(self.panel, data_panel, action)
+ target_uuids = (
+ [output_uuid]
+ if output_uuid is not None
+ else hchain.existing_input_uuids(data_panel, action)
+ )
+ if not target_uuids:
+ return
+ self.syncing = True
+ try:
+ with QC.QSignalBlocker(data_panel.objview):
+ data_panel.objview.select_objects(target_uuids)
+ self.panel.mainwindow.set_current_panel(data_panel)
+ finally:
+ self.syncing = False
+
+ def update_state_widget(self) -> None:
+ """Display the workspace state of the selected action."""
+ action = self.current_action()
+ self.panel.ui.state_widget.update_from_state(
+ action.state if action is not None else None
+ )
+
+ def session_panel_str(self, session: HistorySession) -> str | None:
+ """Return the data panel to which a session belongs."""
+ for action in session.actions:
+ if action.panel_str:
+ return action.panel_str
+ for panel_str, active_session in self.active_session_by_panel.items():
+ if active_session is session:
+ return panel_str
+ return None
+
+ def get_active_session(self, panel_str: str) -> HistorySession | None:
+ """Return the valid active recording session for a data panel."""
+ session = self.active_session_by_panel.get(panel_str)
+ if session is not None and session in self.panel.history_sessions:
+ return session
+ return None
+
+ def set_active_session(
+ self, session: HistorySession, panel_str: str | None = None
+ ) -> None:
+ """Mark a session as active for its data panel."""
+ target = panel_str or self.session_panel_str(session)
+ if target:
+ self.active_session_by_panel[target] = session
+ self.refresh_active_session_highlight()
+
+ def refresh_active_session_highlight(self) -> None:
+ """Highlight each data panel's active session in the tree."""
+ active = {
+ session.number: panel_str
+ for panel_str, session in self.active_session_by_panel.items()
+ if session in self.panel.history_sessions
+ }
+ self.panel.tree.set_active_sessions(active)
+
+ def set_active_session_from_selection(self) -> None:
+ """Make the selected session active while recording."""
+ if not self.panel.record_mode_enabled:
+ return
+ item = self.panel.tree.currentItem()
+ if item is None or not item.isSelected():
+ return
+ if item.parent() is None:
+ index = self.panel.tree.indexOfTopLevelItem(item)
+ if not 0 <= index < len(self.panel.history_sessions):
+ return
+ session = self.panel.history_sessions[index]
+ else:
+ action = self.current_action()
+ session = (
+ hchain.find_parent_session(self.panel, action)
+ if action is not None
+ else None
+ )
+ if session is not None:
+ self.set_active_session(session)
+
+ def on_current_panel_changed(self, panel_str: str) -> None:
+ """Bring the current data panel's active recording session into view."""
+ if panel_str not in ("signal", "image"):
+ return
+ self.refresh_active_session_highlight()
+ session = self.get_active_session(panel_str)
+ if session is not None and session in self.panel.history_sessions:
+ index = self.panel.history_sessions.index(session)
+ item = self.panel.tree.topLevelItem(index)
+ if item is not None:
+ self.panel.tree.scrollToItem(item)
+
+ def current_session(self) -> HistorySession | None:
+ """Return the session relevant for step navigation."""
+ item = self.panel.tree.currentItem()
+ if item is not None:
+ top = item
+ while top.parent() is not None:
+ top = top.parent()
+ index = self.panel.tree.indexOfTopLevelItem(top)
+ if 0 <= index < len(self.panel.history_sessions):
+ return self.panel.history_sessions[index]
+ return self.panel.history_sessions[-1] if self.panel.history_sessions else None
+
+ def can_step_prev(self) -> bool:
+ """Return whether a previous action exists in the current session."""
+ session = self.current_session()
+ action = self.current_action()
+ return bool(
+ session is not None
+ and session.actions
+ and action in session.actions
+ and session.actions.index(action) > 0
+ )
+
+ def can_step_next(self) -> bool:
+ """Return whether a next action exists in the current session."""
+ session = self.current_session()
+ if session is None or not session.actions:
+ return False
+ action = self.current_action()
+ return (
+ action not in session.actions
+ or session.actions.index(action) < len(session.actions) - 1
+ )
+
+ def select_action_in_tree(self, action: HistoryAction) -> None:
+ """Select an action in the history tree."""
+ iterator = QW.QTreeWidgetItemIterator(self.panel.tree)
+ while iterator.value():
+ item = iterator.value()
+ if item.data(0, QC.Qt.UserRole) == action.uuid:
+ self.panel.tree.clearSelection()
+ self.panel.tree.setCurrentItem(item)
+ item.setSelected(True)
+ return
+ iterator += 1
+
+ def step_prev(self) -> None:
+ """Select the previous action in the current session."""
+ if not self.can_step_prev():
+ return
+ session = self.current_session()
+ action = self.current_action()
+ self.select_action_in_tree(session.actions[session.actions.index(action) - 1])
+ self.panel.ui.update_actions_state()
+
+ def step_next(self) -> None:
+ """Select the next action in the current session."""
+ if not self.can_step_next():
+ return
+ session = self.current_session()
+ action = self.current_action()
+ target = (
+ session.actions[0]
+ if action not in session.actions
+ else session.actions[session.actions.index(action) + 1]
+ )
+ self.select_action_in_tree(target)
+ self.panel.ui.update_actions_state()
+
+ def select_sessions(self, sessions: list[HistorySession]) -> None:
+ """Select top-level tree items matching sessions."""
+ self.panel.tree.clearSelection()
+ for session in sessions:
+ index = self.panel.history_sessions.index(session)
+ item = self.panel.tree.topLevelItem(index)
+ item.setSelected(True)
+ self.panel.tree.setCurrentItem(item)
diff --git a/datalab/gui/panel/history/panel.py b/datalab/gui/panel/history/panel.py
new file mode 100644
index 000000000..e55bdec0c
--- /dev/null
+++ b/datalab/gui/panel/history/panel.py
@@ -0,0 +1,103 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+.. History panel (see parent package :mod:`datalab.gui.panel`)
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Generator
+
+from guidata.configtools import get_icon
+from guidata.widgets.dockable import DockableWidgetMixin
+from qtpy import QtCore as QC
+
+from datalab.config import Conf, _
+from datalab.env import execenv
+from datalab.gui import historysession_ops as hsess
+from datalab.gui.panel.base import AbstractPanel
+from datalab.gui.panel.history.facade import (
+ HistoryPersistenceFacadeMixin,
+ HistoryRecordingFacadeMixin,
+ HistoryReplayFacadeMixin,
+ HistoryRuntimeFacadeMixin,
+)
+from datalab.gui.panel.history.navigation import HistoryNavigation
+from datalab.gui.panel.history.runtime import HistoryRuntime
+from datalab.gui.panel.history.ui import HistoryPanelUI
+from datalab.history import HistoryAction, HistorySession
+from datalab.widgets.historytree import HistoryTree
+
+if TYPE_CHECKING:
+ from datalab.gui.main import DLMainWindow
+
+
+class HistoryPanel(
+ HistoryRuntimeFacadeMixin,
+ HistoryReplayFacadeMixin,
+ HistoryRecordingFacadeMixin,
+ HistoryPersistenceFacadeMixin,
+ AbstractPanel,
+ DockableWidgetMixin,
+):
+ """History panel"""
+
+ LOCATION = QC.Qt.RightDockWidgetArea
+ PANEL_STR = _("History panel")
+
+ H5_PREFIX = "DataLab_His"
+
+ SIG_OBJECT_MODIFIED = QC.Signal()
+
+ FILE_FILTERS = f"{_('History files')} (*.dlhist)"
+
+ def __init__(self, parent: DLMainWindow) -> None:
+ super().__init__(parent)
+ self.mainwindow = parent
+ self.setWindowTitle(self.PANEL_STR)
+ self.setWindowIcon(get_icon("history.svg"))
+ self.setOrientation(QC.Qt.Vertical)
+
+ self.history_sessions: list[HistorySession] = []
+ self.tree = HistoryTree(self)
+ self.runtime = HistoryRuntime(self, self.reconnect_chain_after_removal)
+ self.navigation = HistoryNavigation(self)
+ self.ui = HistoryPanelUI(self)
+ self.set_tracking_enabled(True)
+ self.runtime.objects.refresh_obj_ids_snapshot()
+ self.ui.update_actions_state()
+ self.refresh_compatibility_items()
+ if not execenv.unattended and Conf.proc.history_auto_record.get(False):
+ self.ui.actions["record"].setChecked(True)
+ self.create_new_session()
+
+ def __len__(self) -> int:
+ """Return number of objects."""
+ return sum(len(session.actions) for session in self.history_sessions)
+
+ def __getitem__(self, nb: int) -> HistoryAction:
+ """Return object from its number (1 to N)."""
+ for session in self.history_sessions:
+ if nb <= len(session.actions):
+ return session.actions[nb - 1]
+ nb -= len(session.actions)
+ raise IndexError("Index out of range")
+
+ def __iter__(self) -> Generator[HistoryAction, None, None]:
+ """Iterate over objects."""
+ for session in self.history_sessions:
+ yield from session.actions
+
+ # ------ AbstractPanel interface ---------------------------------------------------
+ def create_object(self) -> HistoryAction:
+ """Create and return object."""
+ return HistoryAction()
+
+ def add_object(self, obj: HistoryAction) -> None:
+ """Add an object to the history."""
+ return hsess.add_object(self, obj)
+
+ def remove_all_objects(self) -> None:
+ """Remove all objects."""
+ super().remove_all_objects()
+ self.runtime.objects.clear_output_mappings()
diff --git a/datalab/gui/panel/history/recompute.py b/datalab/gui/panel/history/recompute.py
new file mode 100644
index 000000000..b5a8cea13
--- /dev/null
+++ b/datalab/gui/panel/history/recompute.py
@@ -0,0 +1,628 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""In-place recompute helpers for the History panel cascade."""
+
+from __future__ import annotations
+
+import copy
+import logging
+from typing import TYPE_CHECKING
+
+from qtpy import QtWidgets as QW
+from sigima.objects import ImageObj, SignalObj
+
+from datalab.config import _
+from datalab.env import execenv
+from datalab.gui.creation import (
+ create_image_from_param,
+ create_signal_from_param,
+ insert_creation_parameters,
+ prepare_signal_parameters,
+)
+from datalab.gui.panel.history import chain as hchain
+from datalab.gui.processor.base import (
+ FeatureNotFoundError,
+ ProcessingParameters,
+ extract_analysis_parameters,
+ extract_processing_parameters,
+ insert_processing_parameters,
+)
+from datalab.history import HistoryAction
+from datalab.objectmodel import get_uuid
+
+if TYPE_CHECKING:
+ from datalab.gui.panel.base import BaseDataPanel
+ from datalab.gui.panel.history.panel import HistoryPanel
+
+_logger = logging.getLogger(__name__)
+
+
+def refresh_action(panel: HistoryPanel, action: HistoryAction) -> None:
+ """Refresh the tree display for ``action`` after its kwargs were mutated.
+
+ Used by :meth:`ObjectProp.apply_processing_parameters` to update the
+ Description column when the user edits a ``param`` from the Processing
+ tab of the Signal/Image panel.
+ """
+ panel.tree.refresh_action_item(action)
+
+
+def update_obj_in_place(
+ target_obj: SignalObj | ImageObj,
+ new_obj: SignalObj | ImageObj,
+) -> None:
+ """Copy data + title + metadata from ``new_obj`` onto ``target_obj``.
+
+ Preserves the target's identity (UUID, panel position, references)
+ while reflecting all user-visible changes produced by a recompute.
+ """
+ target_obj.title = new_obj.title
+ if isinstance(target_obj, SignalObj):
+ target_obj.xydata = new_obj.xydata
+ else:
+ target_obj.data = new_obj.data
+ target_obj.invalidate_maskdata_cache()
+ try:
+ saved_uuid = target_obj.metadata.get("__uuid")
+ saved_number = target_obj.metadata.get("__number")
+ target_obj.metadata.clear()
+ target_obj.metadata.update(new_obj.metadata)
+ if saved_uuid is not None:
+ target_obj.metadata["__uuid"] = saved_uuid
+ if saved_number is not None:
+ target_obj.metadata["__number"] = saved_number
+ except AttributeError:
+ pass
+
+
+def refresh_target(panel_data: BaseDataPanel, output_uuid: str) -> None:
+ """Refresh tree item + plot for ``output_uuid`` in ``panel_data``.
+
+ Also updates the Properties panel when the refreshed object is
+ currently selected, marks the object as freshly processed so the
+ Processing tab is shown, and emits ``SIG_OBJECT_MODIFIED``.
+ """
+ panel_data.objview.update_item(output_uuid)
+ panel_data.refresh_plot(output_uuid, update_items=True, force=True)
+ obj = (
+ panel_data.objmodel[output_uuid]
+ if panel_data.objmodel.has_uuid(output_uuid)
+ else None
+ )
+ if obj is not None:
+ if obj is panel_data.objview.get_current_object():
+ panel_data.objprop.update_properties_from(obj, force_tab="processing")
+ else:
+ panel_data.objprop.mark_as_freshly_processed(obj)
+ panel_data.SIG_OBJECT_MODIFIED.emit()
+
+
+def record_missing_outputs(
+ panel: HistoryPanel, action: HistoryAction, missing: list[str]
+) -> None:
+ """Log + queue a user-facing warning for deleted output objects."""
+ if not missing:
+ return
+ name = action.func_name or action.title or action.uuid
+ _logger.warning(
+ "Cascade recompute: %d output(s) missing for action %s (%s).",
+ len(missing),
+ action.uuid,
+ name,
+ )
+ panel.runtime.execution.cascade_warnings.append(
+ _(
+ "Action %s has been edited but its target output object(s) "
+ "no longer exist — skipping."
+ )
+ % name
+ )
+
+
+def recompute_action_in_place(panel: HistoryPanel, action: HistoryAction) -> bool:
+ """Re-run ``action`` on the existing output object(s) (same UUIDs)."""
+ if (
+ action.kind == HistoryAction.KIND_UI
+ and action.method_name in HistoryAction.UI_CREATION_METHODS
+ ):
+ return recompute_creation_in_place(panel, action)
+ if action.kind != HistoryAction.KIND_COMPUTE:
+ return False
+ method = {
+ "1_to_1": recompute_1_to_1_in_place,
+ "1_to_n": recompute_1_to_n_in_place,
+ "n_to_1": recompute_n_to_1_in_place,
+ "2_to_1": recompute_2_to_1_in_place,
+ "1_to_0": recompute_1_to_0_in_place,
+ }.get(action.pattern or "")
+ if method is None:
+ _logger.warning(
+ "Cascade recompute: unsupported pattern %r for action %s.",
+ action.pattern,
+ action.uuid,
+ )
+ panel.runtime.execution.cascade_warnings.append(
+ _("Action %s uses pattern %r which is not recomputable yet.")
+ % (action.func_name or action.uuid, action.pattern)
+ )
+ return False
+ try:
+ warning_count = len(panel.runtime.execution.cascade_warnings)
+ success = method(panel, action)
+ if (
+ not success
+ and len(panel.runtime.execution.cascade_warnings) == warning_count
+ ):
+ panel.runtime.execution.cascade_warnings.append(
+ _("Action %s could not be fully recomputed.")
+ % (action.func_name or action.uuid)
+ )
+ return success
+ except FeatureNotFoundError as exc:
+ handle_missing_feature(panel, action, exc)
+ except (RuntimeError, ValueError, AttributeError, KeyError, TypeError) as exc:
+ action.is_stale = True
+ panel.runtime.execution.broken_actions.add(action.uuid)
+ _logger.exception(
+ "Cascade recompute failed for action %s (%s): %s",
+ action.uuid,
+ action.func_name,
+ exc,
+ )
+ panel.runtime.execution.cascade_warnings.append(
+ _("Recompute failed for action %s: %s")
+ % (action.func_name or action.uuid, exc)
+ )
+ return False
+
+
+def handle_missing_feature(
+ panel: HistoryPanel, action: HistoryAction, exc: FeatureNotFoundError
+) -> None:
+ """Flag ``action`` as broken (missing plugin) and queue a user warning."""
+ action.is_stale = True
+ panel.runtime.execution.broken_actions.add(action.uuid)
+ plugin_origin = action.plugin_origin or exc.plugin_origin or {}
+ directory = (plugin_origin.get("directory") if plugin_origin else None) or "?"
+ param = action.kwargs.get("param")
+ paramclass = exc.paramclass_name or (
+ type(param).__name__ if param is not None else "—"
+ )
+ func_name = action.func_name or exc.func_name or action.uuid
+ location = f"{directory}/plugins:{func_name}"
+ _logger.warning(
+ "Cascade recompute: plugin missing for action %s (%s) — %s.",
+ action.uuid,
+ func_name,
+ location,
+ )
+ panel.runtime.execution.cascade_warnings.append(
+ _(
+ "Action %(name)s skipped: plugin '%(loc)s' is missing.\n"
+ "Required parameter class: %(param)s\n"
+ "Reinstall the plugin to re-enable this action."
+ )
+ % {"name": func_name, "loc": location, "param": paramclass}
+ )
+
+
+def recompute_creation_in_place(panel: HistoryPanel, action: HistoryAction) -> bool:
+ """Recompute a creation (``new_object``) action in place.
+
+ Rebuild the object from the edited ``param`` and copy it onto the
+ existing output object so its UUID (and downstream references) are kept.
+ """
+ panel_data = hchain.resolve_panel_for_action(panel, action)
+ if panel_data is None:
+ return False
+ existing, missing = hchain.resolve_target_outputs(panel, panel_data, action)
+ record_missing_outputs(panel, action, missing)
+ if missing or not existing:
+ return False
+ output_uuid = existing[0]
+ if not panel_data.objmodel.has_uuid(output_uuid):
+ return False
+ output_obj = panel_data.objmodel[output_uuid]
+ param = action.kwargs.get("param")
+ if param is None:
+ return False
+ if action.target == "signalpanel":
+ prepared = prepare_signal_parameters(param, edit=False)
+ if prepared is None:
+ return False
+ new_obj = create_signal_from_param(prepared)
+ else:
+ new_obj = create_image_from_param(param)
+ update_obj_in_place(output_obj, new_obj)
+ insert_creation_parameters(output_obj, param)
+ refresh_target(panel_data, output_uuid)
+ return True
+
+
+def recompute_1_to_1_in_place(panel: HistoryPanel, action: HistoryAction) -> bool:
+ """Recompute a single 1-to-1 action in place."""
+ panel_data = hchain.resolve_panel_for_action(panel, action)
+ if panel_data is None:
+ return False
+ existing, missing = hchain.resolve_target_outputs(panel, panel_data, action)
+ if not existing and not missing:
+ legacy = hchain.find_output_object_uuid(panel, panel_data, action)
+ if legacy is not None:
+ existing = [legacy]
+ record_missing_outputs(panel, action, missing)
+ if missing or not existing:
+ return False
+ output_uuid = existing[0]
+ if not panel_data.objmodel.has_uuid(output_uuid):
+ return False
+ output_obj = panel_data.objmodel[output_uuid]
+ pp = extract_processing_parameters(output_obj)
+ if pp is None or pp.source_uuid is None:
+ return False
+ if not panel_data.objmodel.has_uuid(pp.source_uuid):
+ panel.runtime.execution.cascade_warnings.append(
+ _("Action %s: source object was deleted — skipping.")
+ % (action.func_name or action.uuid)
+ )
+ return False
+ source_obj = panel_data.objmodel[pp.source_uuid]
+ param = action.kwargs.get("param")
+ plugin_origin = action.plugin_origin or pp.plugin_origin
+ compout = panel_data.processor.recompute_1_to_1(
+ action.func_name,
+ source_obj,
+ param,
+ plugin_origin=plugin_origin,
+ )
+ if compout.cancelled:
+ return False
+ if compout.error_msg:
+ panel.runtime.execution.cascade_warnings.append(
+ _("Recompute failed for action %s: %s")
+ % (action.func_name or action.uuid, compout.error_msg)
+ )
+ return False
+ new_obj = compout.result
+ if not isinstance(new_obj, (SignalObj, ImageObj)):
+ return False
+ panel_data.objprop.apply_recomputed_object_in_place(
+ output_obj,
+ new_obj,
+ ProcessingParameters(
+ func_name=pp.func_name,
+ pattern=pp.pattern,
+ param=param if param is not None else pp.param,
+ source_uuid=pp.source_uuid,
+ plugin_origin=plugin_origin,
+ ),
+ )
+ refresh_target(panel_data, output_uuid)
+ return True
+
+
+def recompute_1_to_n_in_place(panel: HistoryPanel, action: HistoryAction) -> bool:
+ """Recompute a 1-to-n action in place: replace each of the N outputs."""
+ panel_data = hchain.resolve_panel_for_action(panel, action)
+ if panel_data is None:
+ return False
+ existing, missing = hchain.resolve_target_outputs(panel, panel_data, action)
+ record_missing_outputs(panel, action, missing)
+ if missing or not existing or not panel_data.objmodel.has_uuid(existing[0]):
+ return False
+ first_obj = panel_data.objmodel[existing[0]]
+ pp = extract_processing_parameters(first_obj)
+ if pp is None or pp.source_uuid is None:
+ return False
+ if not panel_data.objmodel.has_uuid(pp.source_uuid):
+ panel.runtime.execution.cascade_warnings.append(
+ _("Action %s: source object was deleted — skipping.")
+ % (action.func_name or action.uuid)
+ )
+ return False
+ source_obj = panel_data.objmodel[pp.source_uuid]
+ params = action.kwargs.get("params") or []
+ if not params:
+ return False
+ plugin_origin = action.plugin_origin or pp.plugin_origin
+ new_objs = panel_data.processor.recompute_1_to_n(
+ action.func_name,
+ source_obj,
+ params,
+ plugin_origin=plugin_origin,
+ )
+ if not new_objs:
+ return False
+ if len(new_objs) != len(existing) or not all(
+ isinstance(obj, (SignalObj, ImageObj)) for obj in new_objs
+ ):
+ _logger.warning(
+ "1-to-n cardinality changed for action %s: %d outputs, %d existing.",
+ action.uuid,
+ len(new_objs),
+ len(existing),
+ )
+ panel.runtime.execution.cascade_warnings.append(
+ _("Action %s: recompute returned %d output(s), expected %d.")
+ % (action.func_name or action.uuid, len(new_objs), len(existing))
+ )
+ return False
+ snapshots = {
+ out_uuid: copy.deepcopy(panel_data.objmodel[out_uuid]) for out_uuid in existing
+ }
+ try:
+ for idx, out_uuid in enumerate(existing):
+ out_obj = panel_data.objmodel[out_uuid]
+ update_obj_in_place(out_obj, new_objs[idx])
+ insert_processing_parameters(
+ out_obj,
+ ProcessingParameters(
+ func_name=action.func_name,
+ pattern="1-to-n",
+ param=params[idx],
+ source_uuid=pp.source_uuid,
+ plugin_origin=plugin_origin,
+ ),
+ )
+ for out_uuid in existing:
+ refresh_target(panel_data, out_uuid)
+ except Exception:
+ for out_uuid, snapshot in snapshots.items():
+ update_obj_in_place(panel_data.objmodel[out_uuid], snapshot)
+ for out_uuid in snapshots:
+ try:
+ refresh_target(panel_data, out_uuid)
+ except Exception:
+ _logger.exception(
+ "Cascade recompute rollback refresh failed for output %s.",
+ out_uuid,
+ )
+ raise
+ return True
+
+
+def recompute_n_to_1_in_place(panel: HistoryPanel, action: HistoryAction) -> bool:
+ """Recompute an n-to-1 action in place."""
+ panel_data = hchain.resolve_panel_for_action(panel, action)
+ if panel_data is None:
+ return False
+ existing, missing = hchain.resolve_target_outputs(panel, panel_data, action)
+ record_missing_outputs(panel, action, missing)
+ if missing or not existing:
+ return False
+ output_uuid = existing[0]
+ if not panel_data.objmodel.has_uuid(output_uuid):
+ return False
+ output_obj = panel_data.objmodel[output_uuid]
+ pp = extract_processing_parameters(output_obj)
+ source_uuids: list[str] = []
+ if pp is not None and pp.source_uuids:
+ source_uuids = list(pp.source_uuids)
+ else:
+ source_uuids = list(action.state.selection.get(panel_data.PANEL_STR_ID, []))
+ if not source_uuids or not all(
+ panel_data.objmodel.has_uuid(uuid) for uuid in source_uuids
+ ):
+ panel.runtime.execution.cascade_warnings.append(
+ _("Action %s: source object(s) were deleted — skipping.")
+ % (action.func_name or action.uuid)
+ )
+ return False
+ src_objs = [panel_data.objmodel[uuid] for uuid in source_uuids]
+ param = action.kwargs.get("param")
+ plugin_origin = action.plugin_origin or (pp.plugin_origin if pp else None)
+ new_obj = panel_data.processor.recompute_n_to_1(
+ action.func_name,
+ src_objs,
+ param,
+ plugin_origin=plugin_origin,
+ )
+ if not isinstance(new_obj, (SignalObj, ImageObj)):
+ return False
+ update_obj_in_place(output_obj, new_obj)
+ insert_processing_parameters(
+ output_obj,
+ ProcessingParameters(
+ func_name=action.func_name,
+ pattern="n-to-1",
+ param=param,
+ source_uuids=[get_uuid(o) for o in src_objs],
+ plugin_origin=plugin_origin,
+ ),
+ )
+ refresh_target(panel_data, output_uuid)
+ return True
+
+
+def recompute_2_to_1_in_place(panel: HistoryPanel, action: HistoryAction) -> bool:
+ """Recompute a 2-to-1 action in place (single or pairwise)."""
+ panel_data = hchain.resolve_panel_for_action(panel, action)
+ if panel_data is None:
+ return False
+ existing, missing = hchain.resolve_target_outputs(panel, panel_data, action)
+ record_missing_outputs(panel, action, missing)
+ if missing or not existing:
+ return False
+ param = action.kwargs.get("param")
+ obj2_uuids = action.kwargs.get("obj2_uuids") or []
+ if isinstance(obj2_uuids, str):
+ obj2_uuids = [obj2_uuids]
+ pairwise = bool(action.kwargs.get("pairwise"))
+ recorded_inputs = list(action.state.selection.get(panel_data.PANEL_STR_ID, []))
+ staged: list[
+ tuple[
+ str,
+ SignalObj | ImageObj,
+ SignalObj | ImageObj,
+ SignalObj | ImageObj,
+ dict | None,
+ ]
+ ] = []
+ for idx, out_uuid in enumerate(existing):
+ if not panel_data.objmodel.has_uuid(out_uuid):
+ return False
+ pp = extract_processing_parameters(panel_data.objmodel[out_uuid])
+ src_uuids = (
+ list(pp.source_uuids)
+ if pp is not None and pp.source_uuids
+ else (
+ recorded_inputs[idx : idx + 1] + obj2_uuids[idx : idx + 1]
+ if pairwise
+ else recorded_inputs[idx : idx + 1] + obj2_uuids[:1]
+ )
+ )
+ if len(src_uuids) < 2:
+ panel.runtime.execution.cascade_warnings.append(
+ _("Action %s: missing source(s) for output #%d — skipping.")
+ % (action.func_name or action.uuid, idx + 1)
+ )
+ return False
+ if not (
+ panel_data.objmodel.has_uuid(src_uuids[0])
+ and panel_data.objmodel.has_uuid(src_uuids[1])
+ ):
+ panel.runtime.execution.cascade_warnings.append(
+ _("Action %s: source object(s) were deleted — skipping.")
+ % (action.func_name or action.uuid)
+ )
+ return False
+ obj1 = panel_data.objmodel[src_uuids[0]]
+ obj2 = panel_data.objmodel[src_uuids[1]]
+ plugin_origin = action.plugin_origin or (pp.plugin_origin if pp else None)
+ new_obj = panel_data.processor.recompute_2_to_1(
+ action.func_name,
+ obj1,
+ obj2,
+ param,
+ plugin_origin=plugin_origin,
+ )
+ if not isinstance(new_obj, (SignalObj, ImageObj)):
+ return False
+ staged.append((out_uuid, new_obj, obj1, obj2, plugin_origin))
+ snapshots = {
+ out_uuid: copy.deepcopy(panel_data.objmodel[out_uuid])
+ for out_uuid, *_rest in staged
+ }
+ try:
+ for out_uuid, new_obj, obj1, obj2, plugin_origin in staged:
+ output_obj = panel_data.objmodel[out_uuid]
+ update_obj_in_place(output_obj, new_obj)
+ insert_processing_parameters(
+ output_obj,
+ ProcessingParameters(
+ func_name=action.func_name,
+ pattern="2-to-1",
+ param=param,
+ source_uuids=[get_uuid(obj1), get_uuid(obj2)],
+ plugin_origin=plugin_origin,
+ ),
+ )
+ for out_uuid, *_rest in staged:
+ refresh_target(panel_data, out_uuid)
+ except Exception:
+ for out_uuid, snapshot in snapshots.items():
+ update_obj_in_place(panel_data.objmodel[out_uuid], snapshot)
+ for out_uuid in snapshots:
+ try:
+ refresh_target(panel_data, out_uuid)
+ except Exception:
+ _logger.exception(
+ "Cascade recompute rollback refresh failed for output %s.",
+ out_uuid,
+ )
+ raise
+ return True
+
+
+def recompute_1_to_0_in_place(panel: HistoryPanel, action: HistoryAction) -> bool:
+ """Recompute a 1-to-0 analysis on each source object in place."""
+ panel_data = hchain.resolve_panel_for_action(panel, action)
+ if panel_data is None:
+ return False
+ sources = list(action.state.selection.get(panel_data.PANEL_STR_ID, []))
+ if not sources:
+ return False
+ param = copy.deepcopy(action.kwargs.get("param"))
+ if hasattr(param, "create_rois"):
+ param.create_rois = False
+ missing = [uuid for uuid in sources if not panel_data.objmodel.has_uuid(uuid)]
+ if missing:
+ panel.runtime.execution.cascade_warnings.append(
+ _("Action %s: %d analysed object(s) were deleted — skipping.")
+ % (action.func_name or action.uuid, len(missing))
+ )
+ return False
+ source_objs = [panel_data.objmodel[uuid] for uuid in sources]
+ metadata_snapshots = [copy.deepcopy(obj.metadata) for obj in source_objs]
+ try:
+ for src_obj in source_objs:
+ analysis_parameters = extract_analysis_parameters(src_obj)
+ plugin_origin = action.plugin_origin or (
+ analysis_parameters.plugin_origin if analysis_parameters else None
+ )
+ success = panel_data.processor.recompute_1_to_0(
+ action.func_name,
+ src_obj,
+ param,
+ plugin_origin=plugin_origin,
+ )
+ if not success:
+ for obj, metadata in zip(source_objs, metadata_snapshots):
+ obj.metadata.clear()
+ obj.metadata.update(metadata)
+ return False
+ except Exception:
+ for obj, metadata in zip(source_objs, metadata_snapshots):
+ obj.metadata.clear()
+ obj.metadata.update(metadata)
+ raise
+ for uuid in sources:
+ refresh_target(panel_data, uuid)
+ return True
+
+
+def recompute_cascade(
+ panel: HistoryPanel,
+ root_action: HistoryAction,
+ descendants: list[HistoryAction] | None = None,
+) -> None:
+ """Recompute ``root_action``'s descendants in the current session in place."""
+ if descendants is None:
+ descendants = hchain.get_downstream_actions(panel, root_action)
+ if root_action.is_stale:
+ descendants = [root_action] + descendants
+ if panel.runtime.execution.cascade_in_progress:
+ flush_cascade_warnings(panel)
+ return
+ if not descendants:
+ flush_cascade_warnings(panel)
+ return
+ with panel.runtime.execution.recomputing_cascade() as started:
+ if not started:
+ flush_cascade_warnings(panel)
+ return
+ for action in descendants:
+ action.is_stale = True
+ panel.tree.refresh_action_item(action)
+ QW.QApplication.processEvents()
+ for action in descendants:
+ success = recompute_action_in_place(panel, action)
+ if success:
+ action.is_stale = False
+ panel.tree.refresh_action_item(action)
+ QW.QApplication.processEvents()
+ if not success:
+ break
+ flush_cascade_warnings(panel)
+
+
+def flush_cascade_warnings(panel: HistoryPanel) -> None:
+ """Show + clear accumulated cascade warnings (no-op when empty)."""
+ if panel.runtime.execution.cascade_warnings and not execenv.unattended:
+ QW.QMessageBox.warning(
+ panel.mainwindow,
+ _("Cascade recompute"),
+ _("Some downstream actions could not be recomputed:")
+ + "\n\n• "
+ + "\n• ".join(panel.runtime.execution.cascade_warnings),
+ )
+ panel.runtime.execution.cascade_warnings.clear()
diff --git a/datalab/gui/panel/history/reconnection.py b/datalab/gui/panel/history/reconnection.py
new file mode 100644
index 000000000..c2c88d0fa
--- /dev/null
+++ b/datalab/gui/panel/history/reconnection.py
@@ -0,0 +1,47 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""GUI-boundary orchestration for reconnecting removed history objects."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from datalab.gui.panel.history import chain as hchain
+from datalab.gui.panel.history import recompute as hrec
+from datalab.history import HistoryAction
+
+if TYPE_CHECKING:
+ from datalab.gui.panel.base import BaseDataPanel
+ from datalab.gui.panel.history.panel import HistoryPanel
+
+
+def reconnect_chain_after_removal(
+ panel: HistoryPanel, panel_data: BaseDataPanel
+) -> None:
+ """Reconnect the processing chain after objects are deleted from a data panel."""
+ panel_str = panel_data.PANEL_STR_ID
+ previous = panel.runtime.objects.obj_ids_snapshot.get(panel_str, set())
+ current = set(panel_data.objmodel.get_object_ids())
+ removed = previous - current
+ if not removed:
+ return
+ with panel.runtime.objects.reconnecting_objects() as started:
+ if not started:
+ return
+ plans = [
+ hchain.plan_reconnection(panel, panel_data, object_uuid)
+ for object_uuid in removed
+ ]
+ roots_to_recompute: list[HistoryAction] = []
+ for plan in plans:
+ hchain.apply_reconnection_plan(panel, panel_data, plan, roots_to_recompute)
+ for action in roots_to_recompute:
+ success = hrec.recompute_action_in_place(panel, action)
+ action.is_stale = not success
+ panel.tree.refresh_action_item(action)
+ if success:
+ hrec.recompute_cascade(panel, action)
+ hchain.show_reconnection_warnings(
+ panel, [plan.warning for plan in plans if plan.warning is not None]
+ )
+ hchain.refresh_reconnected_history(panel)
diff --git a/datalab/gui/panel/history/runtime.py b/datalab/gui/panel/history/runtime.py
new file mode 100644
index 000000000..ad6d3af0e
--- /dev/null
+++ b/datalab/gui/panel/history/runtime.py
@@ -0,0 +1,246 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Runtime state and registries for the History panel."""
+
+from __future__ import annotations
+
+import functools
+from contextlib import contextmanager
+from typing import TYPE_CHECKING, Any, Callable, Generator
+
+from qtpy import QtCore as QC
+
+if TYPE_CHECKING:
+ from datalab.gui.panel.base import BaseDataPanel
+ from datalab.gui.panel.history.panel import HistoryPanel
+ from datalab.history import HistoryAction
+
+
+class HistoryExecutionState:
+ """Own transient replay modes and re-entrance guards."""
+
+ def __init__(self) -> None:
+ self.record_mode = False
+ self.edit_mode = False
+ self.session_input_pending = False
+ self.suppress_session_prompt = False
+ self.replaying_active = False
+ self.output_suppressed_active = False
+ self.cascade_in_progress = False
+ self.edit_replay_in_progress = False
+ self.cascade_warnings: list[str] = []
+ self.broken_actions: set[str] = set()
+
+ @contextmanager
+ def replaying(self) -> Generator[None, None, None]:
+ """Suppress history capture during the context scope."""
+ previous = self.replaying_active
+ self.replaying_active = True
+ try:
+ yield
+ finally:
+ self.replaying_active = previous
+
+ @contextmanager
+ def output_suppressed(self) -> Generator[None, None, None]:
+ """Suppress compute outputs during the context scope."""
+ previous = self.output_suppressed_active
+ self.output_suppressed_active = True
+ try:
+ yield
+ finally:
+ self.output_suppressed_active = previous
+
+ @contextmanager
+ def session_prompt_suppressed(self) -> Generator[None, None, None]:
+ """Suppress the new-session prompt during the context scope."""
+ previous = self.suppress_session_prompt
+ self.suppress_session_prompt = True
+ try:
+ yield
+ finally:
+ self.suppress_session_prompt = previous
+
+ def start_session_input_prompt(self) -> bool:
+ """Start the input prompt debounce window if it is not already active."""
+ if self.session_input_pending:
+ return False
+ self.session_input_pending = True
+ QC.QTimer.singleShot(0, self.finish_session_input_prompt)
+ return True
+
+ def finish_session_input_prompt(self) -> None:
+ """End the input prompt debounce window."""
+ self.session_input_pending = False
+
+ @contextmanager
+ def recomputing_cascade(self) -> Generator[bool, None, None]:
+ """Guard a cascade recomputation against re-entrance."""
+ if self.cascade_in_progress:
+ yield False
+ return
+ self.broken_actions.clear()
+ self.cascade_in_progress = True
+ try:
+ yield True
+ finally:
+ self.cascade_in_progress = False
+
+ @contextmanager
+ def replaying_edits(self) -> Generator[bool, None, None]:
+ """Guard an edit-mode replay against re-entrance."""
+ if self.edit_replay_in_progress:
+ yield False
+ return
+ self.edit_replay_in_progress = True
+ try:
+ yield True
+ finally:
+ self.edit_replay_in_progress = False
+
+
+class HistoryObjectIndex:
+ """Own object snapshots, output indexes, and panel tracking callbacks."""
+
+ def __init__(
+ self,
+ panel: HistoryPanel,
+ reconnect_after_removal: Callable[[BaseDataPanel], None],
+ ) -> None:
+ self.panel = panel
+ self.reconnect_after_removal = reconnect_after_removal
+ self.reconnecting = False
+ self.obj_ids_snapshot: dict[str, set[str]] = {}
+ self.action_output_uuids: dict[str, list[str]] = {}
+ self.output_to_action: dict[str, str] = {}
+ self.tracking_enabled = False
+ self.object_tracking_connections: list[tuple[Any, Any]] = []
+ self.build_tracking_connections()
+
+ def build_tracking_connections(self) -> None:
+ """Build callbacks that keep history state aligned with data panels."""
+ for data_panel in (
+ self.panel.mainwindow.signalpanel,
+ self.panel.mainwindow.imagepanel,
+ ):
+ self.object_tracking_connections.extend(
+ (
+ (
+ data_panel.SIG_OBJECT_ADDED,
+ self.panel.refresh_compatibility_items,
+ ),
+ (data_panel.SIG_OBJECT_ADDED, self.refresh_obj_ids_snapshot),
+ (
+ data_panel.SIG_OBJECT_REMOVED,
+ self.panel.refresh_compatibility_items,
+ ),
+ (
+ data_panel.SIG_OBJECT_REMOVED,
+ functools.partial(self.reconnect_after_removal, data_panel),
+ ),
+ (data_panel.SIG_OBJECT_REMOVED, self.prune_output_mapping),
+ (
+ data_panel.SIG_OBJECT_MODIFIED,
+ self.panel.refresh_compatibility_items,
+ ),
+ )
+ )
+
+ def set_tracking_enabled(self, enabled: bool) -> None:
+ """Enable or disable synchronization with data panel object changes."""
+ if enabled == self.tracking_enabled:
+ return
+ for signal, callback in self.object_tracking_connections:
+ if enabled:
+ signal.connect(callback)
+ else:
+ signal.disconnect(callback)
+ self.tracking_enabled = enabled
+
+ def refresh_obj_ids_snapshot(self) -> None:
+ """Cache the current object ids of both data panels."""
+ signal_panel = self.panel.mainwindow.signalpanel
+ image_panel = self.panel.mainwindow.imagepanel
+ self.obj_ids_snapshot = {
+ signal_panel.PANEL_STR_ID: set(signal_panel.objmodel.get_object_ids()),
+ image_panel.PANEL_STR_ID: set(image_panel.objmodel.get_object_ids()),
+ }
+
+ @contextmanager
+ def reconnecting_objects(self) -> Generator[bool, None, None]:
+ """Guard object reconnection and refresh snapshots when it completes."""
+ if self.reconnecting:
+ yield False
+ return
+ self.reconnecting = True
+ try:
+ yield True
+ finally:
+ self.reconnecting = False
+ self.refresh_obj_ids_snapshot()
+
+ def register_action_outputs(
+ self, action: HistoryAction, output_uuids: list[str]
+ ) -> None:
+ """Register outputs while maintaining both mapping directions."""
+ previous = self.action_output_uuids.get(action.uuid, [])
+ for previous_uuid in previous:
+ if self.output_to_action.get(previous_uuid) == action.uuid:
+ self.output_to_action.pop(previous_uuid, None)
+ new_outputs = list(output_uuids)
+ for output_uuid in new_outputs:
+ old_action_uuid = self.output_to_action.get(output_uuid)
+ if old_action_uuid is not None and old_action_uuid != action.uuid:
+ old_outputs = self.action_output_uuids.get(old_action_uuid)
+ if old_outputs is not None and output_uuid in old_outputs:
+ old_outputs.remove(output_uuid)
+ if not old_outputs:
+ del self.action_output_uuids[old_action_uuid]
+ action.output_uuids = list(new_outputs)
+ self.action_output_uuids[action.uuid] = new_outputs
+ for output_uuid in new_outputs:
+ self.output_to_action[output_uuid] = action.uuid
+
+ def prune_output_mapping(self) -> None:
+ """Drop reverse-index entries for objects that no longer exist."""
+ if not self.output_to_action:
+ return
+ alive: set[str] = set()
+ for data_panel in (
+ self.panel.mainwindow.signalpanel,
+ self.panel.mainwindow.imagepanel,
+ ):
+ alive.update(data_panel.objmodel.get_object_ids())
+ for output_uuid in [
+ uuid for uuid in self.output_to_action if uuid not in alive
+ ]:
+ action_uuid = self.output_to_action.pop(output_uuid)
+ outputs = self.action_output_uuids.get(action_uuid)
+ if outputs is not None and output_uuid in outputs:
+ outputs.remove(output_uuid)
+ if not outputs:
+ del self.action_output_uuids[action_uuid]
+
+ def remove_action_outputs(self, action: HistoryAction) -> None:
+ """Remove all output-index entries owned by an action."""
+ outputs = self.action_output_uuids.pop(action.uuid, [])
+ for output_uuid in outputs:
+ if self.output_to_action.get(output_uuid) == action.uuid:
+ self.output_to_action.pop(output_uuid, None)
+
+ def clear_output_mappings(self) -> None:
+ """Clear both output mapping indexes."""
+ self.action_output_uuids.clear()
+ self.output_to_action.clear()
+
+
+class HistoryRuntime:
+ """Coordinate transient execution state and history object indexes."""
+
+ def __init__(
+ self,
+ panel: HistoryPanel,
+ reconnect_after_removal: Callable[[BaseDataPanel], None],
+ ) -> None:
+ self.execution = HistoryExecutionState()
+ self.objects = HistoryObjectIndex(panel, reconnect_after_removal)
diff --git a/datalab/gui/panel/history/ui.py b/datalab/gui/panel/history/ui.py
new file mode 100644
index 000000000..d47664c29
--- /dev/null
+++ b/datalab/gui/panel/history/ui.py
@@ -0,0 +1,182 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Qt action and widget setup for the History panel."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from guidata.configtools import get_icon
+from guidata.qthelpers import add_actions, create_action
+from qtpy import QtGui as QG
+from qtpy import QtWidgets as QW
+
+from datalab.config import _
+from datalab.gui import historytools_ops as htools
+from datalab.widgets.workspacestate_widget import WorkspaceStateWidget
+
+if TYPE_CHECKING:
+ from qtpy import QtCore as QC
+
+ from datalab.gui.panel.history.panel import HistoryPanel
+
+
+class HistoryPanelUI:
+ """Build and own History panel widgets and actions."""
+
+ def __init__(self, panel: HistoryPanel) -> None:
+ self.panel = panel
+ self.state_widget = WorkspaceStateWidget(panel)
+ self.actions = self.create_actions()
+ self.menu_actions = self.create_menu_actions()
+ self.setup_connections()
+ self.setup_layout()
+
+ def create_actions(self) -> dict[str, QW.QAction]:
+ """Create named actions used by history menus and toolbar."""
+ panel = self.panel
+ actions = {
+ "record": create_action(
+ panel,
+ _("Record mode"),
+ toggled=panel.toggle_record_mode,
+ icon=get_icon("record.svg"),
+ ),
+ "new_session": create_action(
+ panel,
+ _("New session"),
+ lambda checked=False: panel.create_new_session(),
+ icon=get_icon("libre-gui-add.svg"),
+ tip=_("Start a new history session"),
+ ),
+ "open": create_action(
+ panel,
+ _("Open history file..."),
+ triggered=lambda checked=False: panel.open_dlhist_file(),
+ icon=get_icon("fileopen_h5.svg"),
+ tip=_("Open history from a standalone .dlhist file"),
+ ),
+ "save": create_action(
+ panel,
+ _("Save history file..."),
+ triggered=lambda checked=False: panel.save_to_dlhist_file(),
+ icon=get_icon("filesave_h5.svg"),
+ tip=_("Save history to a standalone .dlhist file"),
+ ),
+ "delete": create_action(
+ panel,
+ _("Delete"),
+ lambda: htools.delete_selected(panel),
+ icon=get_icon("delete.svg"),
+ ),
+ "duplicate": create_action(
+ panel,
+ _("Duplicate"),
+ lambda: htools.duplicate_selected_entries(panel),
+ icon=get_icon("duplicate.svg"),
+ tip=_("Duplicate selected history action/session"),
+ ),
+ "step_prev": create_action(
+ panel,
+ _("Previous step"),
+ triggered=panel.navigation.step_prev,
+ icon=get_icon("libre-gui-arrow-left.svg"),
+ tip=_("Select the previous action in the current session"),
+ shortcut=QG.QKeySequence("Ctrl+Left"),
+ ),
+ "step_next": create_action(
+ panel,
+ _("Next step"),
+ triggered=panel.navigation.step_next,
+ icon=get_icon("libre-gui-arrow-right.svg"),
+ tip=_("Select the next action in the current session"),
+ shortcut=QG.QKeySequence("Ctrl+Right"),
+ ),
+ "remove_incompatible": create_action(
+ panel,
+ _("Remove incompatible"),
+ lambda: htools.remove_incompatible_actions(panel),
+ icon=get_icon("edit/delete_all.svg"),
+ tip=_("Remove actions incompatible with the current workspace"),
+ ),
+ "replay": create_action(
+ panel,
+ _("Replay"),
+ lambda: panel.replay_restore_actions(restore_selection=False),
+ icon=get_icon("replay.svg"),
+ tip=_("Replay the selection silently (no parameter dialogs)"),
+ ),
+ "step_by_step": create_action(
+ panel,
+ _("Step-by-step"),
+ triggered=lambda checked=False: panel.replay_step_by_step(),
+ icon=get_icon("edit_mode.svg"),
+ tip=_(
+ "Replay the selection step by step, editing parameters at each step"
+ ),
+ ),
+ }
+ actions["record"].setChecked(panel.runtime.execution.record_mode)
+ return actions
+
+ def create_menu_actions(self) -> list[QW.QAction | None]:
+ """Return ordered actions and separators for menus and toolbar."""
+ action = self.actions
+ return [
+ action["record"],
+ action["new_session"],
+ None,
+ action["open"],
+ action["save"],
+ None,
+ action["step_prev"],
+ action["step_next"],
+ None,
+ action["replay"],
+ action["step_by_step"],
+ None,
+ action["duplicate"],
+ None,
+ action["remove_incompatible"],
+ action["delete"],
+ ]
+
+ def setup_connections(self) -> None:
+ """Connect history-tree interactions to their owning components."""
+ tree = self.panel.tree
+ tree.customContextMenuRequested.connect(self.show_context_menu)
+ tree.itemDoubleClicked.connect(self.panel.replay_restore_actions)
+ tree.itemSelectionChanged.connect(self.panel.navigation.sync_panel_selection)
+ tree.itemSelectionChanged.connect(self.update_actions_state)
+ tree.itemSelectionChanged.connect(self.panel.navigation.update_state_widget)
+ tree.itemSelectionChanged.connect(
+ self.panel.navigation.set_active_session_from_selection
+ )
+
+ def setup_layout(self) -> None:
+ """Install the toolbar, history tree, and workspace-state widget."""
+ toolbar = QW.QToolBar(self.panel)
+ add_actions(toolbar, self.menu_actions)
+ widget = QW.QWidget(self.panel)
+ layout = QW.QVBoxLayout()
+ layout.addWidget(toolbar)
+ layout.addWidget(self.panel.tree)
+ layout.addWidget(self.state_widget)
+ layout.setContentsMargins(0, 0, 0, 0)
+ widget.setLayout(layout)
+ self.panel.addWidget(widget)
+
+ def update_actions_state(self) -> None:
+ """Update action availability from history and step state."""
+ has_history = len(self.panel) > 0
+ self.actions["delete"].setEnabled(has_history)
+ self.actions["duplicate"].setEnabled(has_history)
+ self.actions["step_prev"].setEnabled(self.panel.navigation.can_step_prev())
+ self.actions["step_next"].setEnabled(self.panel.navigation.can_step_next())
+
+ def show_context_menu(self, pos: QC.QPoint) -> None:
+ """Show the history context menu at a tree position."""
+ self.panel.refresh_compatibility_items()
+ menu = QW.QMenu()
+ add_actions(menu, self.menu_actions)
+ menu.exec_(self.panel.tree.mapToGlobal(pos))
diff --git a/datalab/gui/panel/image.py b/datalab/gui/panel/image.py
index 661c752e9..c41bbddeb 100644
--- a/datalab/gui/panel/image.py
+++ b/datalab/gui/panel/image.py
@@ -255,8 +255,20 @@ def new_object(
image = create_image_gui(param, edit=edit, parent=self.parentWidget())
if image is None:
return None
+ action = self.mainwindow.historypanel.add_ui_entry(
+ _("New image"),
+ target="imagepanel",
+ method_name="new_object",
+ save_state=False,
+ param=param,
+ add_to_panel=add_to_panel,
+ )
if add_to_panel:
self.add_object(image)
+ if action is not None:
+ self.mainwindow.historypanel.register_action_outputs(
+ action, [get_uuid(image)]
+ )
return image
def toggle_show_contrast(self, state: bool) -> None:
diff --git a/datalab/gui/panel/signal.py b/datalab/gui/panel/signal.py
index 26d8abca1..5dcb83449 100644
--- a/datalab/gui/panel/signal.py
+++ b/datalab/gui/panel/signal.py
@@ -32,6 +32,7 @@
from datalab.gui.panel.base import BaseDataPanel
from datalab.gui.plothandler import SignalPlotHandler
from datalab.gui.processor.signal import SignalProcessor
+from datalab.objectmodel import get_uuid
if TYPE_CHECKING:
from qtpy import QtWidgets as QW
@@ -146,8 +147,20 @@ def new_object(
signal = create_signal_gui(param, edit=edit, parent=self.parentWidget())
if signal is None:
return None
+ action = self.mainwindow.historypanel.add_ui_entry(
+ _("New signal"),
+ target="signalpanel",
+ method_name="new_object",
+ save_state=False,
+ param=param,
+ add_to_panel=add_to_panel,
+ )
if add_to_panel:
self.add_object(signal)
+ if action is not None:
+ self.mainwindow.historypanel.register_action_outputs(
+ action, [get_uuid(signal)]
+ )
return signal
# ------Plotting--------------------------------------------------------------------
diff --git a/datalab/gui/processor/base.py b/datalab/gui/processor/base.py
index fd421fed9..895943562 100644
--- a/datalab/gui/processor/base.py
+++ b/datalab/gui/processor/base.py
@@ -9,10 +9,13 @@
from __future__ import annotations
import abc
+import copy
+import inspect
import multiprocessing
+import os.path as osp
import time
import warnings
-from dataclasses import asdict, dataclass
+from dataclasses import asdict, dataclass, field
from enum import Enum, auto
from multiprocessing.pool import Pool
from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, Optional
@@ -68,6 +71,7 @@ class ProcessingParameters:
param: Processing parameter dataset (optional, for 1-to-1 only)
source_uuid: Source object UUID (for 1-to-1 pattern)
source_uuids: Source object UUIDs (for n-to-1 and 2-to-1 patterns)
+ plugin_origin: Optional plugin origin descriptor
"""
func_name: str
@@ -75,6 +79,7 @@ class ProcessingParameters:
param: gds.DataSet | None = None
source_uuid: str | None = None
source_uuids: list[str] | None = None
+ plugin_origin: dict[str, Any] | None = None
def set_param_from_json(self, param_json: str | list[str]) -> None:
"""Set the param attribute from a JSON string or list of JSON strings.
@@ -126,6 +131,23 @@ def from_dict(cls, data: dict[str, Any]) -> ProcessingParameters:
return instance
+@dataclass
+class ProcessingReport:
+ """Report of processing recompute operation.
+
+ Args:
+ success: True if processing succeeded
+ obj_uuid: UUID of the processed object
+ message: Optional message (error or info)
+ cancelled: True if processing was cancelled by the user
+ """
+
+ success: bool
+ obj_uuid: str | None = None
+ message: str | None = None
+ cancelled: bool = False
+
+
# Metadata options for storing processing parameters (DataLab-specific)
PROCESSING_PARAMETERS_OPTION = "processing_parameters" # Transformation history
ANALYSIS_PARAMETERS_OPTION = "analysis_parameters" # Analysis operation (1-to-0)
@@ -195,12 +217,52 @@ def insert_processing_parameters(
obj.set_metadata_option(PROCESSING_PARAMETERS_OPTION, pp.to_dict())
+def build_processing_parameters(
+ func_name: str,
+ pattern: str,
+ *,
+ param: gds.DataSet | list[gds.DataSet] | None = None,
+ source_uuid: str | None = None,
+ source_uuids: list[str] | None = None,
+ plugin_origin: dict[str, Any] | None = None,
+) -> ProcessingParameters:
+ """Single factory for :class:`ProcessingParameters`.
+
+ Centralises construction so that history-panel entries and per-object
+ metadata always share the same identity (``func_name``, ``pattern``,
+ ``param``).
+
+ Args:
+ func_name: Sigima feature name.
+ pattern: Dash-form pattern (``"1-to-1"``, ``"1-to-0"``,
+ ``"n-to-1"``, ``"2-to-1"``, ``"1-to-n"``).
+ param: Optional parameter dataset (or list of datasets for
+ multi-parameter patterns).
+ source_uuid: Source object UUID for ``"1-to-1"`` / ``"1-to-0"`` /
+ ``"1-to-n"`` patterns.
+ source_uuids: Source object UUIDs for ``"n-to-1"`` / ``"2-to-1"``
+ patterns.
+ plugin_origin: Optional plugin origin descriptor.
+
+ Returns:
+ Newly constructed :class:`ProcessingParameters`.
+ """
+ return ProcessingParameters(
+ func_name=func_name,
+ pattern=pattern,
+ param=param,
+ source_uuid=source_uuid,
+ source_uuids=source_uuids,
+ plugin_origin=plugin_origin,
+ )
+
+
def clear_analysis_parameters(obj: SignalObj | ImageObj) -> None:
"""Clear analysis parameters from object metadata.
This removes the stored analysis parameters (1-to-0 operations) from the object.
Should be called when all analysis results are deleted to prevent the
- auto_recompute_analysis function from attempting to recompute deleted analyses.
+ recompute_analysis function from attempting to recompute deleted analyses.
Args:
obj: Signal or Image object
@@ -490,6 +552,160 @@ def is_pairwise_mode() -> bool:
return state
+class FeatureNotFoundError(ValueError):
+ """Raised when a computing feature cannot be resolved by name or callable.
+
+ Inherits from :class:`ValueError` to preserve backward compatibility with
+ callers that already catch ``ValueError`` on lookup failures.
+
+ Attributes:
+ func_name: Name (or repr) of the missing feature.
+ plugin_origin: Optional plugin origin descriptor captured at registration
+ time. See :func:`_detect_plugin_origin` for the dict shape.
+ paramclass_name: Optional name of the required parameter class (for
+ diagnostic display).
+ """
+
+ def __init__(
+ self,
+ func_name: str,
+ plugin_origin: dict[str, Any] | None = None,
+ paramclass_name: str | None = None,
+ ) -> None:
+ self.func_name = func_name
+ self.plugin_origin = plugin_origin
+ self.paramclass_name = paramclass_name
+ super().__init__(self._build_message())
+
+ def _build_message(self) -> str:
+ """Build the default exception message."""
+ if self.plugin_origin:
+ po = self.plugin_origin
+ param = self.paramclass_name or "—"
+ return (
+ f"Cannot replay action: function '{self.func_name}' from plugin "
+ f"'{po.get('plugin_class')}' (module: {po.get('module')}, "
+ f"directory: {po.get('directory')}) is not available. "
+ f"Required parameter class: {param}. "
+ "Please reinstall or check the plugin."
+ )
+ return f"Unknown computing feature: {self.func_name}"
+
+
+# Module name prefixes considered as built-in (not plugin) origins.
+_BUILTIN_MODULE_PREFIXES: tuple[str, ...] = (
+ "sigima",
+ "datalab",
+ "numpy",
+ "scipy",
+ "skimage",
+ "guidata",
+ "plotpy",
+ "qtpy",
+ "builtins",
+ "__main__",
+)
+
+
+def _detect_plugin_origin(func: Callable) -> dict[str, Any] | None:
+ """Detect whether ``func`` originates from a DataLab plugin.
+
+ Inspects ``func.__module__`` and compares it against registered plugins
+ (:class:`datalab.plugins.PluginRegistry`). Falls back to a heuristic for
+ modules that are clearly not from the DataLab/Sigima/scientific-Python
+ built-in surface (then treated as "anonymous" plugin origin).
+
+ **Wrapper-aware**: when *func* is a Sigima wrapper (e.g.
+ ``Wrap1to1Func``), its ``__module__`` points to the wrapper class's
+ module (``sigima.proc.image.base``), not to the user-supplied function.
+ The method therefore probes ``func.__wrapped__`` (``functools.wraps``
+ convention) and ``func.func`` (Sigima ``Wrap1to1Func`` / signal
+ ``Wrap1to1Func`` attribute) to recover the *inner* function and uses
+ that function's ``__module__`` for origin detection.
+
+ Args:
+ func: Computation function to inspect.
+
+ Returns:
+ A dict ``{"plugin_class", "module", "directory", "version"}`` if the
+ function originates from a plugin, otherwise ``None``.
+ """
+ # Build a list of candidate functions to inspect, starting with the
+ # innermost wrapped function so that plugin origins are detected even
+ # when the outer callable belongs to a built-in module (e.g. sigima).
+ candidates: list[Callable] = []
+ inner = getattr(func, "__wrapped__", None) or getattr(func, "func", None)
+ if inner is not None and callable(inner):
+ candidates.append(inner)
+ candidates.append(func)
+
+ module_name = ""
+ origin_candidate = func
+ for candidate in candidates:
+ mod = getattr(candidate, "__module__", "") or ""
+ if mod:
+ top = mod.split(".", 1)[0]
+ if top not in _BUILTIN_MODULE_PREFIXES:
+ module_name = mod
+ origin_candidate = candidate
+ break
+ if not module_name:
+ # All candidates are built-in; fall back to the outer func's module
+ # so the rest of the logic can still run (and return None).
+ module_name = getattr(func, "__module__", "") or ""
+ if not module_name:
+ return None
+ # Local import to avoid a circular dependency at module load time.
+ try:
+ from datalab.plugins import ( # pylint: disable=import-outside-toplevel
+ PluginRegistry,
+ )
+ except ImportError:
+ PluginRegistry = None # type: ignore[assignment]
+
+ if PluginRegistry is not None:
+ for plugin in PluginRegistry.get_plugins():
+ plugin_module = plugin.__class__.__module__
+ if module_name == plugin_module or module_name.startswith(
+ plugin_module + "."
+ ):
+ directory: str | None = None
+ try:
+ directory = osp.basename(
+ osp.dirname(inspect.getfile(plugin.__class__))
+ )
+ except (TypeError, OSError):
+ pass
+ version: str | None = None
+ info = getattr(plugin, "info", None)
+ if info is not None:
+ version = getattr(info, "version", None)
+ return {
+ "plugin_class": plugin.__class__.__name__,
+ "module": module_name,
+ "directory": directory,
+ "version": version,
+ }
+
+ # Heuristic fallback: anything not from a known built-in prefix is
+ # treated as an anonymous plugin origin (e.g. user macros, third-party
+ # functions wrapped through ``compute_1_to_1`` directly).
+ top = module_name.split(".", 1)[0]
+ if top and top not in _BUILTIN_MODULE_PREFIXES:
+ directory = None
+ try:
+ directory = osp.basename(osp.dirname(inspect.getfile(origin_candidate)))
+ except (TypeError, OSError):
+ pass
+ return {
+ "plugin_class": None,
+ "module": module_name,
+ "directory": directory,
+ "version": None,
+ }
+ return None
+
+
@dataclass
class ComputingFeature:
"""Computing feature dataclass.
@@ -504,6 +720,9 @@ class ComputingFeature:
edit: whether to edit the parameters
obj2_name: name of the second object
skip_xarray_compat: whether to skip X-array compatibility check for this feature
+ plugin_origin: optional plugin origin descriptor (auto-detected at
+ :meth:`BaseProcessor.add_feature` time). ``None`` for built-in
+ (Sigima/DataLab) features.
"""
pattern: Literal["1_to_1", "1_to_0", "1_to_n", "n_to_1", "2_to_1"]
@@ -515,6 +734,7 @@ class ComputingFeature:
edit: Optional[bool] = None
obj2_name: Optional[str] = None
skip_xarray_compat: Optional[bool] = None
+ plugin_origin: Optional[dict[str, Any]] = field(default=None)
def __post_init__(self):
"""Validate the function after initialization."""
@@ -739,18 +959,28 @@ def _add_object_to_appropriate_panel(
If False, non-native objects are added to default group. Set to False when
group_id is from the source panel and object goes to a different panel.
"""
+ hpanel = getattr(self.mainwindow, "historypanel", None)
+ if hpanel is not None and hpanel.is_output_suppressed():
+ return
is_new_obj_native = isinstance(new_obj, self.panel.PARAMCLASS)
if is_new_obj_native:
self.panel.add_object(new_obj, group_id=group_id)
else:
+ # Route directly to the target panel to avoid the creation entry
+ # that mainwindow.add_object records (which would duplicate the
+ # compute entry already recorded by the processor).
+ if isinstance(new_obj, SignalObj):
+ target_panel = self.panel.mainwindow.signalpanel
+ else:
+ target_panel = self.panel.mainwindow.imagepanel
if use_group_for_non_native:
- self.panel.mainwindow.add_object(new_obj, group_id=group_id)
+ target_panel.add_object(new_obj, group_id=group_id)
else:
- self.panel.mainwindow.add_object(new_obj)
+ target_panel.add_object(new_obj)
def _create_group_for_result(
self, new_obj: SignalObj | ImageObj, group_name: str
- ) -> str:
+ ) -> str | None:
"""Create a group in the appropriate panel for the result object.
For native objects, creates group in current panel. For non-native objects,
@@ -761,8 +991,11 @@ def _create_group_for_result(
group_name: Name for the new group
Returns:
- UUID of the created group
+ UUID of the created group.
"""
+ hpanel = getattr(self.mainwindow, "historypanel", None)
+ if hpanel is not None and hpanel.is_output_suppressed():
+ return None
is_new_obj_native = isinstance(new_obj, self.panel.PARAMCLASS)
if is_new_obj_native:
return get_uuid(self.panel.add_group(group_name))
@@ -984,55 +1217,187 @@ def _handle_keep_results(self, result_obj: SignalObj | ImageObj) -> None:
TableAdapter.remove_all_from(result_obj)
GeometryAdapter.remove_all_from(result_obj)
- def auto_recompute_analysis(
+ def recompute_analysis(
self, obj: SignalObj | ImageObj, refresh_plot: bool = True
- ) -> None:
- """Automatically recompute analysis (1-to-0) operations after data changes.
+ ) -> bool:
+ """Recompute analysis (1-to-0) operations on demand.
This method checks if the object has 1-to-0 analysis parameters (analysis
- operations like statistics, measurements, etc.) and automatically recomputes
- the analysis to update the results based on the modified data.
-
- This should be called after:
- - ROI modifications (which change the data to be analyzed)
- - Data transformations via recompute_1_to_1 (which modify data in-place)
+ operations like statistics, measurements, etc.) and recomputes the analysis
+ to update the results based on the current data.
- Note: Should be called explicitly after ROI modifications, not during
- selection changes, to avoid interfering with the ROI change detection
- mechanism used by the mask refresh system.
+ Recomputation is *not* automatic: it is triggered explicitly by the user
+ through the manual "Recompute" action (see
+ ``BaseDataPanel.recompute_selected``). Editing ROIs, data or object
+ properties no longer implicitly re-runs analyses; existing results are left
+ as-is until the user asks for a refresh.
Args:
- obj: The object whose data was modified
+ obj: The object whose analysis results should be recomputed
refresh_plot: Whether to refresh the plot after recomputation
"""
# Check if object has 1-to-0 analysis parameters (analysis operations)
proc_params = extract_analysis_parameters(obj)
if proc_params is None or proc_params.pattern != "1-to-0":
- return
+ return False
# Get the parameter from processing parameters
- param = proc_params.param
+ param = copy.deepcopy(proc_params.param)
- # Disable ROI creation during auto-recompute: detection functions store
- # create_rois=True in their parameters, but auto-recompute should only
+ # Disable ROI creation during recompute: detection functions store
+ # create_rois=True in their parameters, but recompute should only
# update analysis results, not recreate ROIs (which would make them
# impossible to delete or modify).
if hasattr(param, "create_rois"):
param.create_rois = False
- # Get the actual function from the function name
- feature = self.get_feature(proc_params.func_name)
-
# Recompute the analysis operation silently, only for this specific object
- # (not all selected objects, to avoid O(n²) behavior when called in a loop)
- with Conf.proc.show_result_dialog.temp(False):
- self.compute_1_to_0(feature.function, param, edit=False, target_objs=[obj])
+ # (not all selected objects, to avoid O(n²) behavior when called in a loop).
+ success = self.recompute_1_to_0(
+ proc_params.func_name,
+ obj,
+ param,
+ plugin_origin=proc_params.plugin_origin,
+ )
+ if not success:
+ return False
# Update the view
obj_uuid = get_uuid(obj)
self.panel.objview.update_item(obj_uuid)
if refresh_plot:
self.panel.refresh_plot(obj_uuid, update_items=True, force=True)
+ return True
+
+ def recompute_processing(
+ self,
+ obj: SignalObj | ImageObj,
+ param: gds.DataSet | None = None,
+ interactive: bool = True,
+ refresh_plot: bool = True,
+ ) -> ProcessingReport:
+ """Recompute a stored 1-to-1 processing operation on demand.
+
+ This is the processing counterpart of :meth:`recompute_analysis`. It
+ resolves the stored 1-to-1 processing metadata from ``obj``, recomputes
+ the result from its source object, then updates ``obj`` in-place.
+
+ Args:
+ obj: The processed object to recompute in-place.
+ param: Optional parameter override. When None, use the parameters
+ stored in the object's processing metadata.
+ interactive: If True, show UI error messages.
+ refresh_plot: Whether to refresh the plot after recomputation.
+
+ Returns:
+ ProcessingReport describing the result.
+ """
+ if env.execenv.unattended:
+ interactive = False
+
+ report = ProcessingReport(success=False, obj_uuid=get_uuid(obj))
+
+ # Extract processing parameters
+ proc_params = extract_processing_parameters(obj)
+ if proc_params is None or proc_params.pattern != "1-to-1":
+ report.message = _("Processing metadata is incomplete.")
+ if interactive:
+ QW.QMessageBox.critical(self.panel, _("Error"), report.message)
+ return report
+
+ # Check if source object still exists
+ if proc_params.source_uuid is None:
+ report.message = _(
+ "Processing metadata is incomplete (missing source UUID)."
+ )
+ if interactive:
+ QW.QMessageBox.critical(self.panel, _("Error"), report.message)
+ return report
+
+ # Find source object
+ source_obj = self.mainwindow.find_object_by_uuid(proc_params.source_uuid)
+ if source_obj is None:
+ report.message = _("Source object no longer exists.")
+ if interactive:
+ QW.QMessageBox.critical(
+ self.panel,
+ _("Error"),
+ report.message
+ + "\n\n"
+ + _(
+ "The object that was used to create this processed object "
+ "has been deleted and cannot be used for reprocessing."
+ ),
+ )
+ return report
+
+ # Get updated parameters from caller/editor override, fallback to metadata
+ param = proc_params.param if param is None else param
+
+ # For cross-panel computations, we need to use the processor from the panel
+ # that owns the source object (e.g., radial_profile is in ImageProcessor)
+ if isinstance(source_obj, SignalObj):
+ source_processor = self.mainwindow.signalpanel.processor
+ else:
+ source_processor = self.mainwindow.imagepanel.processor
+
+ # Recompute using the dedicated method (with multiprocessing support)
+ try:
+ compout = source_processor.recompute_1_to_1(
+ proc_params.func_name,
+ source_obj,
+ param,
+ plugin_origin=proc_params.plugin_origin,
+ )
+ except Exception as exc: # pylint: disable=broad-exception-caught
+ report.message = _("Failed to reprocess object:\n%s") % str(exc)
+ if interactive:
+ QW.QMessageBox.warning(self.panel, _("Error"), report.message)
+ return report
+
+ # User cancelled the operation
+ if compout.cancelled:
+ report.message = _("Processing was cancelled.")
+ report.cancelled = True
+ return report
+
+ new_obj = compout.result
+ if new_obj is None:
+ report.message = compout.error_msg or _("Failed to reprocess object.")
+ return report
+
+ # Update the current object in-place with data from new object
+ obj.title = new_obj.title
+ if isinstance(obj, SignalObj):
+ obj.xydata = new_obj.xydata
+ else:
+ obj.data = new_obj.data
+ # Invalidate ROI mask cache when image dimensions may have changed
+ # (the mask is computed based on image shape, so it must be recomputed)
+ obj.invalidate_maskdata_cache()
+
+ # Update metadata with new processing parameters
+ updated_proc_params = ProcessingParameters(
+ func_name=proc_params.func_name,
+ pattern=proc_params.pattern,
+ param=param,
+ source_uuid=proc_params.source_uuid,
+ plugin_origin=proc_params.plugin_origin,
+ )
+ insert_processing_parameters(obj, updated_proc_params)
+
+ # Update the tree view item and refresh plot
+ self.panel.objview.update_item(report.obj_uuid)
+ if refresh_plot:
+ self.panel.refresh_plot(report.obj_uuid, update_items=True, force=True)
+
+ report.success = True
+ if isinstance(obj, SignalObj):
+ report.message = _("Signal was reprocessed.")
+ else:
+ report.message = _("Image was reprocessed.")
+ self.panel.SIG_STATUS_MESSAGE.emit("✅ " + report.message, 5000)
+ return report
def __exec_func(
self,
@@ -1073,7 +1438,8 @@ def recompute_1_to_1(
func_name: str,
obj: SignalObj | ImageObj,
param: gds.DataSet | None = None,
- ) -> SignalObj | ImageObj | None:
+ plugin_origin: dict[str, Any] | None = None,
+ ) -> CompOut:
"""Recompute a 1-to-1 processing operation without adding result to panel.
This method is specifically designed for the interactive re-processing feature
@@ -1085,18 +1451,23 @@ def recompute_1_to_1(
func_name: Name of the processing function
obj: Source object to process
param: Processing parameters (optional)
+ plugin_origin: Optional plugin origin descriptor (propagated to
+ :meth:`get_feature` for richer error reporting).
Returns:
- New processed object (not added to panel), or None if cancelled or error
+ Computation output containing the new processed object, an error, or an
+ explicit cancellation status
Raises:
- ValueError: If function is not found in registry
+ FeatureNotFoundError: If function is not found in registry.
"""
# Get the function from the registry
- try:
- feature = self.get_feature(func_name)
- except ValueError as exc:
- raise ValueError(f"Function '{func_name}' not found in registry") from exc
+ paramclass_name = type(param).__name__ if param is not None else None
+ feature = self.get_feature(
+ func_name,
+ plugin_origin=plugin_origin,
+ paramclass_name=paramclass_name,
+ )
func = feature.function
@@ -1110,21 +1481,205 @@ def recompute_1_to_1(
comp_out = self.__exec_func(func, args, progress)
if comp_out is None: # Cancelled by user
- return None
+ return CompOut(cancelled=True)
# Handle the output
new_obj = self.handle_output(comp_out, _("Recomputing"), progress)
if new_obj is None:
- return None
+ return comp_out
# Handle keep_results logic
if isinstance(new_obj, (SignalObj, ImageObj)):
self._handle_keep_results(new_obj)
patch_title_with_ids(new_obj, [obj], get_short_id)
+ comp_out.result = new_obj
+ return comp_out
+
+ # ------------------------------------------------------------------
+ # In-place recompute helpers used by the History panel cascade
+ # (Edit mode tweaks + downstream propagation). They mirror their
+ # ``compute_*`` counterparts but:
+ # - never add results to a panel (caller updates targets in place);
+ # - never record a history entry (cascade runs under ``replaying``);
+ # - never insert :class:`ProcessingParameters` (caller does, so that
+ # ``source_uuid`` / ``source_uuids`` stay consistent with the
+ # existing output object identity).
+ # ------------------------------------------------------------------
+
+ def recompute_1_to_n(
+ self,
+ func_name: str,
+ obj: SignalObj | ImageObj,
+ params: list[gds.DataSet],
+ plugin_origin: dict[str, Any] | None = None,
+ ) -> list[SignalObj | ImageObj] | None:
+ """Recompute a 1-to-n processing operation without adding results to panel.
+
+ Args:
+ func_name: Name of the processing function.
+ obj: Source object to process.
+ params: List of N parameter datasets (one per output).
+ plugin_origin: Optional plugin origin descriptor.
+
+ Returns:
+ List of N new objects (in input order), or ``None`` if cancelled
+ or an unrecoverable error occurred. Shorter lists are possible
+ when individual sub-calls return ``None``.
+ """
+ paramclass_name = (
+ type(params[0]).__name__ if params and params[0] is not None else None
+ )
+ feature = self.get_feature(
+ func_name,
+ plugin_origin=plugin_origin,
+ paramclass_name=paramclass_name,
+ )
+ func = feature.function
+ results: list[SignalObj | ImageObj] = []
+ with create_progress_bar(
+ self.panel, _("Recomputing..."), max_=len(params)
+ ) as progress:
+ for idx, param in enumerate(params):
+ progress.setValue(idx)
+ progress.setLabelText(_("Processing object with updated parameters..."))
+ args = (obj, param) if param is not None else (obj,)
+ comp_out = self.__exec_func(func, args, progress)
+ if comp_out is None:
+ return None
+ new_obj = self.handle_output(comp_out, _("Recomputing"), progress)
+ if new_obj is None:
+ continue
+ if isinstance(new_obj, (SignalObj, ImageObj)):
+ self._handle_keep_results(new_obj)
+ patch_title_with_ids(new_obj, [obj], get_short_id)
+ results.append(new_obj)
+ return results
+
+ def recompute_n_to_1(
+ self,
+ func_name: str,
+ objs: list[SignalObj | ImageObj],
+ param: gds.DataSet | None = None,
+ plugin_origin: dict[str, Any] | None = None,
+ ) -> SignalObj | ImageObj | None:
+ """Recompute an n-to-1 processing operation without adding result to panel.
+
+ Args:
+ func_name: Name of the processing function.
+ objs: Source object list to aggregate.
+ param: Processing parameters (optional).
+ plugin_origin: Optional plugin origin descriptor.
+
+ Returns:
+ New aggregated object, or ``None`` if cancelled / errored.
+
+ .. note::
+ Pairwise mode is not handled here: each pairwise output is a
+ distinct single-output recompute -- the caller is expected to
+ split the work per output and iterate.
+ """
+ paramclass_name = type(param).__name__ if param is not None else None
+ feature = self.get_feature(
+ func_name,
+ plugin_origin=plugin_origin,
+ paramclass_name=paramclass_name,
+ )
+ func = feature.function
+ with create_progress_bar(self.panel, _("Recomputing..."), max_=1) as progress:
+ progress.setValue(0)
+ progress.setLabelText(_("Processing object with updated parameters..."))
+ args = (objs, param) if param is not None else (objs,)
+ comp_out = self.__exec_func(func, args, progress)
+ if comp_out is None:
+ return None
+ new_obj = self.handle_output(comp_out, _("Recomputing"), progress)
+ if new_obj is None:
+ return None
+ if isinstance(new_obj, (SignalObj, ImageObj)):
+ self._handle_keep_results(new_obj)
+ self._merge_geometry_results_for_n_to_1(new_obj, objs)
+ patch_title_with_ids(new_obj, objs, get_short_id)
return new_obj
+ def recompute_2_to_1(
+ self,
+ func_name: str,
+ obj1: SignalObj | ImageObj,
+ obj2: SignalObj | ImageObj,
+ param: gds.DataSet | None = None,
+ plugin_origin: dict[str, Any] | None = None,
+ ) -> SignalObj | ImageObj | None:
+ """Recompute a 2-to-1 processing operation without adding result to panel.
+
+ Args:
+ func_name: Name of the processing function.
+ obj1: First source object.
+ obj2: Second source object.
+ param: Processing parameters (optional).
+ plugin_origin: Optional plugin origin descriptor.
+
+ Returns:
+ New combined object, or ``None`` if cancelled / errored.
+ """
+ paramclass_name = type(param).__name__ if param is not None else None
+ feature = self.get_feature(
+ func_name,
+ plugin_origin=plugin_origin,
+ paramclass_name=paramclass_name,
+ )
+ func = feature.function
+ with create_progress_bar(self.panel, _("Recomputing..."), max_=1) as progress:
+ progress.setValue(0)
+ progress.setLabelText(_("Processing object with updated parameters..."))
+ args = (obj1, obj2, param) if param is not None else (obj1, obj2)
+ comp_out = self.__exec_func(func, args, progress)
+ if comp_out is None:
+ return None
+ new_obj = self.handle_output(comp_out, _("Recomputing"), progress)
+ if new_obj is None:
+ return None
+ if isinstance(new_obj, (SignalObj, ImageObj)):
+ self._handle_keep_results(new_obj)
+ patch_title_with_ids(new_obj, [obj1, obj2], get_short_id)
+ return new_obj
+
+ def recompute_1_to_0(
+ self,
+ func_name: str,
+ obj: SignalObj | ImageObj,
+ param: gds.DataSet | None = None,
+ plugin_origin: dict[str, Any] | None = None,
+ ) -> bool:
+ """Recompute a 1-to-0 analysis on ``obj`` in place.
+
+ Reuses :meth:`compute_1_to_0` with ``target_objs=[obj]`` under the
+ history-panel ``replaying`` guard so no synthetic history entry is
+ recorded. The analysis result is written to ``obj``'s metadata.
+
+ Args:
+ func_name: Name of the analysis function.
+ obj: Object whose analysis must be refreshed.
+ param: Analysis parameters (optional).
+ plugin_origin: Optional plugin origin descriptor.
+
+ Returns:
+ True if the analysis result was refreshed successfully.
+ """
+ paramclass_name = type(param).__name__ if param is not None else None
+ feature = self.get_feature(
+ func_name,
+ plugin_origin=plugin_origin,
+ paramclass_name=paramclass_name,
+ )
+ historypanel = self.mainwindow.historypanel
+ with historypanel.replaying(), Conf.proc.show_result_dialog.temp(False):
+ result = self.compute_1_to_0(
+ feature.function, param, edit=False, target_objs=[obj]
+ )
+ return result is not None and result.execution_success
+
def _compute_1_to_1_subroutine(
self, funcs: list[Callable], params: list, title: str
) -> None:
@@ -1171,6 +1726,7 @@ def _compute_1_to_1_subroutine(
pattern="1-to-1",
param=param,
source_uuid=get_uuid(obj),
+ plugin_origin=self._get_plugin_origin_for(func),
)
insert_processing_parameters(new_obj, pp)
@@ -1269,7 +1825,7 @@ def compute_1_to_1(
comment: str | None = None,
edit: bool | None = None,
) -> None:
- """Generic processing method: 1 object in → 1 object out.
+ """Generic processing method: 1 object in → 1 object out.
Applies a function independently to each selected object in the active panel.
The result of each computation is a new object appended to the same panel.
@@ -1299,7 +1855,18 @@ def compute_1_to_1(
if param is not None:
if edit and not param.edit(parent=self.mainwindow):
return
- self._compute_1_to_1_subroutine([func], [param], title)
+ plugin_origin = self._get_plugin_origin_for(func)
+ pp = build_processing_parameters(
+ func.__name__, "1-to-1", param=param, plugin_origin=plugin_origin
+ )
+ action = self.mainwindow.historypanel.add_compute_entry_from_pp(
+ title or func.__name__,
+ pp,
+ panel_str=self.panel.PANEL_STR_ID,
+ plugin_origin=plugin_origin,
+ )
+ with self.mainwindow.historypanel.capture_outputs(action):
+ self._compute_1_to_1_subroutine([func], [param], title)
def compute_multiple_1_to_1(
self,
@@ -1308,7 +1875,7 @@ def compute_multiple_1_to_1(
title: str | None = None,
edit: bool | None = None,
) -> None:
- """Generic processing method: 1 object in → n objects out.
+ """Generic processing method: 1 object in → n objects out.
Applies multiple functions to each selected object, generating multiple
outputs per object. The resulting objects are appended to the active panel.
@@ -1324,7 +1891,7 @@ def compute_multiple_1_to_1(
.. note::
With k selected objects and n outputs per function,
- the method produces k × n outputs.
+ the method produces k × n outputs.
.. note::
This method does not support pairwise mode.
@@ -1337,7 +1904,19 @@ def compute_multiple_1_to_1(
return
if len(funcs) != len(params):
raise ValueError("Number of functions must match number of parameters")
- self._compute_1_to_1_subroutine(funcs, params, title)
+ pp = build_processing_parameters(
+ funcs[0].__name__ if funcs else "", "multiple-1-to-1"
+ )
+ action = self.mainwindow.historypanel.add_compute_entry_from_pp(
+ title or "compute_multiple_1_to_1",
+ pp,
+ panel_str=self.panel.PANEL_STR_ID,
+ func_names=[f.__name__ for f in funcs],
+ params=params if any(p is not None for p in params) else None,
+ plugin_origin=(self._get_plugin_origin_for(funcs[0]) if funcs else None),
+ )
+ with self.mainwindow.historypanel.capture_outputs(action):
+ self._compute_1_to_1_subroutine(funcs, params, title)
def compute_1_to_n(
self,
@@ -1346,7 +1925,7 @@ def compute_1_to_n(
title: str | None = None,
edit: bool | None = None,
) -> None:
- """Generic processing method: 1 object in → n objects out.
+ """Generic processing method: 1 object in → n objects out.
Applies a single function to each selected object, with n different parameters
set, thus generating n outputs per object. The resulting objects are appended to
@@ -1363,7 +1942,7 @@ def compute_1_to_n(
.. note::
With k selected objects and n parameter sets,
- the method produces k × n outputs.
+ the method produces k × n outputs.
.. note::
This method does not support pairwise mode.
@@ -1373,7 +1952,16 @@ def compute_1_to_n(
group = gds.DataSetGroup(params, title=_("Parameters"))
if not group.edit(parent=self.mainwindow):
return
- self._compute_1_to_1_subroutine([func] * len(params), params, title)
+ pp = build_processing_parameters(func.__name__, "1-to-n")
+ action = self.mainwindow.historypanel.add_compute_entry_from_pp(
+ title or func.__name__,
+ pp,
+ panel_str=self.panel.PANEL_STR_ID,
+ params=params,
+ plugin_origin=self._get_plugin_origin_for(func),
+ )
+ with self.mainwindow.historypanel.capture_outputs(action):
+ self._compute_1_to_1_subroutine([func] * len(params), params, title)
def compute_1_to_0(
self,
@@ -1384,14 +1972,15 @@ def compute_1_to_0(
comment: str | None = None,
edit: bool | None = None,
target_objs: list[SignalObj | ImageObj] | None = None,
- ) -> ResultData:
- """Generic processing method: 1 object in → no object out.
+ ) -> ResultData | None:
+ """Generic processing method: 1 object in → no object out.
Applies a function to each selected object (or specified target objects),
returning metadata or measurement results (e.g. peak coordinates, statistical
properties) without generating new objects. Results are stored in the object's
metadata and returned as a
- ResultData instance.
+ ResultData instance, or None if parameter editing is cancelled or
+ preprocessing fails.
Args:
func: Function to execute, that takes either `(obj)` or `(obj, param)` as
@@ -1406,7 +1995,8 @@ def compute_1_to_0(
processes all currently selected objects.
Returns:
- ResultData instance containing the results for all processed objects.
+ ResultData instance containing the results for all processed objects,
+ or None if the operation is cancelled or cannot be prepared.
.. note::
With k selected objects, the method performs k analyses and produces
@@ -1429,6 +2019,17 @@ def compute_1_to_0(
return None
current_obj = self.panel.objview.get_current_object()
title = func.__name__ if title is None else title
+ pp_history = build_processing_parameters(func.__name__, "1-to-0", param=param)
+ action = self.mainwindow.historypanel.add_compute_entry_from_pp(
+ title,
+ pp_history,
+ panel_str=self.panel.PANEL_STR_ID,
+ plugin_origin=self._get_plugin_origin_for(func),
+ )
+ # 1-to-0: no data object is produced. Register an empty output list so
+ # the bijective mapping records the action even with zero outputs.
+ if action is not None:
+ self.mainwindow.historypanel.register_action_outputs(action, [])
refresh_needed = False
with create_progress_bar(self.panel, title, max_=len(objs)) as progress:
rdata = ResultData()
@@ -1441,45 +2042,53 @@ def compute_1_to_0(
# Execute function
compout = self.__exec_func(func, args, progress)
if compout is None:
+ rdata.execution_success = False
break
result = self.handle_output(
compout, _("Computing: %s") % title, progress
)
if result is None:
+ rdata.execution_success = False
continue
- # Using the adapters:
- if isinstance(result, GeometryResult):
- adapter = GeometryAdapter(result)
- elif isinstance(result, TableResult):
- adapter = TableAdapter(result)
- else:
- # For "compute 1 to 0" functions, the result is either a
- # GeometryResult or TableResult:
- raise TypeError("Unsupported result type")
-
- # Add result shape to object's metadata
- # Pass function name for better parameter context in the Analysis tab
- adapter.add_to(obj, param)
-
- # Store processing parameters for auto-recompute on ROI change
- # This enables automatic recalculation when ROI is modified
- # Analysis parameters (1-to-0) are stored separately from
- # transformation history to avoid overwriting the processing chain
- # when analyzing objects.
- pp = ProcessingParameters(
- func_name=func.__name__,
- pattern="1-to-0",
- param=param,
- source_uuid=get_uuid(obj),
- )
- insert_processing_parameters(obj, pp)
+ metadata_snapshot = copy.deepcopy(obj.metadata)
+ result_count = len(rdata.results)
+ ylabel_count = len(rdata.ylabels)
+ short_id_count = len(rdata.short_ids)
- # Apply processor-specific post-processing on the result
- refresh_needed |= self.postprocess_1_to_0_result(obj, result)
+ def persist_result() -> bool:
+ if isinstance(result, GeometryResult):
+ adapter = GeometryAdapter(result)
+ elif isinstance(result, TableResult):
+ adapter = TableAdapter(result)
+ else:
+ raise TypeError("Unsupported result type")
- # Append result to result data for later display
- rdata.append(adapter, obj)
+ adapter.add_to(obj, param)
+ pp = ProcessingParameters(
+ func_name=func.__name__,
+ pattern="1-to-0",
+ param=param,
+ source_uuid=get_uuid(obj),
+ plugin_origin=self._get_plugin_origin_for(func),
+ )
+ insert_processing_parameters(obj, pp)
+ result_modified = self.postprocess_1_to_0_result(obj, result)
+ rdata.append(adapter, obj)
+ return result_modified
+
+ persistence_output = wng_err_func(persist_result, ())
+ result_modified = self.handle_output(
+ persistence_output, _("Computing: %s") % title, progress
+ )
+ if result_modified is None:
+ obj.metadata = metadata_snapshot
+ del rdata.results[result_count:]
+ del rdata.ylabels[ylabel_count:]
+ del rdata.short_ids[short_id_count:]
+ rdata.execution_success = False
+ continue
+ refresh_needed |= result_modified
if obj is current_obj:
# Mark object as having fresh analysis results to show Analysis tab
@@ -1504,8 +2113,9 @@ def compute_n_to_1(
title: str | None = None,
comment: str | None = None,
edit: bool | None = None,
+ pairwise: bool | None = None,
) -> None:
- """Generic processing method: n objects in → 1 object out.
+ """Generic processing method: n objects in → 1 object out.
Aggregates multiple selected objects into a single result using the provided
function. In pairwise mode, applies the function to object pairs (grouped by
@@ -1536,202 +2146,226 @@ def compute_n_to_1(
objs = self.panel.objview.get_sel_objects(include_groups=True)
objmodel = self.panel.objmodel
- pairwise = is_pairwise_mode()
+ pairwise = is_pairwise_mode() if pairwise is None else pairwise
name = func.__name__
- if pairwise:
- src_grps, src_gids, src_objs, _nbobj, valid = (
- self.__get_src_grps_gids_objs_nbobj_valid(min_group_nb=2)
- )
- if not valid:
- return
- dst_gname = (
- f"{name}({','.join([get_short_id(grp) for grp in src_grps])})|pairwise"
- )
- group_exclusive = len(self.panel.objview.get_sel_groups()) != 0
- if not group_exclusive:
- # This is not a group exclusive selection
- dst_gname += "[...]"
- # Delay group creation until after first result to determine target panel
- dst_gid = None
- n_pairs = len(src_objs[src_gids[0]])
- max_i_pair = min(
- n_pairs, max(len(src_objs[get_uuid(grp)]) for grp in src_grps)
- )
- # Track "Yes to All" choice for this compute operation
- auto_interpolate_for_operation = False
+ pp_history = build_processing_parameters(name, "n-to-1", param=param)
+ action = self.mainwindow.historypanel.add_compute_entry_from_pp(
+ name,
+ pp_history,
+ panel_str=self.panel.PANEL_STR_ID,
+ pairwise=pairwise,
+ plugin_origin=self._get_plugin_origin_for(func),
+ )
- with create_progress_bar(self.panel, title, max_=n_pairs) as progress:
- for i_pair, src_obj1 in enumerate(src_objs[src_gids[0]][:max_i_pair]):
- progress.setValue(i_pair + 1)
- progress.setLabelText(title)
- src_objs_pair = [src_obj1]
- for src_gid in src_gids[1:]:
- src_obj = src_objs[src_gid][i_pair]
- src_objs_pair.append(src_obj)
-
- # Check signal x-array compatibility for n-to-1 operations
- if auto_interpolate_for_operation:
- # "Yes to All" selected, automatically interpolate
- # by temporarily changing the configuration
- with Conf.proc.xarray_compat_behavior.temp("interpolate"):
+ with self.mainwindow.historypanel.capture_outputs(action):
+ if pairwise:
+ src_grps, src_gids, src_objs, _nbobj, valid = (
+ self.__get_src_grps_gids_objs_nbobj_valid(min_group_nb=2)
+ )
+ if not valid:
+ return
+ dst_gname = (
+ f"{name}({','.join([get_short_id(grp) for grp in src_grps])})"
+ "|pairwise"
+ )
+ group_exclusive = len(self.panel.objview.get_sel_groups()) != 0
+ if not group_exclusive:
+ # This is not a group exclusive selection
+ dst_gname += "[...]"
+ # Delay group creation until after first result
+ # to determine target panel
+ dst_gid = None
+ n_pairs = len(src_objs[src_gids[0]])
+ max_i_pair = min(
+ n_pairs, max(len(src_objs[get_uuid(grp)]) for grp in src_grps)
+ )
+ # Track "Yes to All" choice for this compute operation
+ auto_interpolate_for_operation = False
+
+ with create_progress_bar(self.panel, title, max_=n_pairs) as progress:
+ for i_pair, src_obj1 in enumerate(
+ src_objs[src_gids[0]][:max_i_pair]
+ ):
+ progress.setValue(i_pair + 1)
+ progress.setLabelText(title)
+ src_objs_pair = [src_obj1]
+ for src_gid in src_gids[1:]:
+ src_obj = src_objs[src_gid][i_pair]
+ src_objs_pair.append(src_obj)
+
+ # Check signal x-array compatibility for n-to-1 operations
+ if auto_interpolate_for_operation:
+ # "Yes to All" selected, automatically interpolate
+ # by temporarily changing the configuration
+ with Conf.proc.xarray_compat_behavior.temp("interpolate"):
+ result = self._check_signal_xarray_compatibility(
+ src_objs_pair, progress=progress
+ )
+ else:
+ # Normal compatibility check with dialog
result = self._check_signal_xarray_compatibility(
src_objs_pair, progress=progress
)
- else:
- # Normal compatibility check with dialog
- result = self._check_signal_xarray_compatibility(
- src_objs_pair, progress=progress
- )
- if result is None:
- # User canceled or compatibility check failed
- return
-
- checked_objs, yes_to_all_selected = result
- if yes_to_all_selected:
- auto_interpolate_for_operation = True
-
- src_objs_pair = checked_objs
- if param is None:
- args = (src_objs_pair,)
- else:
- args = (src_objs_pair, param)
- result = self.__exec_func(func, args, progress)
- if result is None:
- break
- new_obj = self.handle_output(
- result, _("Calculating: %s") % title, progress
- )
- if new_obj is None:
- break
- assert isinstance(new_obj, (SignalObj, ImageObj))
+ if result is None:
+ # User canceled or compatibility check failed
+ return
+
+ checked_objs, yes_to_all_selected = result
+ if yes_to_all_selected:
+ auto_interpolate_for_operation = True
+
+ src_objs_pair = checked_objs
+ if param is None:
+ args = (src_objs_pair,)
+ else:
+ args = (src_objs_pair, param)
+ result = self.__exec_func(func, args, progress)
+ if result is None:
+ break
+ new_obj = self.handle_output(
+ result, _("Calculating: %s") % title, progress
+ )
+ if new_obj is None:
+ break
+ assert isinstance(new_obj, (SignalObj, ImageObj))
- patch_title_with_ids(new_obj, src_objs_pair, get_short_id)
+ patch_title_with_ids(new_obj, src_objs_pair, get_short_id)
- # Handle keep_results and geometry result merging
- self._handle_keep_results(new_obj)
- self._merge_geometry_results_for_n_to_1(new_obj, src_objs_pair)
+ # Handle keep_results and geometry result merging
+ self._handle_keep_results(new_obj)
+ self._merge_geometry_results_for_n_to_1(new_obj, src_objs_pair)
- # Store lightweight processing metadata (non-interactive)
- proc_params = ProcessingParameters(
- func_name=name,
- pattern="n-to-1",
- param=param,
- source_uuids=[get_uuid(obj) for obj in src_objs_pair],
- )
- insert_processing_parameters(new_obj, proc_params)
+ # Store lightweight processing metadata (non-interactive)
+ proc_params = ProcessingParameters(
+ func_name=name,
+ pattern="n-to-1",
+ param=param,
+ source_uuids=[get_uuid(obj) for obj in src_objs_pair],
+ plugin_origin=self._get_plugin_origin_for(func),
+ )
+ insert_processing_parameters(new_obj, proc_params)
- # Create destination group on first result, in appropriate panel
- if dst_gid is None:
- dst_gid = self._create_group_for_result(new_obj, dst_gname)
+ # Create destination group on first result, in appropriate panel
+ if dst_gid is None:
+ dst_gid = self._create_group_for_result(new_obj, dst_gname)
- self._add_object_to_appropriate_panel(new_obj, group_id=dst_gid)
+ self._add_object_to_appropriate_panel(new_obj, group_id=dst_gid)
- else:
- # In single operand mode, we create a single object for all selected objects
+ else:
+ # In single operand mode, we create a single object
+ # for all selected objects
+
+ # [src_objs dictionary] keys: old group id, values: list of old objects
+ src_objs: dict[str, list[SignalObj | ImageObj]] = {}
+
+ grps = self.panel.objview.get_sel_groups()
+ dst_group_name = None
+ if grps:
+ # (Group exclusive selection)
+ # At least one group is selected: create a new group
+ dst_gname = f"{name}({','.join([get_uuid(grp) for grp in grps])})"
+ # Delay group creation until after first result
+ dst_gid = None
+ dst_group_name = dst_gname # Store name for later use
+ else:
+ # (Object exclusive selection)
+ # No group is selected: use each object's group
+ dst_gid = None
- # [src_objs dictionary] keys: old group id, values: list of old objects
- src_objs: dict[str, list[SignalObj | ImageObj]] = {}
+ for src_obj in objs:
+ src_gid = objmodel.get_object_group_id(src_obj)
+ src_objs.setdefault(src_gid, []).append(src_obj)
- grps = self.panel.objview.get_sel_groups()
- dst_group_name = None
- if grps:
- # (Group exclusive selection)
- # At least one group is selected: create a new group
- dst_gname = f"{name}({','.join([get_uuid(grp) for grp in grps])})"
- # Delay group creation until after first result
- dst_gid = None
- dst_group_name = dst_gname # Store name for later use
- else:
- # (Object exclusive selection)
- # No group is selected: use each object's group
- dst_gid = None
+ # Track "Yes to All" choice for this compute operation
+ auto_interpolate_for_operation = False
- for src_obj in objs:
- src_gid = objmodel.get_object_group_id(src_obj)
- src_objs.setdefault(src_gid, []).append(src_obj)
-
- # Track "Yes to All" choice for this compute operation
- auto_interpolate_for_operation = False
-
- with create_progress_bar(self.panel, title, max_=len(objs)) as progress:
- progress.setValue(0)
- progress.setLabelText(title)
- for src_gid, src_obj_list in src_objs.items():
- # Check signal x-array compatibility for n-to-1 operations
- if auto_interpolate_for_operation:
- # "Yes to All" selected, automatically interpolate
- with Conf.proc.xarray_compat_behavior.temp("interpolate"):
+ with create_progress_bar(self.panel, title, max_=len(objs)) as progress:
+ progress.setValue(0)
+ progress.setLabelText(title)
+ for src_gid, src_obj_list in src_objs.items():
+ # Check signal x-array compatibility for n-to-1 operations
+ if auto_interpolate_for_operation:
+ # "Yes to All" selected, automatically interpolate
+ with Conf.proc.xarray_compat_behavior.temp("interpolate"):
+ result = self._check_signal_xarray_compatibility(
+ src_obj_list, progress=progress
+ )
+ else:
+ # Normal compatibility check with dialog
result = self._check_signal_xarray_compatibility(
src_obj_list, progress=progress
)
- else:
- # Normal compatibility check with dialog
- result = self._check_signal_xarray_compatibility(
- src_obj_list, progress=progress
- )
- if result is None:
- # User canceled or compatibility check failed
- return
+ if result is None:
+ # User canceled or compatibility check failed
+ return
- checked_objs, yes_to_all_selected = result
- if yes_to_all_selected:
- auto_interpolate_for_operation = True
+ checked_objs, yes_to_all_selected = result
+ if yes_to_all_selected:
+ auto_interpolate_for_operation = True
- src_obj_list = checked_objs
+ src_obj_list = checked_objs
- if param is None:
- args = (src_obj_list,)
- else:
- args = (src_obj_list, param)
- result = self.__exec_func(func, args, progress)
- if result is None:
- break
- new_obj = self.handle_output(
- result, _("Calculating: %s") % title, progress
- )
- if new_obj is None:
- break
- assert isinstance(new_obj, (SignalObj, ImageObj))
+ if param is None:
+ args = (src_obj_list,)
+ else:
+ args = (src_obj_list, param)
+ result = self.__exec_func(func, args, progress)
+ if result is None:
+ break
+ new_obj = self.handle_output(
+ result, _("Calculating: %s") % title, progress
+ )
+ if new_obj is None:
+ break
+ assert isinstance(new_obj, (SignalObj, ImageObj))
- group_id = dst_gid if dst_gid is not None else src_gid
- patch_title_with_ids(new_obj, src_obj_list, get_short_id)
+ group_id = dst_gid if dst_gid is not None else src_gid
+ patch_title_with_ids(new_obj, src_obj_list, get_short_id)
- # Handle keep_results and geometry result merging
- self._handle_keep_results(new_obj)
- self._merge_geometry_results_for_n_to_1(new_obj, src_obj_list)
+ # Handle keep_results and geometry result merging
+ self._handle_keep_results(new_obj)
+ self._merge_geometry_results_for_n_to_1(new_obj, src_obj_list)
- # Store lightweight processing metadata (non-interactive)
- proc_params = ProcessingParameters(
- func_name=name,
- pattern="n-to-1",
- param=param,
- source_uuids=[get_uuid(obj) for obj in src_obj_list],
- )
- insert_processing_parameters(new_obj, proc_params)
+ # Store lightweight processing metadata (non-interactive)
+ proc_params = ProcessingParameters(
+ func_name=name,
+ pattern="n-to-1",
+ param=param,
+ source_uuids=[get_uuid(obj) for obj in src_obj_list],
+ plugin_origin=self._get_plugin_origin_for(func),
+ )
+ insert_processing_parameters(new_obj, proc_params)
- # Create destination group on first result, in appropriate panel
- use_group_for_non_native = False
- if dst_gid is None and dst_group_name is not None:
- dst_gid = self._create_group_for_result(new_obj, dst_group_name)
- group_id = dst_gid
- use_group_for_non_native = True
+ # Create destination group on first result, in appropriate panel
+ use_group_for_non_native = False
+ if dst_gid is None and dst_group_name is not None:
+ dst_gid = self._create_group_for_result(
+ new_obj, dst_group_name
+ )
+ group_id = dst_gid
+ use_group_for_non_native = True
- self._add_object_to_appropriate_panel(
- new_obj,
- group_id=group_id,
- use_group_for_non_native=use_group_for_non_native,
- )
+ self._add_object_to_appropriate_panel(
+ new_obj,
+ group_id=group_id,
+ use_group_for_non_native=use_group_for_non_native,
+ )
- # Select newly created group, if any
- if dst_gid is not None:
- self.panel.objview.set_current_item_id(dst_gid)
+ # Select newly created group, if any
+ if dst_gid is not None:
+ self.panel.objview.set_current_item_id(dst_gid)
def compute_2_to_1(
self,
- obj2: SignalObj | ImageObj | list[SignalObj | ImageObj] | None,
+ obj2: SignalObj
+ | ImageObj
+ | list[SignalObj | ImageObj]
+ | int
+ | list[int]
+ | None,
obj2_name: str,
func: Callable,
param: gds.DataSet | None = None,
@@ -1740,8 +2374,9 @@ def compute_2_to_1(
comment: str | None = None,
edit: bool | None = None,
skip_xarray_compat: bool | None = None,
+ pairwise: bool | None = None,
) -> None:
- """Generic processing method: binary operation 1+1 → 1.
+ """Generic processing method: binary operation 1+1 → 1.
Applies a binary function between each selected object and a second operand.
Supports both single operand mode (same operand for all objects)
@@ -1778,7 +2413,7 @@ def compute_2_to_1(
objs = self.panel.objview.get_sel_objects(include_groups=True)
objmodel = self.panel.objmodel
- pairwise = is_pairwise_mode()
+ pairwise = is_pairwise_mode() if pairwise is None else pairwise
name = func.__name__
if obj2 is None:
@@ -1788,6 +2423,9 @@ def compute_2_to_1(
assert pairwise
else:
objs2 = [obj2]
+ if objs2 and all(isinstance(obj, int) for obj in objs2):
+ # If obj2 is a list of object numbers, convert to objects
+ objs2 = [objmodel.get_object_from_number(obj) for obj in objs2]
dlg_title = _("Select %s") % obj2_name
@@ -1812,83 +2450,220 @@ def compute_2_to_1(
if objs2 is None:
return
- n_pairs = len(src_objs[src_gids[0]])
- max_i_pair = min(
- n_pairs, max(len(src_objs[get_uuid(grp)]) for grp in src_grps)
+ pp_history = build_processing_parameters(
+ func.__name__, "2-to-1", param=param
+ )
+ action = self.mainwindow.historypanel.add_compute_entry_from_pp(
+ title or func.__name__,
+ pp_history,
+ panel_str=self.panel.PANEL_STR_ID,
+ obj2_uuids=[get_uuid(obj) for obj in objs2],
+ obj2_name=obj2_name,
+ pairwise=True,
+ plugin_origin=self._get_plugin_origin_for(func),
)
- grp2_id = objmodel.get_object_group_id(objs2[0])
- grp2 = objmodel.get_group(grp2_id)
-
- # Initialize pair mapping for potential interpolations
- pair_maps = {}
-
- # Check x-array compatibility for signal processing (pairwise mode)
- if self._is_signal_panel() and not skip_xarray_compat:
- # Check compatibility between objects from both groups
- all_pairs = []
- for src_gid in src_gids:
- for i_pair in range(max_i_pair):
- src_obj1 = src_objs[src_gid][i_pair]
- src_obj2 = objs2[i_pair]
- if isinstance(src_obj1, SignalObj) and isinstance(
- src_obj2, SignalObj
- ):
- all_pairs.append((src_obj1, src_obj2))
-
- # Track "Yes to All" choice for this compute operation
- auto_interpolate_for_operation = False
- # Check all pairs for compatibility and create interpolation maps
- for src_obj1, src_obj2 in all_pairs:
- if auto_interpolate_for_operation:
- # "Yes to All" selected, automatically interpolate
- with Conf.proc.xarray_compat_behavior.temp("interpolate"):
+ with self.mainwindow.historypanel.capture_outputs(action):
+ n_pairs = len(src_objs[src_gids[0]])
+ max_i_pair = min(
+ n_pairs, max(len(src_objs[get_uuid(grp)]) for grp in src_grps)
+ )
+ grp2_id = objmodel.get_object_group_id(objs2[0])
+ grp2 = objmodel.get_group(grp2_id)
+
+ # Initialize pair mapping for potential interpolations
+ pair_maps = {}
+
+ # Check x-array compatibility for signal processing (pairwise mode)
+ if self._is_signal_panel() and not skip_xarray_compat:
+ # Check compatibility between objects from both groups
+ all_pairs = []
+ for src_gid in src_gids:
+ for i_pair in range(max_i_pair):
+ src_obj1 = src_objs[src_gid][i_pair]
+ src_obj2 = objs2[i_pair]
+ if isinstance(src_obj1, SignalObj) and isinstance(
+ src_obj2, SignalObj
+ ):
+ all_pairs.append((src_obj1, src_obj2))
+
+ # Track "Yes to All" choice for this compute operation
+ auto_interpolate_for_operation = False
+
+ # Check all pairs for compatibility and create interpolation maps
+ for src_obj1, src_obj2 in all_pairs:
+ if auto_interpolate_for_operation:
+ # "Yes to All" selected, automatically interpolate
+ with Conf.proc.xarray_compat_behavior.temp("interpolate"):
+ result = self._check_signal_xarray_compatibility(
+ [src_obj1, src_obj2]
+ )
+ else:
+ # Normal compatibility check with dialog
result = self._check_signal_xarray_compatibility(
[src_obj1, src_obj2]
)
- else:
- # Normal compatibility check with dialog
+
+ if result is None:
+ return # User cancelled or error occurred
+
+ checked_pair, yes_to_all_selected = result
+ if yes_to_all_selected:
+ auto_interpolate_for_operation = True
+
+ # Store mapping for this specific pair
+ pair_maps[(src_obj1, src_obj2)] = checked_pair
+
+ with create_progress_bar(
+ self.panel, title, max_=len(src_gids)
+ ) as progress:
+ for i_group, src_gid in enumerate(src_gids):
+ progress.setValue(i_group + 1)
+ progress.setLabelText(title)
+ if group_exclusive:
+ # This is a group exclusive selection
+ src_grp = objmodel.get_group(src_gid)
+ grp_short_ids = [get_uuid(grp) for grp in (src_grp, grp2)]
+ dst_gname = f"{name}({','.join(grp_short_ids)})|pairwise"
+ else:
+ dst_gname = f"{name}[...]"
+ # Delay group creation until after first result
+ dst_gid = None
+ for i_pair in range(max_i_pair):
+ orig_obj1 = src_objs[src_gid][i_pair]
+ orig_obj2 = objs2[i_pair]
+
+ # Use interpolated signals if available, keep original refs
+ actual_obj1, actual_obj2 = orig_obj1, orig_obj2
+ if (orig_obj1, orig_obj2) in pair_maps:
+ interpolated_pair = pair_maps[(orig_obj1, orig_obj2)]
+ actual_obj1 = interpolated_pair[0]
+ actual_obj2 = interpolated_pair[1]
+
+ args = [actual_obj1, actual_obj2]
+ if param is not None:
+ args.append(param)
+ result = self.__exec_func(func, tuple(args), progress)
+ if result is None:
+ break
+ new_obj = self.handle_output(
+ result, _("Calculating: %s") % title, progress
+ )
+ if new_obj is None:
+ continue
+ assert isinstance(new_obj, (SignalObj, ImageObj))
+
+ # Use original objects for title generation
+ patch_title_with_ids(
+ new_obj, [orig_obj1, orig_obj2], get_short_id
+ )
+
+ # Handle keep_results logic for 2_to_1 operations
+ self._handle_keep_results(new_obj)
+
+ # Store lightweight processing metadata (non-interactive)
+ proc_params = ProcessingParameters(
+ func_name=name,
+ pattern="2-to-1",
+ param=param,
+ source_uuids=[
+ get_uuid(orig_obj1),
+ get_uuid(orig_obj2),
+ ],
+ plugin_origin=self._get_plugin_origin_for(func),
+ )
+ insert_processing_parameters(new_obj, proc_params)
+
+ # Create dest group on first result
+ if dst_gid is None:
+ dst_gid = self._create_group_for_result(
+ new_obj, dst_gname
+ )
+
+ self._add_object_to_appropriate_panel(
+ new_obj, group_id=dst_gid
+ )
+
+ else:
+ if not objs2:
+ objs2 = self.panel.get_objects_with_dialog(
+ dlg_title,
+ _(
+ "Note: operation mode is single operand: "
+ "1 object expected"
+ ),
+ )
+ if objs2 is None:
+ return
+ obj2 = objs2[0]
+
+ pp_history = build_processing_parameters(
+ func.__name__, "2-to-1", param=param
+ )
+ action = self.mainwindow.historypanel.add_compute_entry_from_pp(
+ title or func.__name__,
+ pp_history,
+ panel_str=self.panel.PANEL_STR_ID,
+ obj2_uuids=[get_uuid(obj2)],
+ obj2_name=obj2_name,
+ pairwise=False,
+ plugin_origin=self._get_plugin_origin_for(func),
+ )
+
+ with self.mainwindow.historypanel.capture_outputs(action):
+ # Initialize signal mapping for potential interpolations
+ signal_map = {}
+
+ # Check x-array compatibility for signal processing
+ # (single operand mode)
+ orig_obj2 = obj2 # Keep reference to original obj2 for title generation
+ if (
+ self._is_signal_panel()
+ and isinstance(obj2, SignalObj)
+ and not skip_xarray_compat
+ ):
+ signal_objs = [obj for obj in objs if isinstance(obj, SignalObj)]
+ if signal_objs:
+ # Check compatibility and get potentially interpolated signals
result = self._check_signal_xarray_compatibility(
- [src_obj1, src_obj2]
+ signal_objs + [obj2]
)
+ if result is None:
+ return # User cancelled or error occurred
- if result is None:
- return # User cancelled or error occurred
+ checked_objs, _yes_to_all_selected = result
+ # Note: In single operand mode, "Yes to All" doesn't apply
+ # since there's only one compatibility check
- checked_pair, yes_to_all_selected = result
- if yes_to_all_selected:
- auto_interpolate_for_operation = True
+ # Replace obj2 with the potentially interpolated version
+ obj2 = checked_objs[-1] # obj2 was added last
- # Store mapping for this specific pair
- pair_maps[(src_obj1, src_obj2)] = checked_pair
+ # Create a mapping of original to interpolated signals
+ for orig_obj, checked_obj in zip(
+ signal_objs, checked_objs[:-1]
+ ):
+ signal_map[orig_obj] = checked_obj
+
+ with create_progress_bar(self.panel, title, max_=len(objs)) as progress:
+ for index, obj in enumerate(objs):
+ progress.setValue(index + 1)
+ progress.setLabelText(title)
+
+ # Use interpolated signal if available
+ actual_obj = obj
+ if (
+ self._is_signal_panel()
+ and isinstance(obj, SignalObj)
+ and obj in signal_map
+ ):
+ actual_obj = signal_map[obj]
- with create_progress_bar(self.panel, title, max_=len(src_gids)) as progress:
- for i_group, src_gid in enumerate(src_gids):
- progress.setValue(i_group + 1)
- progress.setLabelText(title)
- if group_exclusive:
- # This is a group exclusive selection
- src_grp = objmodel.get_group(src_gid)
- grp_short_ids = [get_uuid(grp) for grp in (src_grp, grp2)]
- dst_gname = f"{name}({','.join(grp_short_ids)})|pairwise"
- else:
- dst_gname = f"{name}[...]"
- # Delay group creation until after first result
- dst_gid = None
- for i_pair in range(max_i_pair):
- orig_obj1, orig_obj2 = src_objs[src_gid][i_pair], objs2[i_pair]
-
- # Use interpolated signals if available, keep original refs
- actual_obj1, actual_obj2 = orig_obj1, orig_obj2
- if (orig_obj1, orig_obj2) in pair_maps:
- interpolated_pair = pair_maps[(orig_obj1, orig_obj2)]
- actual_obj1 = interpolated_pair[0]
- actual_obj2 = interpolated_pair[1]
-
- args = [actual_obj1, actual_obj2]
- if param is not None:
- args.append(param)
- result = self.__exec_func(func, tuple(args), progress)
+ args = (
+ (actual_obj, obj2)
+ if param is None
+ else (actual_obj, obj2, param)
+ )
+ result = self.__exec_func(func, args, progress)
if result is None:
break
new_obj = self.handle_output(
@@ -1898,10 +2673,9 @@ def compute_2_to_1(
continue
assert isinstance(new_obj, (SignalObj, ImageObj))
+ group_id = objmodel.get_object_group_id(obj)
# Use original objects for title generation
- patch_title_with_ids(
- new_obj, [orig_obj1, orig_obj2], get_short_id
- )
+ patch_title_with_ids(new_obj, [obj, orig_obj2], get_short_id)
# Handle keep_results logic for 2_to_1 operations
self._handle_keep_results(new_obj)
@@ -1912,113 +2686,18 @@ def compute_2_to_1(
pattern="2-to-1",
param=param,
source_uuids=[
- get_uuid(orig_obj1),
+ get_uuid(obj),
get_uuid(orig_obj2),
],
+ plugin_origin=self._get_plugin_origin_for(func),
)
insert_processing_parameters(new_obj, proc_params)
- # Create destination group on first result, in appropriate panel
- if dst_gid is None:
- dst_gid = self._create_group_for_result(new_obj, dst_gname)
-
- self._add_object_to_appropriate_panel(new_obj, group_id=dst_gid)
-
- else:
- if not objs2:
- objs2 = self.panel.get_objects_with_dialog(
- dlg_title,
- _(
- "Note: operation mode is single operand: "
- "1 object expected"
- ),
- )
- if objs2 is None:
- return
- obj2 = objs2[0]
-
- # Initialize signal mapping for potential interpolations
- signal_map = {}
-
- # Check x-array compatibility for signal processing (single operand mode)
- orig_obj2 = obj2 # Keep reference to original obj2 for title generation
- if (
- self._is_signal_panel()
- and isinstance(obj2, SignalObj)
- and not skip_xarray_compat
- ):
- signal_objs = [obj for obj in objs if isinstance(obj, SignalObj)]
- if signal_objs:
- # Check compatibility and get potentially interpolated signals
- result = self._check_signal_xarray_compatibility(
- signal_objs + [obj2]
- )
- if result is None:
- return # User cancelled or error occurred
-
- checked_objs, _yes_to_all_selected = result
- # Note: In single operand mode, "Yes to All" doesn't apply
- # since there's only one compatibility check
-
- # Replace obj2 with the potentially interpolated version
- obj2 = checked_objs[-1] # obj2 was added last
-
- # Create a mapping of original to interpolated signals
- for orig_obj, checked_obj in zip(signal_objs, checked_objs[:-1]):
- signal_map[orig_obj] = checked_obj
-
- with create_progress_bar(self.panel, title, max_=len(objs)) as progress:
- for index, obj in enumerate(objs):
- progress.setValue(index + 1)
- progress.setLabelText(title)
-
- # Use interpolated signal if available
- actual_obj = obj
- if (
- self._is_signal_panel()
- and isinstance(obj, SignalObj)
- and obj in signal_map
- ):
- actual_obj = signal_map[obj]
-
- args = (
- (actual_obj, obj2)
- if param is None
- else (actual_obj, obj2, param)
- )
- result = self.__exec_func(func, args, progress)
- if result is None:
- break
- new_obj = self.handle_output(
- result, _("Calculating: %s") % title, progress
- )
- if new_obj is None:
- continue
- assert isinstance(new_obj, (SignalObj, ImageObj))
-
- group_id = objmodel.get_object_group_id(obj)
- # Use original objects for title generation
- patch_title_with_ids(new_obj, [obj, orig_obj2], get_short_id)
-
- # Handle keep_results logic for 2_to_1 operations
- self._handle_keep_results(new_obj)
-
- # Store lightweight processing metadata (non-interactive)
- proc_params = ProcessingParameters(
- func_name=name,
- pattern="2-to-1",
- param=param,
- source_uuids=[
- get_uuid(obj),
- get_uuid(orig_obj2),
- ],
- )
- insert_processing_parameters(new_obj, proc_params)
-
- # group_id is from source panel, don't use for non-native objects
- self._add_object_to_appropriate_panel(
- new_obj, group_id=group_id, use_group_for_non_native=False
- )
+ # group_id is from source panel, don't use
+ # for non-native objects
+ self._add_object_to_appropriate_panel(
+ new_obj, group_id=group_id, use_group_for_non_native=False
+ )
def register_1_to_1(
self,
@@ -2210,27 +2889,66 @@ def register_2_to_1(
def add_feature(self, feature: ComputingFeature) -> None:
"""Add a computing feature to the registry.
+ Auto-detects the plugin origin from ``feature.function.__module__`` and
+ stores it on the feature (see :func:`_detect_plugin_origin`).
+
Args:
feature: ComputingFeature instance to add.
"""
+ if feature.function is not None and feature.plugin_origin is None:
+ feature.plugin_origin = _detect_plugin_origin(feature.function)
self.computing_registry[feature.function] = feature
- def get_feature(self, function_or_name: Callable | str) -> ComputingFeature:
+ def _get_plugin_origin_for(self, func: Callable) -> dict[str, Any] | None:
+ """Return the plugin origin descriptor for ``func`` if known.
+
+ Falls back to a fresh detection if ``func`` is not in the registry.
+
+ Args:
+ func: Computation function.
+
+ Returns:
+ Plugin origin dict, or ``None`` for built-in functions.
+ """
+ feature = self.computing_registry.get(func)
+ if feature is not None:
+ return feature.plugin_origin
+ return _detect_plugin_origin(func)
+
+ def get_feature(
+ self,
+ function_or_name: Callable | str,
+ plugin_origin: dict[str, Any] | None = None,
+ paramclass_name: str | None = None,
+ ) -> ComputingFeature:
"""Get a computing feature by name or function.
Args:
function_or_name: Name of the feature or the function itself.
+ plugin_origin: Optional plugin origin descriptor used to enrich the
+ :class:`FeatureNotFoundError` raised when the feature is unknown.
+ paramclass_name: Optional name of the required parameter class, also
+ used to enrich the error message.
Returns:
Computing feature instance.
+
+ Raises:
+ FeatureNotFoundError: If no matching feature is registered. The
+ exception subclasses :class:`ValueError` to preserve backward
+ compatibility with existing callers.
"""
try:
return self.computing_registry[function_or_name]
- except KeyError as exc:
+ except KeyError:
for _func, feature in self.computing_registry.items():
if feature.name == function_or_name:
return feature
- raise ValueError(f"Unknown computing feature: {function_or_name}") from exc
+ raise FeatureNotFoundError(
+ str(function_or_name),
+ plugin_origin=plugin_origin,
+ paramclass_name=paramclass_name,
+ )
@qt_try_except()
def run_feature(
@@ -2297,6 +3015,9 @@ def run_feature(
assert isinstance(param, (gds.DataSet, type(None))), (
f"For pattern '{pattern}', 'param' must be a DataSet or None"
)
+ compute_kwargs = {}
+ if pattern == "n_to_1":
+ compute_kwargs["pairwise"] = kwargs.pop("pairwise", None)
return compute_method(
feature.function,
param=param,
@@ -2304,6 +3025,7 @@ def run_feature(
title=title,
comment=comment,
edit=edit,
+ **compute_kwargs,
)
if pattern == "2_to_1":
obj2 = kwargs.pop("obj2", args[0] if args else None)
@@ -2315,6 +3037,7 @@ def run_feature(
assert isinstance(param, (gds.DataSet, type(None))), (
"For pattern '2_to_1', 'param' must be a DataSet or None"
)
+ pairwise = kwargs.pop("pairwise", None)
return self.compute_2_to_1(
obj2,
feature.obj2_name or _("Second operand"),
@@ -2325,6 +3048,7 @@ def run_feature(
comment=comment,
edit=edit,
skip_xarray_compat=feature.skip_xarray_compat,
+ pairwise=pairwise,
)
if pattern == "1_to_n":
params = kwargs.get("params", args[0] if args else [])
@@ -2440,15 +3164,6 @@ def edit_roi_graphically(
only_visible=False,
only_existing=True,
)
- # Auto-recompute analysis operations for objects with modified ROIs
- if mode == "apply":
- with create_progress_bar(
- self.panel, _("Recomputing..."), max_=len(objs)
- ) as progress:
- for idx, obj_i in enumerate(objs):
- progress.setValue(idx)
- self.auto_recompute_analysis(obj_i, refresh_plot=False)
- self.panel.manual_refresh()
return edited_roi
def edit_roi_numerically(self) -> TypeROI:
@@ -2480,8 +3195,6 @@ def edit_roi_numerically(self) -> TypeROI:
only_visible=False,
only_existing=True,
)
- # Auto-recompute analysis operations after ROI modification
- self.auto_recompute_analysis(obj)
return edited_roi
return obj.roi
@@ -2496,15 +3209,10 @@ def delete_regions_of_interest(self) -> None:
)
== QW.QMessageBox.Yes
):
- modified_objs = []
for obj in self.panel.objview.get_sel_objects():
if obj.roi is not None:
obj.roi = None
- modified_objs.append(obj)
self.panel.selection_changed(update_items=True)
- # Auto-recompute analysis operations after ROI deletion
- for obj in modified_objs:
- self.auto_recompute_analysis(obj)
def delete_single_roi(self, roi_index: int) -> None:
"""Delete a single ROI by index
@@ -2529,7 +3237,4 @@ def delete_single_roi(self, roi_index: int) -> None:
if len(obj.roi.single_rois) == 0:
obj.roi = None
obj.mark_roi_as_changed()
- # Auto-recompute analysis operations after ROI modification
- # (must be done BEFORE selection_changed to avoid stale results)
- self.auto_recompute_analysis(obj)
self.panel.selection_changed(update_items=True)
diff --git a/datalab/gui/processor/catcher.py b/datalab/gui/processor/catcher.py
index 131028313..52f342646 100644
--- a/datalab/gui/processor/catcher.py
+++ b/datalab/gui/processor/catcher.py
@@ -29,11 +29,13 @@ class CompOut:
result: computation result
error_msg: error message
warning_msg: warning message
+ cancelled: True if computation was cancelled by the user
"""
result: SignalObj | ImageObj | GeometryResult | TableResult | None = None
error_msg: str | None = None
warning_msg: str | None = None
+ cancelled: bool = False
def wng_err_func(func: Callable, args: tuple[Any]) -> CompOut:
diff --git a/datalab/gui/processor/image.py b/datalab/gui/processor/image.py
index 2f38febaa..ece5824fe 100644
--- a/datalab/gui/processor/image.py
+++ b/datalab/gui/processor/image.py
@@ -8,7 +8,6 @@
from __future__ import annotations
-import guidata.dataset as gds
import numpy as np
import sigima.params
import sigima.proc.base as sipb
@@ -26,7 +25,6 @@
)
from sigima.objects.scalar import GeometryResult, TableResult
-from datalab import env
from datalab.config import APP_NAME, _
from datalab.gui.processor.base import BaseProcessor
from datalab.gui.processor.geometry_postprocess import (
@@ -60,50 +58,6 @@ def _wrap_geometric_transform(self, func, operation: str):
"""
return GeometricTransformWrapper(func, operation)
- def preprocess_1_to_0(
- self,
- func,
- param: gds.DataSet | None,
- objs: list[ImageObj],
- ) -> bool:
- """Override to confirm ROI replacement before the progress bar opens.
-
- When the parameter has ``create_rois=True`` and at least one selected
- image already has ROIs, the user is warned that the existing ROIs will
- be replaced.
-
- Args:
- func: The computation function that will be called
- param: Optional parameter set
- objs: List of image objects that will be processed
-
- Returns:
- True to proceed with the computation, False to abort
- """
- if (
- param is not None
- and getattr(param, "create_rois", False)
- and not env.execenv.unattended
- and any(obj.roi is not None and not obj.roi.is_empty() for obj in objs)
- ):
- return (
- QW.QMessageBox.question(
- self.mainwindow,
- _("Warning"),
- _(
- "Regions of interest are already defined for this "
- "image.
"
- "Creating new ROIs from detection will replace the "
- "existing ones, which will be lost.
"
- "Do you want to continue?"
- ),
- QW.QMessageBox.Yes | QW.QMessageBox.No,
- QW.QMessageBox.No,
- )
- == QW.QMessageBox.Yes
- )
- return True
-
def postprocess_1_to_0_result(
self, obj: ImageObj, result: GeometryResult | TableResult
) -> bool:
diff --git a/datalab/gui/processor/signal.py b/datalab/gui/processor/signal.py
index 1d6e025c4..ad4ae5f9d 100644
--- a/datalab/gui/processor/signal.py
+++ b/datalab/gui/processor/signal.py
@@ -32,6 +32,7 @@
from datalab.adapters_metadata.table_adapter import TableAdapter
from datalab.config import _
from datalab.gui.processor.base import BaseProcessor
+from datalab.objectmodel import get_uuid
from datalab.utils.qthelpers import qt_try_except
from datalab.widgets import (
fitdialog,
@@ -705,10 +706,15 @@ def polynomialfit(x, y, parent=None):
"""Polynomial fit dialog function"""
return dlgfunc(x, y, param.degree, parent=parent)
- self.compute_fit(txt, polynomialfit)
+ self.compute_fit(txt, polynomialfit, fit_type="polynomial")
def __row_compute_fit(
- self, obj: SignalObj, name: str, fitdlgfunc: Callable
+ self,
+ obj: SignalObj,
+ name: str,
+ fitdlgfunc: Callable,
+ fit_type: str | None = None,
+ fit_x0: list[float] | None = None,
) -> None:
"""Curve fitting computing sub-method"""
output = fitdlgfunc(obj.x, obj.y, parent=self.mainwindow)
@@ -726,19 +732,79 @@ def __row_compute_fit(
# Creating new signal
metadata = {fitdlgfunc.__name__: pvalues}
signal = create_signal(f"{name}({obj.title})", obj.x, y, metadata=metadata)
- # Creating new plot item
- self.panel.add_object(signal)
+ # Record a deterministically-replayable history action when the fit
+ # type is supported by the headless evaluator. Falls back to no
+ # recording (interactive-only) for unsupported types.
+ action = None
+ if fit_type is not None:
+ action = self.mainwindow.historypanel.add_ui_entry(
+ name,
+ target="signalprocessor",
+ method_name="recompute_fit",
+ save_state=True,
+ fit_type=fit_type,
+ fit_values=[float(p.value) for p in params],
+ fit_x0=[float(v) for v in fit_x0] if fit_x0 else None,
+ fit_name=name,
+ source_uuid=get_uuid(obj),
+ )
+ # Creating new plot item (capture its UUID as the action output)
+ with self.mainwindow.historypanel.capture_outputs(action):
+ self.panel.add_object(signal)
@qt_try_except()
- def compute_fit(self, title: str, fitdlgfunc: Callable) -> None:
+ def compute_fit(
+ self, title: str, fitdlgfunc: Callable, fit_type: str | None = None
+ ) -> None:
"""Compute fitting curve using an interactive dialog
Args:
title: Title of the dialog
fitdlgfunc: Fitting dialog function
+ fit_type: Optional explicit fit type id for deterministic replay.
+ When None, it is derived from ``fitdlgfunc.__name__``.
"""
+ if fit_type is None:
+ fit_type = fitdialog.fit_type_from_dlgfunc_name(fitdlgfunc.__name__)
for obj in self.panel.objview.get_sel_objects():
- self.__row_compute_fit(obj, title, fitdlgfunc)
+ self.__row_compute_fit(obj, title, fitdlgfunc, fit_type=fit_type)
+
+ @qt_try_except()
+ def recompute_fit(
+ self,
+ fit_type: str,
+ fit_values: list[float],
+ fit_x0: list[float] | None = None,
+ fit_name: str = "",
+ source_uuid: str | None = None,
+ ) -> None:
+ """Deterministically recompute an interactive fit result curve.
+
+ Used at history-replay time to reconstruct the fitted curve from the
+ recorded model type and parameter values, without reopening the
+ interactive dialog.
+
+ Args:
+ fit_type: Canonical fit type id (see
+ :func:`datalab.widgets.fitdialog.evaluate_fit`).
+ fit_values: Ordered fitted parameter values.
+ fit_x0: Peak abscissas for multi-peak fits (``None`` otherwise).
+ fit_name: Title prefix used for the produced signal.
+ source_uuid: UUID of the source signal to fit. When missing or no
+ longer present, falls back to the first selected signal.
+ """
+ obj = None
+ if source_uuid is not None and self.panel.objmodel.has_uuid(source_uuid):
+ obj = self.panel.objmodel[source_uuid]
+ if obj is None:
+ selected = self.panel.objview.get_sel_objects(include_groups=True)
+ obj = selected[0] if selected else None
+ if obj is None:
+ return
+ extra = {"a_x0": fit_x0} if fit_x0 else None
+ y = fitdialog.evaluate_fit(fit_type, obj.x, fit_values, extra)
+ signal = create_signal(f"{fit_name}({obj.title})", obj.x, y)
+ self.panel.add_object(signal)
@qt_try_except()
def compute_multigaussianfit(self) -> None:
@@ -755,7 +821,13 @@ def multigaussianfit(x, y, parent=None):
# pylint: disable=cell-var-from-loop
return fitdlgfunc(x, y, peaks, parent=parent)
- self.__row_compute_fit(obj, _("Multi-Gaussian fit"), multigaussianfit)
+ self.__row_compute_fit(
+ obj,
+ _("Multi-Gaussian fit"),
+ multigaussianfit,
+ fit_type="multigaussian",
+ fit_x0=[float(v) for v in obj.x[peaks]],
+ )
@qt_try_except()
def compute_multilorentzianfit(self) -> None:
@@ -773,7 +845,11 @@ def multilorentzianfit(x, y, parent=None):
return fitdlgfunc(x, y, peaks, parent=parent)
self.__row_compute_fit(
- obj, _("Multi-Lorentzian fit"), multilorentzianfit
+ obj,
+ _("Multi-Lorentzian fit"),
+ multilorentzianfit,
+ fit_type="multilorentzian",
+ fit_x0=[float(v) for v in obj.x[peaks]],
)
@qt_try_except()
diff --git a/datalab/h5/history.py b/datalab/h5/history.py
new file mode 100644
index 000000000..23c7d20b4
--- /dev/null
+++ b/datalab/h5/history.py
@@ -0,0 +1,326 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""History panel HDF5 import/export and persistence helpers."""
+
+from __future__ import annotations
+
+import os.path as osp
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+from uuid import uuid4
+
+from qtpy.compat import getopenfilename, getsavefilename
+
+from datalab.config import Conf, _
+from datalab.gui.processor.base import (
+ PROCESSING_PARAMETERS_OPTION,
+ ProcessingParameters,
+)
+from datalab.h5.native import NativeH5Reader, NativeH5Writer
+from datalab.history import HistorySession
+from datalab.objectmodel import get_uuid
+from datalab.utils.qthelpers import qt_try_loadsave_file, save_restore_stds
+
+if TYPE_CHECKING:
+ from datalab.gui.panel.base import BaseDataPanel
+ from datalab.gui.panel.history import HistoryPanel
+
+
+@dataclass
+class HistoryImportRegistry:
+ """Imported objects and their old-to-new UUID mappings."""
+
+ panel_map: dict[str, BaseDataPanel]
+ uuid_remap: dict[str, dict[str, str]]
+ imported_by_pstr: dict[str, list[Any]]
+
+
+def save_to_dlhist_file(panel: HistoryPanel, filename: str | None = None) -> bool:
+ """Save the History Panel content to a standalone ``.dlhist`` file.
+
+ Args:
+ filename: History filename. If None, a file dialog is opened.
+
+ Returns:
+ True if the history was saved, False if the operation was canceled.
+ """
+ if filename is None:
+ basedir = Conf.main.base_dir.get()
+ with save_restore_stds():
+ filename, _filt = getsavefilename(
+ panel, _("Save history file"), basedir, panel.FILE_FILTERS
+ )
+ if not filename:
+ return False
+ if osp.splitext(filename)[1] == "":
+ filename += ".dlhist"
+ with qt_try_loadsave_file(panel.parentWidget(), filename, "save"):
+ Conf.main.base_dir.set(filename)
+ with NativeH5Writer(filename) as writer:
+ # Make the .dlhist file panel-contained: store the signal and
+ # image panel objects (all of them) alongside the history, so
+ # that reopening restores both the data objects and the history
+ # that references them. Each section is read back by its own
+ # H5_PREFIX key, so the write order is not significant.
+ panel.mainwindow.signalpanel.serialize_to_hdf5(writer)
+ panel.mainwindow.imagepanel.serialize_to_hdf5(writer)
+ panel.serialize_to_hdf5(writer)
+ return True
+
+
+def open_dlhist_file(panel: HistoryPanel, filename: str | None = None) -> bool:
+ """Open a standalone ``.dlhist`` file into the History Panel.
+
+ Args:
+ filename: History filename. If None, a file dialog is opened.
+
+ Returns:
+ True if the history was loaded, False if the operation was canceled.
+ """
+ if filename is None:
+ basedir = Conf.main.base_dir.get()
+ with save_restore_stds():
+ filename, _filt = getopenfilename(
+ panel, _("Open history file"), basedir, panel.FILE_FILTERS
+ )
+ if not filename:
+ return False
+ with qt_try_loadsave_file(panel.parentWidget(), filename, "load"):
+ Conf.main.base_dir.set(filename)
+ with NativeH5Reader(filename) as reader:
+ # A panel-contained .dlhist file stores the signal and image
+ # panel objects in addition to the history sessions. The way
+ # they are restored depends on whether the workspace is already
+ # in use (data objects OR history): a pristine workspace is
+ # loaded directly while preserving UUIDs, otherwise the file
+ # is imported as new groups/sessions.
+ workspace_in_use = (
+ panel.mainwindow.signalpanel.objmodel.get_object_ids()
+ or panel.mainwindow.imagepanel.objmodel.get_object_ids()
+ or bool(panel.history_sessions)
+ )
+ if workspace_in_use:
+ # Workspace not empty: import the objects into new groups
+ # with fresh UUIDs and append the history as new sessions
+ # whose references are remapped to the imported objects.
+ panel.import_dlhist_into_new_session(reader)
+ else:
+ # Workspace empty: load directly, preserving original UUIDs
+ # (reset_all=True) so that history references stay valid.
+ panel.mainwindow.signalpanel.deserialize_from_hdf5(
+ reader, reset_all=True
+ )
+ panel.mainwindow.imagepanel.deserialize_from_hdf5(
+ reader, reset_all=True
+ )
+ panel.deserialize_from_hdf5(reader)
+ return True
+
+
+def create_import_registry(panel: HistoryPanel) -> HistoryImportRegistry:
+ """Create empty per-panel object and UUID registries."""
+ panel_map = {
+ "signal": panel.mainwindow.signalpanel,
+ "image": panel.mainwindow.imagepanel,
+ }
+ return HistoryImportRegistry(
+ panel_map=panel_map,
+ uuid_remap={panel_str: {} for panel_str in panel_map},
+ imported_by_pstr={panel_str: [] for panel_str in panel_map},
+ )
+
+
+def assign_imported_uuid(obj: Any) -> tuple[str, str]:
+ """Assign a fresh UUID to an imported object and return both UUIDs."""
+ old_uuid = get_uuid(obj)
+ new_uuid = str(uuid4())
+ try:
+ obj.set_metadata_option("uuid", new_uuid)
+ except AttributeError:
+ obj.uuid = new_uuid
+ return old_uuid, new_uuid
+
+
+def read_imported_group(
+ reader: NativeH5Reader,
+ data_panel: BaseDataPanel,
+ panel_str: str,
+ group_name: str,
+ registry: HistoryImportRegistry,
+) -> None:
+ """Read one object group and register its regenerated UUIDs."""
+ with reader.group(group_name):
+ group = data_panel.add_group("")
+ with reader.group("title"):
+ group.title = reader.read_str()
+ path = f"{data_panel.H5_PREFIX}/{group_name}"
+ for object_name in reader.h5.get(path, []):
+ obj = data_panel.deserialize_object_from_hdf5(
+ reader, object_name, reset_all=True
+ )
+ old_uuid, new_uuid = assign_imported_uuid(obj)
+ registry.uuid_remap[panel_str][old_uuid] = new_uuid
+ data_panel.add_object(obj, get_uuid(group), set_current=False)
+ registry.imported_by_pstr[panel_str].append(obj)
+ data_panel.selection_changed()
+
+
+def read_imported_objects(
+ reader: NativeH5Reader, registry: HistoryImportRegistry
+) -> None:
+ """Read signal and image payloads into fresh object groups."""
+ for panel_str, data_panel in registry.panel_map.items():
+ if data_panel.H5_PREFIX not in reader.h5:
+ continue
+ with reader.group(data_panel.H5_PREFIX):
+ for group_name in reader.h5.get(data_panel.H5_PREFIX, []):
+ read_imported_group(reader, data_panel, panel_str, group_name, registry)
+
+
+def remap_imported_object_sources(obj: Any, uuid_remap: dict[str, str]) -> None:
+ """Remap processing source UUIDs stored on one imported object."""
+ try:
+ parameters_dict = obj.get_metadata_option(PROCESSING_PARAMETERS_OPTION)
+ except (AttributeError, ValueError):
+ return
+ if not parameters_dict:
+ return
+ try:
+ parameters = ProcessingParameters.from_dict(parameters_dict)
+ except (TypeError, ValueError, AttributeError):
+ return
+ changed = False
+ if parameters.source_uuid is not None and parameters.source_uuid in uuid_remap:
+ parameters.source_uuid = uuid_remap[parameters.source_uuid]
+ changed = True
+ if parameters.source_uuids is not None:
+ new_sources = [uuid_remap.get(uuid, uuid) for uuid in parameters.source_uuids]
+ if new_sources != parameters.source_uuids:
+ parameters.source_uuids = new_sources
+ changed = True
+ if changed:
+ try:
+ obj.set_metadata_option(PROCESSING_PARAMETERS_OPTION, parameters.to_dict())
+ except (AttributeError, ValueError):
+ pass
+
+
+def remap_imported_sources(registry: HistoryImportRegistry) -> None:
+ """Remap processing sources for all imported objects."""
+ for panel_str, objects in registry.imported_by_pstr.items():
+ uuid_remap = registry.uuid_remap.get(panel_str, {})
+ if not uuid_remap:
+ continue
+ for obj in objects:
+ remap_imported_object_sources(obj, uuid_remap)
+
+
+def assemble_imported_sessions(
+ panel: HistoryPanel,
+ reader: NativeH5Reader,
+ registry: HistoryImportRegistry,
+) -> list[HistorySession] | None:
+ """Read history payload and clone sessions with remapped UUIDs."""
+ if panel.H5_PREFIX not in reader.h5:
+ return None
+ sessions = reader.read_object_list(panel.H5_PREFIX, HistorySession) or []
+ imported_suffix = _("Imported")
+ new_sessions: list[HistorySession] = []
+ for session in sessions:
+ panel.navigation.session_increment += 1
+ title = f"{session.title} {imported_suffix}"
+ new_session = session.copy_with_uuid_remap(
+ title=title, uuid_remap=registry.uuid_remap
+ )
+ new_session.number = panel.navigation.session_increment
+ new_sessions.append(new_session)
+ return new_sessions
+
+
+def register_imported_outputs(
+ panel: HistoryPanel, sessions: list[HistorySession]
+) -> None:
+ """Register action output mappings for imported sessions."""
+ for session in sessions:
+ for action in session.actions:
+ if action.output_uuids:
+ panel.runtime.objects.register_action_outputs(
+ action, action.output_uuids
+ )
+
+
+def update_imported_history_ui(
+ panel: HistoryPanel, sessions: list[HistorySession]
+) -> None:
+ """Append imported sessions and refresh history presentation."""
+ panel.history_sessions.extend(sessions)
+ panel.tree.populate_tree(panel.history_sessions)
+ panel.refresh_compatibility_items()
+ panel.ui.update_actions_state()
+
+
+def import_dlhist_into_new_session(panel: HistoryPanel, reader: NativeH5Reader) -> None:
+ """Import a ``.dlhist`` file into new groups and new history sessions.
+
+ Args:
+ reader: HDF5 reader positioned on a ``.dlhist`` file.
+ """
+ registry = create_import_registry(panel)
+ read_imported_objects(reader, registry)
+ remap_imported_sources(registry)
+ sessions = assemble_imported_sessions(panel, reader, registry)
+ if sessions is None:
+ return
+ register_imported_outputs(panel, sessions)
+ update_imported_history_ui(panel, sessions)
+
+
+def refresh_compatibility_items(panel: HistoryPanel, *args: Any) -> None:
+ """Refresh action item compatibility markers in the tree."""
+ del args
+ panel.tree.update_compatibility_states(panel.history_sessions, panel.mainwindow)
+
+
+def serialize_to_hdf5(panel: HistoryPanel, writer: NativeH5Writer) -> None:
+ """Serialize whole panel to a HDF5 file
+
+ Args:
+ writer: HDF5 writer
+ """
+ writer.write_object_list(panel.history_sessions, panel.H5_PREFIX)
+
+
+def deserialize_from_hdf5(
+ panel: HistoryPanel, reader: NativeH5Reader, reset_all: bool = False
+) -> None:
+ """Deserialize whole panel from a HDF5 file
+
+ Args:
+ reader: HDF5 reader
+ reset_all: Unused (kept for compatibility with panel API)
+ """
+ del reset_all # required by the polymorphic panel API; unused here
+ panel.runtime.objects.clear_output_mappings()
+ if panel.H5_PREFIX not in reader.h5:
+ panel.history_sessions = []
+ panel.navigation.session_increment = 0
+ panel.tree.populate_tree(panel.history_sessions)
+ panel.ui.update_actions_state()
+ return
+ panel.history_sessions = (
+ reader.read_object_list(panel.H5_PREFIX, HistorySession) or []
+ )
+ if panel.history_sessions:
+ panel.navigation.session_increment = panel.history_sessions[-1].number
+ # Rebuild the bijective mapping from the loaded actions. Legacy
+ # (v1) actions have empty ``output_uuids`` and contribute nothing
+ # to the index — the heuristic fallback handles them.
+ for session in panel.history_sessions:
+ for action in session.actions:
+ if action.output_uuids:
+ panel.runtime.objects.register_action_outputs(
+ action, action.output_uuids
+ )
+ panel.tree.populate_tree(panel.history_sessions)
+ panel.refresh_compatibility_items()
+ panel.ui.update_actions_state()
diff --git a/datalab/h5/native.py b/datalab/h5/native.py
index 84419e4e8..5004c5774 100644
--- a/datalab/h5/native.py
+++ b/datalab/h5/native.py
@@ -8,11 +8,18 @@
from __future__ import annotations
+import importlib
+from typing import Any, Callable
+
from guidata.io import HDF5Reader, HDF5Writer
+from guidata.io.h5fmt import NoDefault
import datalab
DATALAB_VERSION_NAME = "DataLab_Version"
+DATALAB_PACKAGE_NAME = "datalab"
+
+H5_CALLABLE_PREFIX = "#callable#"
class NativeH5Writer(HDF5Writer):
@@ -26,6 +33,45 @@ def __init__(self, filename: str) -> None:
super().__init__(filename)
self.h5[DATALAB_VERSION_NAME] = datalab.__version__
+ @staticmethod
+ def serialize_func_or_class(obj: Callable | type) -> str:
+ """Serialize a function or a class object
+
+ Args:
+ obj: Object to serialize
+
+ Returns:
+ str: Serialized object
+ """
+ if not obj.__module__.startswith(DATALAB_PACKAGE_NAME):
+ raise ValueError(
+ f"Only {DATALAB_PACKAGE_NAME} functions and classes can be serialized"
+ )
+ val = f"{H5_CALLABLE_PREFIX}{obj.__module__}."
+ if isinstance(obj, type):
+ return val + obj.__name__
+ return val + obj.__qualname__
+
+ # Reimplement the write method to handle callable objects
+ def write(self, val: Any, group_name: str | None = None) -> None:
+ """
+ Write a value depending on its type, optionally within a named group.
+
+ Args:
+ val: The value to be written.
+ group_name: The name of the group. If provided, the group
+ context will be used for writing the value.
+ """
+ try:
+ super().write(val, group_name)
+ except NotImplementedError:
+ if callable(val):
+ super().write_str(self.serialize_func_or_class(val))
+ if group_name:
+ self.end(group_name)
+ else:
+ raise
+
class NativeH5Reader(HDF5Reader):
"""DataLab signal/image objects HDF5 guidata dataset Writer class
@@ -37,3 +83,63 @@ class NativeH5Reader(HDF5Reader):
def __init__(self, filename: str) -> None:
super().__init__(filename)
self.version = self.h5[DATALAB_VERSION_NAME]
+
+ @staticmethod
+ def deserialize_func_or_class(obj: str) -> Callable | type:
+ """Deserialize a function or a class object
+
+ Args:
+ obj: Serialized object
+
+ Returns:
+ Callable | type: Deserialized object
+ """
+ parts = obj[len(H5_CALLABLE_PREFIX) :].split(".")
+ if not parts or not parts[0].startswith(DATALAB_PACKAGE_NAME):
+ raise ValueError(
+ f"Only {DATALAB_PACKAGE_NAME} functions and classes can be deserialized"
+ )
+ # Walk path parts: find longest valid module prefix, then resolve the
+ # remaining attribute chain (supports methods like ``Class.method``).
+ for split_index in range(len(parts) - 1, 0, -1):
+ module_name = ".".join(parts[:split_index])
+ try:
+ module = importlib.import_module(module_name)
+ except ImportError:
+ continue
+ attr: Any = module
+ try:
+ for name in parts[split_index:]:
+ attr = getattr(attr, name)
+ except AttributeError:
+ continue
+ return attr
+ raise ImportError(f"Cannot deserialize callable: {obj}")
+
+ # Reimplement the read method to handle callable objects
+ def read(
+ self,
+ group_name: str | None = None,
+ func: Callable[[], Any] | None = None,
+ instance: Any | None = None,
+ default: Any | NoDefault = NoDefault,
+ ) -> Any:
+ """
+ Read a value from the current group or specified group_name.
+
+ Args:
+ group_name: The name of the group to read from. Defaults to None.
+ func: The function to use for reading the value. Defaults to None.
+ instance: An object that implements the DataSet-like `deserialize` method.
+ Defaults to None.
+ default: The default value to return if the value is not found.
+ Defaults to `NoDefault` (no default value: raises an exception if the
+ value is not found).
+
+ Returns:
+ The read value.
+ """
+ val = super().read(group_name, func=func, instance=instance, default=default)
+ if isinstance(val, str) and val.startswith(H5_CALLABLE_PREFIX):
+ return self.deserialize_func_or_class(val)
+ return val
diff --git a/datalab/history/__init__.py b/datalab/history/__init__.py
new file mode 100644
index 000000000..35dc65ae7
--- /dev/null
+++ b/datalab/history/__init__.py
@@ -0,0 +1,21 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""DataLab history model package (pure data model, no Qt widgets)."""
+
+from datalab.history.action import HistoryAction
+from datalab.history.core import (
+ HISTORY_ACTION_SCHEMA_VERSION,
+ HISTORY_SCHEMA_VERSION,
+ get_datetime_str,
+)
+from datalab.history.session import HistorySession
+from datalab.history.workspace_state import WorkspaceState
+
+__all__ = [
+ "HISTORY_ACTION_SCHEMA_VERSION",
+ "HISTORY_SCHEMA_VERSION",
+ "HistoryAction",
+ "HistorySession",
+ "WorkspaceState",
+ "get_datetime_str",
+]
diff --git a/datalab/history/action.py b/datalab/history/action.py
new file mode 100644
index 000000000..5c9f05c4b
--- /dev/null
+++ b/datalab/history/action.py
@@ -0,0 +1,815 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""HistoryAction model: serialisable description of one recorded operation."""
+
+from __future__ import annotations
+
+import html
+import inspect
+import json
+import logging
+import os
+from contextlib import nullcontext
+from dataclasses import dataclass
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ Callable,
+ Dict,
+ Generator,
+ Generic,
+ Optional,
+ TypeVar,
+ overload,
+)
+from uuid import uuid4
+
+import sigima.proc.image
+import sigima.proc.signal
+from guidata.dataset.datatypes import DataSet
+
+from datalab.config import _
+from datalab.gui import ObjItf
+from datalab.history.core import (
+ HISTORY_ACTION_SCHEMA_VERSION,
+ HISTORY_SCHEMA_VERSION,
+ copy_history_value,
+ decode_kwargs,
+ encode_kwargs,
+ get_datetime_str,
+)
+from datalab.history.workspace_state import WorkspaceState
+from datalab.objectmodel import get_uuid
+
+if TYPE_CHECKING:
+ from datalab.gui.main import DLMainWindow
+ from datalab.h5.native import NativeH5Reader, NativeH5Writer
+
+_logger = logging.getLogger(__name__)
+
+T = TypeVar("T")
+
+
+class DescriptorField(Generic[T]):
+ """Typed descriptor forwarding access to an action descriptor field."""
+
+ def __init__(self, name: str) -> None:
+ self.name = name
+
+ @overload
+ def __get__(
+ self, instance: None, owner: type[HistoryAction]
+ ) -> DescriptorField[T]: ...
+
+ @overload
+ def __get__(self, instance: HistoryAction, owner: type[HistoryAction]) -> T: ...
+
+ def __get__(
+ self, instance: HistoryAction | None, owner: type[HistoryAction]
+ ) -> DescriptorField[T] | T:
+ if instance is None:
+ return self
+ return getattr(instance.descriptors, self.name)
+
+ def __set__(self, instance: HistoryAction, value: T) -> None:
+ setattr(instance.descriptors, self.name, value)
+
+
+class HistoryAction(ObjItf):
+ """Object representing an action in the history panel.
+
+ An action is a serialisable description of either a *compute* call (resolved
+ via the panel processor's feature registry) or a *UI* call (resolved as a
+ method on a known target -- ``mainwindow``/``signalpanel``/``imagepanel``).
+
+ No Python ``Callable`` is ever pickled: a compute action is identified by
+ ``(panel_str, func_name, pattern)`` and a UI action by ``(target,
+ method_name)``. ``DataSet`` payloads inside ``kwargs`` are serialised with
+ :func:`guidata.dataset.conv.dataset_to_json`.
+ """
+
+ KIND_COMPUTE = "compute"
+ KIND_UI = "ui"
+
+ FUNC_EDIT_MODE = "edit" # Name of the function parameter to enable edit mode
+ # Methods that create new data objects. During non-persistent (output-suppressed)
+ # replay, these UI actions are skipped so the panel object count stays stable.
+ UI_CREATION_METHODS: frozenset[str] = frozenset({"new_object"})
+ # UI methods that destroy data objects. Replaying these requires that the
+ # captured selection still resolves to existing objects (see ``replay_ui``).
+ DESTRUCTIVE_METHODS: frozenset[str] = frozenset(
+ {"remove_object", "remove_group", "delete_all_objects"}
+ )
+
+ @dataclass
+ class Descriptors:
+ """Operation descriptors persisted by a history action."""
+
+ kind: str
+ panel_str: str | None = None
+ func_name: str | None = None
+ pattern: str | None = None
+ target: str | None = None
+ method_name: str | None = None
+ plugin_origin: dict[str, Any] | None = None
+
+ kind = DescriptorField[str]("kind")
+ panel_str = DescriptorField[Optional[str]]("panel_str")
+ func_name = DescriptorField[Optional[str]]("func_name")
+ pattern = DescriptorField[Optional[str]]("pattern")
+ target = DescriptorField[Optional[str]]("target")
+ method_name = DescriptorField[Optional[str]]("method_name")
+ plugin_origin = DescriptorField[Optional[Dict[str, Any]]]("plugin_origin")
+
+ def __init__(
+ self,
+ title: str = "",
+ kind: str = KIND_UI,
+ # --- compute-only --------------------------------------------------
+ panel_str: str | None = None,
+ func_name: str | None = None,
+ pattern: str | None = None,
+ # --- ui-only -------------------------------------------------------
+ target: str | None = None,
+ method_name: str | None = None,
+ # --- common --------------------------------------------------------
+ kwargs: dict[str, Any] | None = None,
+ state: WorkspaceState | None = None,
+ ) -> None:
+ super().__init__()
+ self.__title = title or ""
+ self.descriptors = self.Descriptors(
+ kind=kind,
+ panel_str=panel_str,
+ func_name=func_name,
+ pattern=pattern,
+ target=target,
+ method_name=method_name,
+ )
+ # Common:
+ self.kwargs: dict[str, Any] = (
+ {} if kwargs is None else {k: v for k, v in kwargs.items() if v is not None}
+ )
+ self.state = WorkspaceState() if state is None else state
+ self.dtstr: str = get_datetime_str()
+ self.uuid: str = str(uuid4())
+ self.schema_version: int = HISTORY_ACTION_SCHEMA_VERSION
+ # UUIDs of the data objects produced by this action (bijective mapping
+ # maintained by :class:`HistoryPanel`). Populated post-compute via
+ # :meth:`HistoryPanel.register_action_outputs`. Empty for ``1_to_0``
+ # patterns, for UI actions, and for sessions loaded without output info
+ # loaded from disk (the heuristic fallback then takes over).
+ self.output_uuids: list[str] = []
+ # Plugin origin descriptor for compute actions (None for built-in
+ # Sigima/DataLab features). Populated at registration time by
+ # :meth:`BaseProcessor.add_feature` and propagated through
+ # ``add_compute_entry_from_pp``. See
+ # :func:`datalab.gui.processor.base._detect_plugin_origin` for shape.
+ # Persisted as a JSON string in HDF5.
+ # Transient flag (NOT serialized): set during a cascade recompute to
+ # display a "stale" visual marker in the tree. Cleared once the
+ # action has been recomputed.
+ self.is_stale: bool = False
+ # Snapshot of original kwargs before edit-mode modification.
+ # Set lazily when the first edit-mode change touches this action.
+ # Persisted to HDF5 so that the Restore
+ # action still works after a save/reload cycle while Edit mode is
+ # active. Cleared by ``discard_snapshot`` (definitive commit when
+ # toggling Edit mode off) or ``restore_kwargs`` (Restore button).
+ self.saved_kwargs: dict[str, Any] | None = None
+
+ def snapshot_kwargs(self) -> None:
+ """Save a copy of the current kwargs as the pre-edit baseline.
+
+ No-op if a snapshot already exists (preserves the original baseline
+ across multiple edit-mode replays).
+ """
+ if self.saved_kwargs is None:
+ self.saved_kwargs = {
+ key: copy_history_value(value) for key, value in self.kwargs.items()
+ }
+
+ def restore_kwargs(self) -> None:
+ """Restore kwargs from the saved snapshot and clear the snapshot."""
+ if self.saved_kwargs is not None:
+ self.kwargs = self.saved_kwargs
+ self.saved_kwargs = None
+
+ def discard_snapshot(self) -> None:
+ """Discard the saved snapshot (accept current kwargs as definitive)."""
+ self.saved_kwargs = None
+
+ @property
+ def has_pending_edits(self) -> bool:
+ """Return True if this action has unsaved edit-mode changes."""
+ return self.saved_kwargs is not None
+
+ def copy(self, title_suffix: str | None = None) -> HistoryAction:
+ """Return an independent copy of this history action."""
+ state = self.state.copy()
+ title = self.title
+ if title_suffix:
+ title = f"{title} {title_suffix}"
+ new_action = HistoryAction(
+ title=title,
+ kind=self.kind,
+ panel_str=self.panel_str,
+ func_name=self.func_name,
+ pattern=self.pattern,
+ target=self.target,
+ method_name=self.method_name,
+ kwargs={
+ key: copy_history_value(value) for key, value in self.kwargs.items()
+ },
+ state=state,
+ )
+ new_action.plugin_origin = copy_history_value(self.plugin_origin)
+ new_action.output_uuids = list(self.output_uuids)
+ # Note: saved_kwargs is intentionally NOT propagated to the copy.
+ # Copying an action acts as an implicit commit (no pending edits).
+ return new_action
+
+ def effective_panel_str(self) -> str:
+ """Return the panel this action operates on ("signal"/"image").
+
+ Falls back to the UI ``target`` when ``panel_str`` is unset (the case for
+ creation actions such as ``new_object``, whose ``panel_str`` is ``None``
+ but whose ``target`` identifies the panel).
+ """
+ if self.panel_str:
+ return self.panel_str
+ return {"imagepanel": "image", "signalpanel": "signal"}.get(self.target, "")
+
+ def copy_with_uuid_remap(
+ self, uuid_remap: dict[str, dict[str, str]]
+ ) -> HistoryAction:
+ """Return a copy of this action with all captured UUIDs rewritten.
+
+ Args:
+ uuid_remap: Per-panel mapping ``{panel_str: {old_uuid: new_uuid}}``
+ used to translate captured UUIDs to the cloned objects created by
+ the Duplicate operation.
+
+ Returns:
+ A new independent :class:`HistoryAction` with remapped UUIDs.
+ """
+ new_action = self.copy()
+ # Rewrite state.selection
+ for pstr, uuids in new_action.state.selection.items():
+ pmap = uuid_remap.get(pstr, {})
+ new_action.state.selection[pstr] = [pmap.get(u, u) for u in uuids]
+ # Rewrite state.object_metadata keys
+ for pstr, metadata in new_action.state.object_metadata.items():
+ pmap = uuid_remap.get(pstr, {})
+ new_action.state.object_metadata[pstr] = {
+ pmap.get(uuid, uuid): val for uuid, val in metadata.items()
+ }
+ # Rewrite obj2_uuids in kwargs
+ obj2 = new_action.kwargs.get("obj2_uuids")
+ if obj2:
+ if isinstance(obj2, str):
+ obj2 = [obj2]
+ pstr = new_action.effective_panel_str()
+ pmap = uuid_remap.get(pstr, {})
+ rewritten = [pmap.get(u, u) for u in obj2]
+ new_action.kwargs["obj2_uuids"] = (
+ rewritten[0] if len(rewritten) == 1 else rewritten
+ )
+ # Rewrite output_uuids — they reference the target panel.
+ if new_action.output_uuids:
+ pstr = new_action.effective_panel_str()
+ pmap = uuid_remap.get(pstr, {})
+ new_action.output_uuids = [pmap.get(u, u) for u in new_action.output_uuids]
+ return new_action
+
+ @property
+ def title(self) -> str:
+ """Return object title"""
+ return self.__title
+
+ # ------------------------------------------------------------------
+ # Description rendering (used by the tree view)
+ # ------------------------------------------------------------------
+
+ def __iter_param_kwargs(self) -> Generator[Any, None, None]:
+ """Yield kwargs values whose name ends with ``param`` (typically DataSets)."""
+ for kwname, value in self.kwargs.items():
+ if kwname.endswith("param") and value is not None:
+ yield value
+
+ @property
+ def description(self) -> str:
+ """Return object description (string representing function parameters)"""
+ desc = ""
+ no_parameters = True
+ for param in self.__iter_param_kwargs():
+ if desc:
+ desc += os.linesep
+ desc += str(param)
+ no_parameters = False
+ if desc or no_parameters:
+ if desc:
+ return desc
+ # Fall back to a textual hint of the resolved callable
+ return self.__fallback_doc()
+
+ def __fallback_doc(self) -> str:
+ """Return a single-line docstring for the underlying call, if available."""
+ try:
+ func = self.resolve_callable()
+ except (
+ ImportError,
+ ModuleNotFoundError,
+ AttributeError,
+ TypeError,
+ ValueError,
+ ):
+ return ""
+ doc = getattr(func, "__doc__", None) or ""
+ return doc.splitlines()[0] if doc else ""
+
+ @property
+ def description_summary(self) -> str:
+ """Return a short, single-line summary of the description (collapsed view).
+
+ For DataSet parameters, uses the dataset title followed by a compact
+ representation of its public fields ("name=value, ..."). Falls back to
+ the first non-empty line of the full description when no DataSet is
+ present.
+ """
+ summaries: list[str] = []
+ for param in self.__iter_param_kwargs():
+ if isinstance(param, DataSet):
+ title = param.get_title() or ""
+ # Collect "name=value" for each non-private item of the DataSet.
+ pairs: list[str] = []
+ for item in param.get_items():
+ name = item.get_name()
+ if name.startswith("_"):
+ continue
+ try:
+ value = item.get_value(param)
+ except (AttributeError, KeyError, TypeError, ValueError):
+ continue
+ # Format floats compactly, leave other reprs as-is
+ if isinstance(value, float):
+ value_str = f"{value:g}"
+ else:
+ value_str = str(value)
+ pairs.append(f"{name}={value_str}")
+ if pairs:
+ summaries.append(
+ f"{title}: {', '.join(pairs)}" if title else ", ".join(pairs)
+ )
+ elif title:
+ summaries.append(title)
+ if summaries:
+ return " | ".join(summaries)
+ for line in self.description.splitlines():
+ stripped = line.strip()
+ if stripped:
+ return stripped
+ return ""
+
+ @property
+ def description_html(self) -> str:
+ """Return rich-text (HTML) description used for the expanded view."""
+ # Normal path
+ parts: list[str] = []
+ no_parameters = True
+ for param in self.__iter_param_kwargs():
+ no_parameters = False
+ if isinstance(param, DataSet):
+ parts.append(param.to_html())
+ else:
+ parts.append(html.escape(str(param)).replace("\n", " "))
+ if parts:
+ return "
".join(parts)
+ if no_parameters:
+ text = self.description
+ if not text:
+ return ""
+ return html.escape(text).replace("\n", " ")
+ return ""
+
+ # ------------------------------------------------------------------
+ # Workspace-state delegation
+ # ------------------------------------------------------------------
+
+ def is_current_state_compatible(
+ self, mainwindow: DLMainWindow, restore_selection: bool
+ ) -> bool:
+ """Check if the current workspace state is compatible with the saved state."""
+ return self.state.is_current_state_compatible(mainwindow, restore_selection)
+
+ def restore(self, mainwindow: DLMainWindow) -> None:
+ """Restore the associated workspace state."""
+ self.state.restore(mainwindow)
+
+ # ------------------------------------------------------------------
+ # Replay
+ # ------------------------------------------------------------------
+
+ def resolve_target(self, mainwindow: DLMainWindow) -> Any:
+ """Resolve the target object (UI kind) from the mainwindow."""
+ attr = self.target or "mainwindow"
+ if attr == "mainwindow":
+ return mainwindow
+ if attr == "signalprocessor":
+ return mainwindow.signalpanel.processor
+ if attr == "imageprocessor":
+ return mainwindow.imagepanel.processor
+ return getattr(mainwindow, attr)
+
+ def resolve_panel(self, mainwindow: DLMainWindow):
+ """Resolve the data panel for a compute action."""
+ if self.panel_str == "signal":
+ return mainwindow.signalpanel
+ if self.panel_str == "image":
+ return mainwindow.imagepanel
+ raise ValueError(
+ f"Unknown panel_str {self.panel_str!r} for compute history action"
+ )
+
+ def resolve_callable(self) -> Callable | None:
+ """Best-effort lookup of the underlying callable, for description only."""
+ if self.kind == self.KIND_COMPUTE and self.func_name:
+ for module in (sigima.proc.signal, sigima.proc.image):
+ func = getattr(module, self.func_name, None)
+ if callable(func):
+ return func
+ return None
+
+ def resolve_obj_by_uuid(self, mainwindow: DLMainWindow, uuid: str) -> Any | None:
+ """Look up an object by UUID across both data panels."""
+ for panel in (mainwindow.signalpanel, mainwindow.imagepanel):
+ try:
+ return panel.objmodel[uuid]
+ except KeyError:
+ continue
+ return None
+
+ def replay(
+ self,
+ mainwindow: DLMainWindow,
+ restore_selection: bool,
+ edit: bool,
+ uuid_remap: dict[str, dict[str, str]] | None = None,
+ ) -> None:
+ """Replay the action.
+
+ Args:
+ mainwindow: DataLab's main window
+ restore_selection: True to restore the workspace selection before replaying
+ a UI-kind action. Ignored for compute-kind actions: their semantics
+ depends on which objects are selected (e.g. ``n_to_1`` aggregators
+ such as ``average`` require their captured multi-object selection),
+ so the captured selection is always restored before running the
+ computation.
+ edit: if True, always open the dialog boxes to edit parameters; if False,
+ use the parameters captured when the action was recorded
+ uuid_remap: optional per-panel mapping ``{panel_str: {old_uuid: new_uuid}}``
+ used during full-session replay to translate captured UUIDs to the
+ freshly-created ones. Defaults to an empty (identity) mapping.
+ """
+ if uuid_remap is None:
+ uuid_remap = {}
+ # Suppress history capture during replay to avoid recording
+ # synthetic entries when the processor re-executes features.
+ # The context manager is reentrant, so nesting with
+ # HistoryPanel.replay_restore_actions() is safe.
+ hpanel = getattr(mainwindow, "historypanel", None)
+ if hpanel is not None:
+ ctx = hpanel.replaying()
+ else:
+ ctx = nullcontext()
+ with ctx:
+ self.replay_inner(mainwindow, restore_selection, edit, uuid_remap)
+
+ def replay_inner(
+ self,
+ mainwindow: DLMainWindow,
+ restore_selection: bool,
+ edit: bool,
+ uuid_remap: dict[str, dict[str, str]],
+ ) -> None:
+ """Inner replay logic, always called under the replaying guard."""
+ if self.kind == self.KIND_COMPUTE and self.pattern == "1_to_0" and not edit:
+ return
+ if self.kind == self.KIND_COMPUTE:
+ # Compute actions are selection-driven: restore the captured
+ # selection (translated through ``uuid_remap`` for session
+ # replays) whenever it is still resolvable so chained replays
+ # (especially ``n_to_1`` / ``2_to_1`` / ``1_to_n`` patterns)
+ # operate on the original input objects rather than on whatever
+ # the previous action left selected. When the captured UUIDs no
+ # longer exist (e.g. heuristic remap missed an object), fall
+ # back to the current selection -- replay may still fail
+ # downstream, but with the native processor error rather than
+ # an opaque ``WorkspaceState`` incompatibility.
+ translated = self.translate_state(uuid_remap)
+ if translated.is_current_state_compatible(mainwindow, False):
+ translated.restore(mainwindow)
+ self.replay_compute(mainwindow, edit, uuid_remap)
+ else:
+ if restore_selection:
+ self.state.restore(mainwindow)
+ self.replay_ui(mainwindow, edit, uuid_remap)
+
+ def translate_state(self, uuid_remap: dict[str, dict[str, str]]) -> WorkspaceState:
+ """Return a copy of ``self.state`` whose captured UUIDs have been
+ translated through ``uuid_remap`` (identity when no mapping)."""
+ if not uuid_remap:
+ return self.state
+ translated = WorkspaceState()
+ for panel_str, uuids in self.state.selection.items():
+ panel_map = uuid_remap.get(panel_str, {})
+ translated.selection[panel_str] = [panel_map.get(u, u) for u in uuids]
+ translated.states = dict(self.state.states)
+ translated.titles = dict(self.state.titles)
+ for panel_str, metadata in self.state.object_metadata.items():
+ panel_map = uuid_remap.get(panel_str, {})
+ translated.object_metadata[panel_str] = {
+ panel_map.get(uuid, uuid): dict(signature)
+ for uuid, signature in metadata.items()
+ }
+ return translated
+
+ def replay_compute(
+ self,
+ mainwindow: DLMainWindow,
+ edit: bool,
+ uuid_remap: dict[str, dict[str, str]] | None = None,
+ ) -> None:
+ """Replay a compute-kind action via ``processor.run_feature``."""
+ if self.pattern == "multiple_1_to_1":
+ raise NotImplementedError(
+ _("Replaying compound 'multiple_1_to_1' actions is not supported yet.")
+ )
+ panel = self.resolve_panel(mainwindow)
+ processor = panel.processor
+ param = self.kwargs.get("param")
+ params = self.kwargs.get("params") or []
+ paramclass_name = type(param).__name__ if param is not None else None
+ if self.pattern == "1_to_n" and params:
+ paramclass_name = type(params[0]).__name__
+ feature = processor.get_feature(
+ self.func_name,
+ plugin_origin=self.plugin_origin,
+ paramclass_name=paramclass_name,
+ )
+ run_kwargs: dict[str, Any] = {self.FUNC_EDIT_MODE: edit}
+
+ if self.pattern in {"1_to_1", "1_to_0", "n_to_1"}:
+ if param is not None:
+ run_kwargs["param"] = param
+ if self.pattern == "n_to_1" and "pairwise" in self.kwargs:
+ run_kwargs["pairwise"] = self.kwargs["pairwise"]
+ elif self.pattern == "2_to_1":
+ uuids = self.kwargs.get("obj2_uuids") or []
+ if isinstance(uuids, str):
+ uuids = [uuids]
+ # Translate captured UUIDs through ``uuid_remap`` (session replay).
+ # ``uuid_remap`` keys are ``panel.PANEL_STR_ID`` (matches
+ # ``WorkspaceState.selection`` keys and
+ # ``HistoryAction.panel_str``).
+ panel_map = (uuid_remap or {}).get(panel.PANEL_STR_ID, {})
+ uuids = [panel_map.get(u, u) for u in uuids]
+ objs2 = [
+ obj
+ for obj in (self.resolve_obj_by_uuid(mainwindow, u) for u in uuids)
+ if obj is not None
+ ]
+ if not objs2:
+ raise ValueError(
+ _("Cannot replay 2-to-1 action: source object(s) missing.")
+ )
+ run_kwargs["obj2"] = objs2[0] if len(objs2) == 1 else objs2
+ if param is not None:
+ run_kwargs["param"] = param
+ if "pairwise" in self.kwargs:
+ run_kwargs["pairwise"] = self.kwargs["pairwise"]
+ elif self.pattern == "1_to_n":
+ run_kwargs["params"] = params
+ else:
+ raise ValueError(f"Unknown compute pattern: {self.pattern!r}")
+ processor.run_feature(feature, **run_kwargs)
+
+ def replay_ui(
+ self,
+ mainwindow: DLMainWindow,
+ edit: bool,
+ uuid_remap: dict[str, dict[str, str]] | None = None,
+ ) -> None:
+ """Replay a UI-kind action by calling ``target.method_name(**kwargs)``."""
+ hpanel = mainwindow.historypanel
+ if (
+ hpanel is not None
+ and hpanel.is_output_suppressed()
+ and self.method_name in self.UI_CREATION_METHODS
+ ):
+ return # Skip creation UI during non-persistent replay
+ target = self.resolve_target(mainwindow)
+ # Safety guard for destructive UI actions: if the action would delete
+ # objects but the captured selection no longer resolves to existing
+ # UUIDs in the target panel, skip the call rather than delete whatever
+ # is currently selected (which would silently destroy unrelated data).
+ if self.method_name in self.DESTRUCTIVE_METHODS:
+ if target is None:
+ _logger.warning(
+ "Skipping destructive replay '%s': target '%s' not found",
+ self.method_name,
+ self.target,
+ )
+ return
+ panel_str = getattr(target, "PANEL_STR_ID", None)
+ if panel_str and self.state and self.state.selection.get(panel_str):
+ existing_uuids = {
+ get_uuid(o)
+ for o in getattr(target, "objmodel", [])
+ if o is not None
+ }
+ captured = set(self.state.selection.get(panel_str, []))
+ if not captured & existing_uuids:
+ _logger.warning(
+ "Skipping destructive replay '%s': none of the captured "
+ "UUIDs %s exist in panel '%s' anymore",
+ self.method_name,
+ list(captured),
+ panel_str,
+ )
+ return
+ method = getattr(target, self.method_name)
+ call_kwargs = dict(self.kwargs)
+ # Translate a recorded source object UUID through the session uuid_remap
+ # so deterministic replay after save/reload still targets the right object.
+ if uuid_remap and self.panel_str and "source_uuid" in call_kwargs:
+ panel_map = uuid_remap.get(self.panel_str, {})
+ old = call_kwargs["source_uuid"]
+ call_kwargs["source_uuid"] = panel_map.get(old, old)
+ # Inject edit mode if the method supports it
+ try:
+ sig = inspect.signature(method)
+ if self.FUNC_EDIT_MODE in sig.parameters:
+ call_kwargs[self.FUNC_EDIT_MODE] = edit
+ except (TypeError, ValueError):
+ pass
+ method(**call_kwargs)
+
+ # ------------------------------------------------------------------
+ # Serialisation -- no Callable is ever pickled
+ # ------------------------------------------------------------------
+
+ def serialize(self, writer: NativeH5Writer) -> None:
+ """Serialize this action."""
+ with writer.group("schema_version"):
+ writer.write(self.schema_version)
+ with writer.group("kind"):
+ writer.write(self.kind)
+ with writer.group("title"):
+ writer.write(self.__title)
+ with writer.group("uuid"):
+ writer.write(self.uuid)
+ if self.panel_str is not None:
+ with writer.group("panel_str"):
+ writer.write(self.panel_str)
+ if self.func_name is not None:
+ with writer.group("func_name"):
+ writer.write(self.func_name)
+ if self.pattern is not None:
+ with writer.group("pattern"):
+ writer.write(self.pattern)
+ if self.target is not None:
+ with writer.group("target"):
+ writer.write(self.target)
+ if self.method_name is not None:
+ with writer.group("method_name"):
+ writer.write(self.method_name)
+ encoded = encode_kwargs(self.kwargs)
+ if encoded:
+ with writer.group("kwargs"):
+ writer.write_dict(encoded)
+ # ``saved_kwargs``: persisted Edit mode snapshot so the Restore button
+ # keeps working after save/reload. Group omitted when there are no
+ # pending edits.
+ if self.saved_kwargs is not None:
+ encoded_saved = encode_kwargs(self.saved_kwargs)
+ # Write the group unconditionally (even when empty) so that the
+ # round-trip preserves the distinction between None (no pending
+ # edits) and {} (degenerate empty snapshot, keeps has_pending_edits).
+ with writer.group("saved_kwargs"):
+ writer.write_dict(encoded_saved)
+ # Only emit ``output_uuids`` when non-empty (empty lists skipped to
+ # avoid h5py edge cases with empty arrays).
+ if self.output_uuids:
+ with writer.group("output_uuids"):
+ writer.write(list(self.output_uuids))
+ # ``plugin_origin``: stored as a JSON string so the HDF5 schema stays
+ # trivially round-trippable. Skipped when None.
+ if self.plugin_origin is not None:
+ with writer.group("plugin_origin"):
+ writer.write(json.dumps(self.plugin_origin))
+ with writer.group("state"):
+ self.state.serialize(writer)
+ with writer.group("dtstr"):
+ writer.write(self.dtstr)
+
+ def deserialize(self, reader: NativeH5Reader) -> None:
+ """Deserialize this action."""
+ self.schema_version = reader.read(
+ "schema_version", default=HISTORY_SCHEMA_VERSION
+ )
+ with reader.group("kind"):
+ self.kind = reader.read_any()
+ with reader.group("title"):
+ self.__title = reader.read_any()
+ # Optional descriptors are written conditionally; check existence in
+ # the underlying HDF5 group before reading to avoid leaking ``__seq``
+ # frames on the option stack via guidata's read_any fallback path.
+ current = reader.h5
+ for option in reader.option:
+ current = current.require_group(option)
+ deserialize_descriptors(self, reader, current)
+ deserialize_kwargs_snapshot(self, reader, current)
+ deserialize_outputs_plugin_origin(self, reader, current)
+ with reader.group("state"):
+ self.state.deserialize(reader)
+ with reader.group("dtstr"):
+ self.dtstr = reader.read_any()
+
+
+def deserialize_descriptors(
+ action: HistoryAction, reader: NativeH5Reader, current: Any
+) -> None:
+ """Deserialize optional identity and operation descriptors."""
+ # ``uuid`` is present only in files written after UUID persistence was
+ # added; keep the freshly generated ``action.uuid`` for older files.
+ if "uuid" in current.attrs or "uuid" in current:
+ with reader.group("uuid"):
+ loaded_uuid = reader.read_any()
+ if loaded_uuid:
+ action.uuid = str(loaded_uuid)
+ for attr in ("panel_str", "func_name", "pattern", "target", "method_name"):
+ if attr in current.attrs or attr in current:
+ with reader.group(attr):
+ setattr(action, attr, reader.read_any())
+ else:
+ setattr(action, attr, None)
+
+
+def deserialize_kwargs_snapshot(
+ action: HistoryAction, reader: NativeH5Reader, current: Any
+) -> None:
+ """Deserialize call arguments and the optional edit snapshot."""
+ if "kwargs" in current.attrs or "kwargs" in current:
+ with reader.group("kwargs"):
+ raw = reader.read_dict()
+ action.kwargs = decode_kwargs(raw)
+ else:
+ action.kwargs = {}
+ # ``saved_kwargs`` group is present only when an Edit mode snapshot
+ # exists; otherwise leave it as ``None``.
+ if "saved_kwargs" in current.attrs or "saved_kwargs" in current:
+ with reader.group("saved_kwargs"):
+ raw_saved = reader.read_dict()
+ action.saved_kwargs = decode_kwargs(raw_saved)
+ else:
+ action.saved_kwargs = None
+
+
+def deserialize_outputs_plugin_origin(
+ action: HistoryAction, reader: NativeH5Reader, current: Any
+) -> None:
+ """Deserialize optional outputs and plugin provenance."""
+ # ``output_uuids`` is present only when the action produced outputs;
+ # otherwise leave it empty and consumers fall back to the heuristic
+ # matcher.
+ if "output_uuids" in current.attrs or "output_uuids" in current:
+ with reader.group("output_uuids"):
+ raw_outputs = reader.read_any()
+ if raw_outputs is None:
+ action.output_uuids = []
+ else:
+ action.output_uuids = [str(u) for u in raw_outputs]
+ else:
+ action.output_uuids = []
+ # ``plugin_origin`` is present only for plugin-originated compute
+ # actions; otherwise leave it as ``None`` (a replay of a missing plugin
+ # function then surfaces a generic ``FeatureNotFoundError``).
+ if "plugin_origin" in current.attrs or "plugin_origin" in current:
+ with reader.group("plugin_origin"):
+ raw_origin = reader.read_any()
+ if raw_origin in (None, ""):
+ action.plugin_origin = None
+ else:
+ try:
+ action.plugin_origin = json.loads(raw_origin)
+ except (TypeError, ValueError):
+ _logger.warning(
+ "Failed to decode plugin_origin for action %s; "
+ "falling back to None.",
+ action.uuid,
+ )
+ action.plugin_origin = None
+ else:
+ action.plugin_origin = None
diff --git a/datalab/history/core.py b/datalab/history/core.py
new file mode 100644
index 000000000..3737f1a4a
--- /dev/null
+++ b/datalab/history/core.py
@@ -0,0 +1,181 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+History core utilities: schema constants, JSON codec.
+"""
+
+from __future__ import annotations
+
+import importlib
+import json
+import logging
+import warnings
+from copy import deepcopy
+from typing import Any
+
+import numpy as np
+from guidata.dataset.conv import dataset_to_json, json_to_dataset
+from guidata.dataset.datatypes import DataSet
+from qtpy import QtCore as QC
+from sigima.objects.base import BaseROI
+
+from datalab.config import _
+
+_logger = logging.getLogger(__name__)
+_TRUSTED_ROI_MODULE_PREFIX = "sigima."
+
+# Schema versions for persisted history sessions/actions. Both start at 1.
+# Bump the relevant constant (and add the corresponding optional field
+# handling in serialize/deserialize) when the on-disk layout evolves.
+HISTORY_SCHEMA_VERSION = 1
+HISTORY_ACTION_SCHEMA_VERSION = 1
+# Keys used in the kwargs dict to mark DataSet payloads, so that the
+# serialization layer can round-trip them as JSON strings instead of pickling
+# arbitrary Python objects.
+_DATASET_MARKER = "__dataset_json__"
+_DATASET_LIST_MARKER = "__dataset_list_json__"
+_ROI_MARKER = "__roi_json__"
+
+
+def get_datetime_str() -> str:
+ """Return current date and time as a string"""
+ return QC.QDateTime.currentDateTime().toString("yyyy-MM-dd hh:mm:ss")
+
+
+def numpy_to_json_safe(obj: Any) -> Any:
+ """Recursively convert numpy arrays to lists for JSON serialization."""
+ if isinstance(obj, np.ndarray):
+ return obj.tolist()
+ if isinstance(obj, dict):
+ return {k: numpy_to_json_safe(v) for k, v in obj.items()}
+ if isinstance(obj, list):
+ return [numpy_to_json_safe(i) for i in obj]
+ return obj
+
+
+def encode_roi(roi: Any) -> str:
+ """Encode a sigima ROI object to a JSON string via ``to_dict()``."""
+ if not isinstance(roi, BaseROI):
+ raise TypeError(f"Expected BaseROI instance, got {type(roi)!r}")
+ roi_dict = numpy_to_json_safe(roi.to_dict())
+ # Store the concrete class so we can reconstruct on decode.
+ payload = {
+ "module": type(roi).__module__,
+ "class": type(roi).__qualname__,
+ "data": roi_dict,
+ }
+ return json.dumps(payload)
+
+
+def decode_roi(encoded: str) -> Any:
+ """Decode a JSON string back to a sigima ROI object.
+
+ Only classes from trusted ``sigima.`` modules that are actual
+ :class:`sigima.objects.base.BaseROI` subclasses are allowed.
+
+ Raises:
+ ValueError: If the module is not a trusted sigima module or the
+ resolved class is not a BaseROI subclass.
+ """
+ payload = json.loads(encoded)
+ module_name = payload["module"]
+ class_name = payload["class"]
+
+ if not module_name.startswith(_TRUSTED_ROI_MODULE_PREFIX):
+ raise ValueError(
+ f"Untrusted ROI module {module_name!r}: "
+ f"only modules under {_TRUSTED_ROI_MODULE_PREFIX!r} are allowed"
+ )
+
+ mod = importlib.import_module(module_name)
+ cls = getattr(mod, class_name)
+
+ if not (isinstance(cls, type) and issubclass(cls, BaseROI)):
+ raise ValueError(f"{module_name}.{class_name} is not a BaseROI subclass")
+
+ return cls.from_dict(payload["data"])
+
+
+def encode_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
+ """Encode kwargs for HDF5 storage: replace ``DataSet``, ``list[DataSet]``,
+ and sigima ROI values with marker dicts holding their JSON representation.
+
+ All other values must already be HDF5-friendly primitives (str, int, float,
+ bool, list/tuple of the same).
+
+ Args:
+ kwargs: Raw kwargs dict (may contain ``DataSet`` or ROI instances).
+
+ Returns:
+ A new dict with special values wrapped in marker dicts.
+ """
+ encoded: dict[str, Any] = {}
+ for key, value in kwargs.items():
+ if value is None:
+ continue
+ if isinstance(value, DataSet):
+ encoded[key] = {_DATASET_MARKER: dataset_to_json(value)}
+ elif isinstance(value, BaseROI):
+ encoded[key] = {_ROI_MARKER: encode_roi(value)}
+ elif (
+ isinstance(value, list)
+ and value
+ and all(isinstance(item, DataSet) for item in value)
+ ):
+ encoded[key] = {
+ _DATASET_LIST_MARKER: [dataset_to_json(item) for item in value]
+ }
+ else:
+ encoded[key] = value
+ return encoded
+
+
+def decode_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
+ """Inverse of :func:`encode_kwargs`."""
+ decoded: dict[str, Any] = {}
+ for key, value in kwargs.items():
+ if isinstance(value, dict) and _DATASET_MARKER in value:
+ try:
+ decoded[key] = json_to_dataset(value[_DATASET_MARKER])
+ except (TypeError, ValueError, KeyError):
+ warnings.warn(
+ _("Failed to deserialize history DataSet kwarg %r.") % key
+ )
+ decoded[key] = None
+ elif isinstance(value, dict) and _ROI_MARKER in value:
+ try:
+ decoded[key] = decode_roi(value[_ROI_MARKER])
+ except Exception as exc:
+ raise ValueError(
+ f"Failed to deserialize history ROI kwarg {key!r}: {exc}"
+ ) from exc
+ elif isinstance(value, dict) and _DATASET_LIST_MARKER in value:
+ try:
+ decoded[key] = [
+ json_to_dataset(item) for item in value[_DATASET_LIST_MARKER]
+ ]
+ except (TypeError, ValueError, KeyError):
+ warnings.warn(
+ _("Failed to deserialize history DataSet-list kwarg %r.") % key
+ )
+ decoded[key] = []
+ else:
+ decoded[key] = value
+ return decoded
+
+
+def copy_history_value(value: Any) -> Any:
+ """Return an independent copy of a history-serializable value."""
+ if callable(value):
+ raise TypeError("History duplication does not support callable kwargs")
+ if isinstance(value, DataSet):
+ return json_to_dataset(dataset_to_json(value))
+ if isinstance(value, BaseROI):
+ return decode_roi(encode_roi(value))
+ if isinstance(value, dict):
+ return {key: copy_history_value(item) for key, item in value.items()}
+ if isinstance(value, list):
+ return [copy_history_value(item) for item in value]
+ if isinstance(value, tuple):
+ return tuple(copy_history_value(item) for item in value)
+ return deepcopy(value)
diff --git a/datalab/history/replaymap.py b/datalab/history/replaymap.py
new file mode 100644
index 000000000..7d908d4ee
--- /dev/null
+++ b/datalab/history/replaymap.py
@@ -0,0 +1,234 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""UUID reconciliation for history session replay."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from datalab.history.action import HistoryAction
+
+if TYPE_CHECKING:
+ from datalab.gui.panel.base import BaseDataPanel
+
+
+class ReplayUuidMap:
+ """Reconcile recorded object UUIDs with objects created during replay.
+
+ Args:
+ panels: Data panels participating in the replay
+ """
+
+ def __init__(self, panels: tuple[BaseDataPanel, ...]) -> None:
+ """Initialize an empty per-panel UUID reconciliation map."""
+ self.panels = {panel.PANEL_STR_ID: panel for panel in panels}
+ self.mapping: dict[str, dict[str, str]] = {
+ panel_str: {} for panel_str in self.panels
+ }
+ self.unclaimed: dict[str, list[str]] = {
+ panel_str: [] for panel_str in self.panels
+ }
+
+ def translate_uuid(self, panel_str: str, uuid: str) -> str:
+ """Translate a recorded UUID to its replay UUID."""
+ return self.mapping.get(panel_str, {}).get(uuid, uuid)
+
+ def snapshot_object_ids(self) -> dict[str, set[str]]:
+ """Return the current object UUIDs for every replay panel."""
+ return {
+ panel_str: set(panel.objmodel.get_object_ids())
+ for panel_str, panel in self.panels.items()
+ }
+
+ def claim_action_inputs(self, action: HistoryAction) -> None:
+ """Claim replay objects corresponding to a compute action's inputs."""
+ if action.kind != HistoryAction.KIND_COMPUTE:
+ return
+ panel_str = action.panel_str or ""
+ recorded_uuids = list(action.state.selection.get(panel_str, []))
+ if action.pattern == "2_to_1":
+ second_uuids = action.kwargs.get("obj2_uuids") or []
+ if isinstance(second_uuids, str):
+ second_uuids = [second_uuids]
+ recorded_uuids = list(second_uuids) + recorded_uuids
+ self.claim_inputs(panel_str, recorded_uuids, action)
+
+ def claim_inputs(
+ self, panel_str: str, recorded_uuids: list[str], action: HistoryAction
+ ) -> None:
+ """Claim unassigned replay UUIDs for recorded input UUIDs."""
+ unmapped = self.get_unmapped(panel_str, recorded_uuids)
+ if not unmapped:
+ return
+ panel_order = list(action.state.object_metadata.get(panel_str, {}))
+ if panel_order and all(uuid in panel_order for uuid in unmapped):
+ unmapped.sort(key=panel_order.index)
+ queue = self.unclaimed.get(panel_str, [])
+ if not queue:
+ return
+ assigned = self.match_exact_uuids(panel_str, unmapped, queue)
+ assigned.update(self.match_titles(panel_str, unmapped, queue, action))
+ assigned.update(self.match_positions(panel_str, unmapped, queue, panel_order))
+ self.unclaimed[panel_str] = [uuid for uuid in queue if uuid not in assigned]
+
+ def get_unmapped(self, panel_str: str, recorded_uuids: list[str]) -> list[str]:
+ """Return distinct recorded UUIDs that have no replay mapping."""
+ panel_mapping = self.mapping.get(panel_str, {})
+ unmapped: list[str] = []
+ for uuid in recorded_uuids:
+ if uuid not in unmapped and uuid not in panel_mapping:
+ unmapped.append(uuid)
+ return unmapped
+
+ def match_exact_uuids(
+ self, panel_str: str, recorded_uuids: list[str], queue: list[str]
+ ) -> set[str]:
+ """Match recorded inputs whose UUID is present unchanged in the queue."""
+ assigned = set(recorded_uuids).intersection(queue)
+ panel_mapping = self.mapping.setdefault(panel_str, {})
+ panel_mapping.update({uuid: uuid for uuid in assigned})
+ return assigned
+
+ def match_titles(
+ self,
+ panel_str: str,
+ recorded_uuids: list[str],
+ queue: list[str],
+ action: HistoryAction,
+ ) -> set[str]:
+ """Match remaining inputs with the unique replay object of the same title."""
+ old_titles = self.get_recorded_titles(panel_str, recorded_uuids, action)
+ new_titles = self.get_replay_titles(panel_str, queue)
+ panel_mapping = self.mapping.setdefault(panel_str, {})
+ assigned = set(panel_mapping.values()).intersection(queue)
+ for old_uuid in recorded_uuids:
+ if old_uuid in panel_mapping or old_uuid not in old_titles:
+ continue
+ candidates = [
+ new_uuid
+ for new_uuid in queue
+ if new_uuid not in assigned
+ and new_titles.get(new_uuid) == old_titles[old_uuid]
+ ]
+ if len(candidates) == 1:
+ panel_mapping[old_uuid] = candidates[0]
+ assigned.add(candidates[0])
+ return assigned
+
+ def get_recorded_titles(
+ self, panel_str: str, recorded_uuids: list[str], action: HistoryAction
+ ) -> dict[str, str]:
+ """Return recorded object titles indexed by UUID."""
+ selected = action.state.selection.get(panel_str, [])
+ titles = action.state.titles.get(panel_str, [])
+ old_titles = {
+ uuid: title
+ for uuid, title in zip(selected, titles)
+ if uuid in recorded_uuids
+ }
+ metadata = action.state.object_metadata.get(panel_str, {})
+ for uuid in recorded_uuids:
+ signature = metadata.get(uuid)
+ if uuid not in old_titles and isinstance(signature, dict):
+ title = signature.get("title")
+ if isinstance(title, str):
+ old_titles[uuid] = title
+ return old_titles
+
+ def get_replay_titles(
+ self, panel_str: str, replay_uuids: list[str]
+ ) -> dict[str, str]:
+ """Return titles of queued replay objects that still exist."""
+ panel = self.panels.get(panel_str)
+ if panel is None:
+ return {}
+ titles: dict[str, str] = {}
+ for uuid in replay_uuids:
+ try:
+ titles[uuid] = panel.objmodel[uuid].title
+ except KeyError:
+ continue
+ return titles
+
+ def match_positions(
+ self,
+ panel_str: str,
+ recorded_uuids: list[str],
+ queue: list[str],
+ panel_order: list[str],
+ ) -> set[str]:
+ """Match remaining inputs by absolute or relative recorded position."""
+ panel_mapping = self.mapping.setdefault(panel_str, {})
+ old_remaining = [uuid for uuid in recorded_uuids if uuid not in panel_mapping]
+ assigned = set(panel_mapping.values()).intersection(queue)
+ new_remaining = [uuid for uuid in queue if uuid not in assigned]
+ if not old_remaining:
+ return assigned
+ free_indices = [
+ index for index, uuid in enumerate(panel_order) if uuid not in panel_mapping
+ ]
+ if panel_order and len(new_remaining) == len(free_indices):
+ self.match_absolute_positions(
+ panel_mapping, old_remaining, new_remaining, panel_order, free_indices
+ )
+ else:
+ panel_mapping.update(dict(zip(old_remaining, new_remaining)))
+ return set(panel_mapping.values()).intersection(queue)
+
+ @staticmethod
+ def match_absolute_positions(
+ panel_mapping: dict[str, str],
+ old_uuids: list[str],
+ new_uuids: list[str],
+ panel_order: list[str],
+ free_indices: list[int],
+ ) -> None:
+ """Match remaining UUIDs using their absolute recorded panel positions."""
+ new_by_index = dict(zip(free_indices, new_uuids))
+ for old_uuid in old_uuids:
+ if old_uuid in panel_order:
+ index = panel_order.index(old_uuid)
+ if index in new_by_index:
+ panel_mapping[old_uuid] = new_by_index[index]
+
+ def capture_changes(
+ self, action: HistoryAction, before: dict[str, set[str]]
+ ) -> None:
+ """Capture objects created and removed by a replayed action."""
+ for panel_str, panel in self.panels.items():
+ object_ids = panel.objmodel.get_object_ids()
+ current = set(object_ids)
+ self.discard_removed(panel_str, before[panel_str] - current)
+ created = [uuid for uuid in object_ids if uuid not in before[panel_str]]
+ self.capture_created(panel_str, created, action)
+
+ def discard_removed(self, panel_str: str, removed_uuids: set[str]) -> None:
+ """Discard queued objects and mappings whose replay objects were removed."""
+ if not removed_uuids:
+ return
+ self.unclaimed[panel_str] = [
+ uuid
+ for uuid in self.unclaimed.get(panel_str, [])
+ if uuid not in removed_uuids
+ ]
+ panel_mapping = self.mapping.get(panel_str, {})
+ for old_uuid in [
+ uuid
+ for uuid, new_uuid in panel_mapping.items()
+ if new_uuid in removed_uuids
+ ]:
+ panel_mapping.pop(old_uuid)
+
+ def capture_created(
+ self, panel_str: str, created_uuids: list[str], action: HistoryAction
+ ) -> None:
+ """Map or queue objects created by a replayed action."""
+ if not created_uuids:
+ return
+ captured = action.state.selection.get(panel_str, [])
+ if action.kind == HistoryAction.KIND_UI and captured:
+ self.mapping.setdefault(panel_str, {}).update(
+ dict(zip(captured, created_uuids))
+ )
+ created_uuids = created_uuids[len(captured) :]
+ self.unclaimed.setdefault(panel_str, []).extend(created_uuids)
diff --git a/datalab/history/session.py b/datalab/history/session.py
new file mode 100644
index 000000000..3e1f0b63f
--- /dev/null
+++ b/datalab/history/session.py
@@ -0,0 +1,215 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""HistorySession: ordered list of HistoryAction with replay logic."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from datalab.config import _
+from datalab.history.action import HistoryAction
+from datalab.history.core import HISTORY_SCHEMA_VERSION, get_datetime_str
+from datalab.history.replaymap import ReplayUuidMap
+
+if TYPE_CHECKING:
+ from datalab.gui.main import DLMainWindow
+ from datalab.h5.native import NativeH5Reader, NativeH5Writer
+
+
+class HistorySession:
+ """Object representing a history session, i.e. a list of actions.
+
+ A history session is a list of actions that can be replayed in the same order
+ as they were added to the history session. The history session can be saved to
+ a file and loaded from a file.
+
+ Args:
+ title: Title of the history session
+ number: Number of the history session
+ """
+
+ def __init__(self, title: str = "", number: int = 0) -> None:
+ """Create a new history session"""
+ prefix = _("Session")
+ self.title = title if title else f"{prefix} {number:03d}"
+ self.number = number
+ self.dtstr: str = get_datetime_str()
+ self.actions: list[HistoryAction] = []
+ self.schema_version: int = HISTORY_SCHEMA_VERSION
+
+ def add_action(self, action: HistoryAction) -> None:
+ """Add an action to the history session
+
+ Args:
+ action: Action to add
+ """
+ self.actions.append(action)
+
+ def copy(
+ self, title: str | None = None, action_title_suffix: str | None = None
+ ) -> HistorySession:
+ """Return an independent copy of this history session."""
+ session = HistorySession(title=title or self.title, number=self.number)
+ session.actions = [
+ action.copy(title_suffix=action_title_suffix) for action in self.actions
+ ]
+ return session
+
+ def copy_with_uuid_remap(
+ self, title: str, uuid_remap: dict[str, dict[str, str]]
+ ) -> HistorySession:
+ """Return a copy of this session with all UUIDs rewritten via ``uuid_remap``.
+
+ Used by the Duplicate operation to build an independent session whose
+ captured object references point to the cloned data objects.
+
+ Args:
+ title: Title for the new session.
+ uuid_remap: Per-panel mapping ``{panel_str: {old_uuid: new_uuid}}``.
+
+ Returns:
+ A new :class:`HistorySession` with all captured UUIDs remapped.
+ """
+ session = HistorySession(title=title, number=self.number)
+ session.actions = [
+ action.copy_with_uuid_remap(uuid_remap) for action in self.actions
+ ]
+ return session
+
+ def is_current_state_compatible(
+ self, mainwindow: DLMainWindow, restore_selection: bool
+ ) -> bool:
+ """Check if the current workspace state is compatible with the saved state
+
+ Args:
+ mainwindow: DataLab's main window
+ restore_selection: True to restore the selection before checking the state
+
+ Returns:
+ bool: True if the current workspace state is compatible with the saved state
+ """
+ if self.actions:
+ return self.actions[0].is_current_state_compatible(
+ mainwindow, restore_selection
+ )
+ return True
+
+ def restore(self, mainwindow: DLMainWindow) -> None:
+ """Restore the state of the workspace associated to the first action of session
+
+ Args:
+ mainwindow: DataLab's main window
+ """
+ if self.actions:
+ self.actions[0].restore(mainwindow)
+
+ def replay(
+ self, mainwindow: DLMainWindow, restore_selection: bool, edit: bool
+ ) -> None:
+ """Replay the history session
+
+ Args:
+ mainwindow: DataLab's main window
+ restore_selection: True to restore the workspace selection before replaying
+ edit: if True, always open the dialog boxes to edit parameters, if False,
+ use the parameters passed when creating the action
+ """
+ panels = (mainwindow.signalpanel, mainwindow.imagepanel)
+ replay_map = ReplayUuidMap(panels)
+ for action in self.actions[:]:
+ before = replay_map.snapshot_object_ids()
+ replay_map.claim_action_inputs(action)
+ action.replay(
+ mainwindow,
+ restore_selection=restore_selection,
+ edit=edit,
+ uuid_remap=replay_map.mapping,
+ )
+ replay_map.capture_changes(action, before)
+
+ if self.actions:
+ select_last_compute_output(
+ mainwindow, panels, replay_map.mapping, self.actions[-1]
+ )
+
+ def serialize(self, writer: NativeH5Writer) -> None:
+ """Serialize this history session
+
+ Args:
+ writer: Writer
+ """
+ with writer.group("schema_version"):
+ writer.write(self.schema_version)
+ with writer.group("title"):
+ writer.write(self.title)
+ with writer.group("number"):
+ writer.write(self.number)
+ with writer.group("dtstr"):
+ writer.write(self.dtstr)
+ writer.write_object_list(self.actions, "actions")
+
+ def deserialize(self, reader: NativeH5Reader) -> None:
+ """Deserialize this history session
+
+ Args:
+ reader: Reader
+ """
+ self.schema_version = reader.read(
+ "schema_version", default=HISTORY_SCHEMA_VERSION
+ )
+ with reader.group("title"):
+ self.title = reader.read_any()
+ with reader.group("number"):
+ self.number = reader.read_any()
+ with reader.group("dtstr"):
+ self.dtstr = reader.read_any()
+ self.actions = reader.read_object_list("actions", HistoryAction)
+
+ def remove_action(self, action: HistoryAction) -> None:
+ """Remove an action from the history session
+
+ This implies removing all subsequent actions. If action is not found, this
+ fails silently.
+
+ Args:
+ action: Action to remove
+ """
+ if action in self.actions:
+ index = self.actions.index(action)
+ self.actions = self.actions[:index]
+
+
+def select_last_compute_output(
+ mainwindow: DLMainWindow,
+ panels: tuple,
+ uuid_remap: dict[str, dict[str, str]],
+ last_action: HistoryAction,
+) -> None:
+ """Select the output of the last compute action after a session replay.
+
+ Visually closes the replay by highlighting the final result in its panel.
+ No-op when the last action is not a compute action or its output is gone.
+
+ Args:
+ mainwindow: DataLab's main window
+ panels: signal and image panels
+ uuid_remap: per-panel ``{old_uuid: new_uuid}`` mapping built during replay
+ last_action: last action of the replayed session
+ """
+ if last_action.kind != HistoryAction.KIND_COMPUTE:
+ return
+ hpanel = getattr(mainwindow, "historypanel", None)
+ if hpanel is None:
+ return
+ output_uuid = hpanel.action_output_uuid(last_action)
+ if not output_uuid:
+ return
+ panel_str = last_action.panel_str or ""
+ mapped_uuid = uuid_remap.get(panel_str, {}).get(output_uuid, output_uuid)
+ target_panel = next((p for p in panels if p.PANEL_STR_ID == panel_str), None)
+ if target_panel is None:
+ return
+ try:
+ target_panel.objview.select_objects([mapped_uuid])
+ except KeyError:
+ pass
diff --git a/datalab/history/workspace_state.py b/datalab/history/workspace_state.py
new file mode 100644
index 000000000..702f4f50e
--- /dev/null
+++ b/datalab/history/workspace_state.py
@@ -0,0 +1,264 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Workspace state snapshot captured at history action time."""
+
+from __future__ import annotations
+
+from copy import deepcopy
+from typing import TYPE_CHECKING, Any
+
+from datalab.objectmodel import get_uuid
+
+if TYPE_CHECKING:
+ from datalab.gui.main import DLMainWindow
+ from datalab.h5.native import NativeH5Reader, NativeH5Writer
+
+
+class WorkspaceState:
+ """Object representing the workspace state at a given time.
+
+ The workspace state stores the per-panel selection of objects by **UUID**
+ (robust against reordering, renaming or interleaved insertions). For
+ informative display, it also retains the data shape and title of each
+ selected object at the time of capture.
+ """
+
+ def __init__(self) -> None:
+ """Create a new workspace state"""
+ # The selection is stored as a dictionary where the key is the panel name
+ # and the value is the list of UUIDs of selected objects.
+ self.selection: dict[str, list[str]] = {}
+ # The states are stored as a dictionary where the key is the panel name
+ # and the value is the list of states (str) of the objects in the panel. The
+ # state is a string containing the object data shape (kept for informative
+ # display only -- not used for selection matching anymore).
+ self.states: dict[str, list[str]] = {}
+ # The titles are stored as a dictionary where the key is the panel name and the
+ # value is the list of titles of the objects in the panel. The title is only
+ # informative and is not used to determine if two objects have the same state.
+ self.titles: dict[str, list[str]] = {}
+ # Structured data signatures of selected objects, keyed by panel name and UUID.
+ # This is the current schema used for compatibility checks. Missing metadata
+ # means a pre-Gate-2 history and falls back to UUID-existence validation.
+ self.object_metadata: dict[str, dict[str, dict[str, Any]]] = {}
+
+ def copy(self) -> WorkspaceState:
+ """Return an independent copy of this workspace state."""
+ state = WorkspaceState()
+ state.selection = deepcopy(self.selection)
+ state.states = deepcopy(self.states)
+ state.titles = deepcopy(self.titles)
+ state.object_metadata = deepcopy(self.object_metadata)
+ return state
+
+ def serialize(self, writer: NativeH5Writer) -> None:
+ """Serialize this workspace state
+
+ Args:
+ writer: Writer
+ """
+ with writer.group("selection"):
+ writer.write_dict(self.selection)
+ with writer.group("states"):
+ writer.write_dict(self.states)
+ with writer.group("titles"):
+ writer.write_dict(self.titles)
+ with writer.group("object_metadata"):
+ writer.write_dict(self.object_metadata)
+
+ def deserialize(self, reader: NativeH5Reader) -> None:
+ """Deserialize this workspace state
+
+ Args:
+ reader: Reader
+ """
+ with reader.group("selection"):
+ self.selection = reader.read_dict()
+ with reader.group("states"):
+ self.states = reader.read_dict()
+ with reader.group("titles"):
+ self.titles = reader.read_dict()
+ current = reader.h5
+ for option in reader.option:
+ current = current[option]
+ if "object_metadata" in current.attrs or "object_metadata" in current:
+ with reader.group("object_metadata"):
+ self.object_metadata = reader.read_dict()
+ else:
+ self.object_metadata = {}
+ # Normalize legacy translated keys to stable panel identifiers.
+ self.selection = self.normalize_panel_keys(self.selection)
+ self.states = self.normalize_panel_keys(self.states)
+ self.titles = self.normalize_panel_keys(self.titles)
+ self.object_metadata = self.normalize_panel_keys(self.object_metadata)
+
+ def get_current_selection(self, mainwindow: DLMainWindow) -> dict[str, list[str]]:
+ """Get the current selection in the workspace, keyed by panel name and
+ valued by the list of selected object UUIDs.
+
+ Args:
+ mainwindow: DataLab's main window
+
+ Returns:
+ Current selection in the workspace, by panel name → list of UUIDs.
+ """
+ selection: dict[str, list[str]] = {}
+ for panel in (mainwindow.signalpanel, mainwindow.imagepanel):
+ selection[panel.PANEL_STR_ID] = [
+ get_uuid(obj)
+ for obj in panel.objview.get_sel_objects(include_groups=True)
+ ]
+ return selection
+
+ @staticmethod
+ def get_object_metadata(obj: Any) -> dict[str, Any]:
+ """Return a stable data signature for an object."""
+ data = getattr(obj, "data", None)
+ shape = getattr(data, "shape", None)
+ if shape is None:
+ return {}
+ shape = [int(size) for size in shape]
+ ndim = getattr(data, "ndim", len(shape))
+ return {"shape": shape, "ndim": int(ndim)}
+
+ @staticmethod
+ def normalize_object_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
+ """Normalize object metadata loaded from HDF5 for comparison."""
+ shape = metadata.get("shape")
+ if shape is None:
+ return {}
+ shape = [int(size) for size in shape]
+ ndim = metadata.get("ndim", len(shape))
+ return {"shape": shape, "ndim": int(ndim)}
+
+ # Mapping from legacy translated panel keys to stable identifiers.
+ # Covers the English translations; other locales are handled by the
+ # catch-all ``"signal"``/``"image"`` substring heuristic below.
+ _LEGACY_PANEL_KEY_MAP: dict[str, str] = {
+ "Signal Panel": "signal",
+ "Image Panel": "image",
+ }
+
+ @classmethod
+ def normalize_panel_key(cls, key: str) -> str:
+ """Map a potentially translated panel key to its stable identifier."""
+ if key in ("signal", "image"):
+ return key
+ mapped = cls._LEGACY_PANEL_KEY_MAP.get(key)
+ if mapped is not None:
+ return mapped
+ # Heuristic for non-English translations: look for the stable ID
+ # substring in the key (e.g. "Panneau signal" → "signal").
+ lowered = key.lower()
+ for stable_id in ("signal", "image"):
+ if stable_id in lowered:
+ return stable_id
+ return key
+
+ @classmethod
+ def normalize_panel_keys(cls, d: dict) -> dict:
+ """Return *d* with all top-level keys normalized to stable panel IDs."""
+ return {cls.normalize_panel_key(k): v for k, v in d.items()}
+
+ def save(self, mainwindow: DLMainWindow, panel_str: str | None = None) -> None:
+ """Save the current workspace state
+
+ Args:
+ mainwindow: DataLab's main window
+ panel_str: Stable identifier (``"signal"`` or ``"image"``) of the
+ panel the action operates on. When provided, only that panel's
+ selection/states/titles are captured -- the other panel's entries
+ are left empty so that unrelated objects in the other panel cannot
+ produce false incompatibilities. When ``None`` (default), both
+ panels are captured (backward-compatible behavior). ``object_metadata``
+ is always captured for both panels (used by session-replay
+ positional fallback and harmless for compatibility checks).
+ """
+ full_selection = self.get_current_selection(mainwindow)
+ if panel_str is None:
+ self.selection = full_selection
+ else:
+ # Restrict the captured selection to the action's own panel; leave
+ # the other panel's selection empty so compatibility checks ignore
+ # unrelated objects in that panel.
+ self.selection = {panel_str: full_selection.get(panel_str, [])}
+ self.object_metadata = {}
+ for panel in (mainwindow.signalpanel, mainwindow.imagepanel):
+ sel_uuids = self.selection.get(panel.PANEL_STR_ID, [])
+ self.states[panel.PANEL_STR_ID] = [
+ str(obj.data.shape)
+ for obj in panel.objmodel
+ if get_uuid(obj) in sel_uuids
+ ]
+ self.titles[panel.PANEL_STR_ID] = [
+ obj.title for obj in panel.objmodel if get_uuid(obj) in sel_uuids
+ ]
+ # Store metadata for ALL panel objects (not just selected) so that
+ # the dict key order captures the full panel ordering. During
+ # session replay the key order lets us sort old UUIDs by their
+ # original panel position, which prevents non-commutative 2_to_1
+ # operand swaps in the positional-fallback code path.
+ # ``is_current_state_compatible`` only checks *selected* UUIDs, so
+ # the extra entries are harmless for compatibility validation.
+ self.object_metadata[panel.PANEL_STR_ID] = {
+ get_uuid(obj): self.get_object_metadata(obj) for obj in panel.objmodel
+ }
+
+ def is_current_state_compatible( # pylint: disable=unused-argument
+ self, mainwindow: DLMainWindow, restore_selection: bool
+ ) -> bool:
+ """Check if the current workspace state is compatible with the saved state.
+
+ Compatibility means that **every** UUID recorded in the saved selection
+ still exists in the corresponding panel. When structured object metadata
+ is available (current schema), each selected object's data shape and
+ dimensions must also match the saved signature. Histories without this
+ metadata fall back to legacy UUID-existence validation.
+
+ Args:
+ mainwindow: DataLab's main window
+ restore_selection: Unused (kept for API symmetry). With UUID-based
+ identity, the compatibility check no longer depends on the current
+ selection -- it only depends on object existence.
+
+ Returns:
+ True if every saved UUID still exists in its panel and saved
+ metadata, when available, still matches.
+ """
+ if not self.selection:
+ return True
+ for panel in (mainwindow.signalpanel, mainwindow.imagepanel):
+ saved_uuids = self.selection.get(panel.PANEL_STR_ID, [])
+ existing_uuids = set(panel.objmodel.get_object_ids())
+ saved_metadata = self.object_metadata.get(panel.PANEL_STR_ID, {})
+ for uuid in saved_uuids:
+ if uuid not in existing_uuids:
+ return False
+ if uuid in saved_metadata:
+ current = self.get_object_metadata(panel.objmodel[uuid])
+ current = self.normalize_object_metadata(current)
+ saved = self.normalize_object_metadata(saved_metadata[uuid])
+ if saved and current != saved:
+ return False
+ return True
+
+ def restore(self, mainwindow: DLMainWindow) -> None:
+ """Restore the workspace state by selecting the recorded UUIDs.
+
+ Args:
+ mainwindow: DataLab's main window
+
+ Raises:
+ ValueError: If at least one of the saved UUIDs no longer exists in
+ its panel.
+ """
+ if not self.selection:
+ return
+ if not self.is_current_state_compatible(mainwindow, False):
+ raise ValueError(
+ "Current workspace state is not compatible with saved state"
+ )
+ for panel in (mainwindow.signalpanel, mainwindow.imagepanel):
+ uuids = self.selection.get(panel.PANEL_STR_ID, [])
+ if uuids:
+ panel.objview.select_objects(uuids)
diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po
index 4e8672839..376d52e88 100644
--- a/datalab/locale/fr/LC_MESSAGES/datalab.po
+++ b/datalab/locale/fr/LC_MESSAGES/datalab.po
@@ -360,8 +360,12 @@ msgid "Recompute"
msgstr "Retraiter"
#, python-format
-msgid "Recompute selected %s with its processing parameters"
-msgstr "Retraiter l'objet %s sélectionné avec ses paramètres de traitement"
+msgid ""
+"Recompute selected %s: refresh both processing and analysis results with their "
+"stored parameters"
+msgstr ""
+"Retraiter l'objet %s sélectionné : actualiser les résultats de traitement et "
+"d'analyse à partir de leurs paramètres enregistrés"
msgid "Select source objects"
msgstr "Sélectionner les objets source"
@@ -940,6 +944,15 @@ msgstr "Afficher le panneau de contraste"
msgid "Show or hide contrast adjustment panel"
msgstr "Afficher ou cacher le panneau de réglage du contraste"
+msgid "Command palette"
+msgstr "Palette de commandes"
+
+msgid "Type to search for a command…"
+msgstr "Tapez pour rechercher une commande…"
+
+msgid "Search a command…"
+msgstr "Rechercher une commande…"
+
msgid "Process signal"
msgstr "Traiter le signal"
@@ -953,28 +966,36 @@ msgstr "Troncature de données uint32 en int32."
msgid "No supported data available in HDF5 file(s)."
msgstr "Aucune donnée prise en charge dans le(s) fichier(s) HDF5."
-msgid "Generate macro"
-msgstr "Générer une macro"
+msgid "A new object was loaded. Start a new history session?"
+msgstr "Un nouvel objet a été chargé. Démarrer une nouvelle session d'historique ?"
-msgid "No compute actions to export."
-msgstr "Pas d'actions de calcul à exporter."
+msgid "A new object was created. Start a new history session?"
+msgstr "Un nouvel objet a été créé. Démarrer une nouvelle session d'historique ?"
-#, python-format
-msgid "Macro script copied to clipboard (%d actions)."
-msgstr "Script de macro copié dans le presse-papier (%d actions)."
+msgid "New history session"
+msgstr "Nouvelle session d'historique"
+
+msgid "Initial state"
+msgstr "État initial"
+
+msgid "Chain copy"
+msgstr "Copie de chaîne"
msgid ""
"Do you really want to delete the selected items?\n"
"\n"
-"Note: deleting an action also removes all subsequent actions in the same session."
+"Note: deleting an intermediate action splits its processing chain; downstream steps become an independent chain."
msgstr ""
-"Êtes-vous sûr de vouloir supprimer le(s) groupe(s) sélectionné(s) ?\n"
+"Êtes-vous sûr de vouloir supprimer les éléments sélectionnés ?\n"
"\n"
-"Remarque : la suppression d'une action entraîne également la suppression de toutes les actions suivantes dans la même session."
+"Remarque : la suppression d'une action intermédiaire divise sa chaîne de traitement ; les étapes en aval deviennent une chaîne indépendante."
msgid "Do you really want to delete the selected items?"
msgstr "Êtes-vous sûr de vouloir supprimer le(s) groupe(s) sélectionné(s) ?"
+msgid "The deleted action(s) produced object(s) still present in the workspace. Do you want to remove the associated object(s) as well?"
+msgstr "La ou les actions supprimées ont produit des objets encore présents dans l'espace de travail. Voulez-vous également supprimer le ou les objets associés ?"
+
msgid "Remove incompatible"
msgstr "Supprimer les actions incompatibles"
@@ -1044,7 +1065,7 @@ msgstr "Ignorer ce message empêchera son affichage ultérieur."
msgid "Plugins"
msgstr "Plugins"
-msgid "Third-party plugins are disabled. Enable them in the Settings dialog to use this feature."
+msgid "Third-party plugins are disabled. Enable them again from the plugin configuration dialog to use this feature."
msgstr "Les plugins tiers sont désactivés. Activez-les dans la boîte de dialogue des préférences pour utiliser cette fonctionnalité."
#, python-format
@@ -1075,18 +1096,9 @@ msgstr "Préférences..."
msgid "Open settings dialog"
msgstr "Ouvrir la boîte de dialogue des préférences"
-msgid "Command palette"
-msgstr "Palette de commandes"
-
msgid "Command palette..."
msgstr "Palette de commandes..."
-msgid "Search a command…"
-msgstr "Rechercher une commande…"
-
-msgid "Type to search for a command…"
-msgstr "Tapez pour rechercher une commande…"
-
msgid "Search and run any command by its menu path"
msgstr "Rechercher et exécuter n'importe quelle commande par son chemin de menu"
@@ -1240,6 +1252,9 @@ msgstr "Console"
msgid "Macro Panel"
msgstr "Gestionnaire de Macros"
+msgid "History Panel"
+msgstr "Historique"
+
msgid "Disable auto-refresh?"
msgstr "Désactiver le rafraîchissement automatique ?"
@@ -1277,6 +1292,17 @@ msgstr "Ouvrir"
msgid "HDF5 files (*.h5 *.hdf5 *.hdf *.he5);;All files (*)"
msgstr "Fichiers HDF5 (*.h5 *.hdf5 *.hdf *.he5);;Tous les fichiers (*)"
+#, python-format
+msgid "Open %d HDF5 files"
+msgstr "Ouvrir %d fichiers HDF5"
+
+msgid "Open HDF5 file"
+msgstr "Ouvrir un fichier HDF5"
+
+#, python-format
+msgid "New %s"
+msgstr "Créer un nouvel objet %s"
+
msgid "not started"
msgstr "non démarré"
@@ -1349,9 +1375,8 @@ msgstr "Métadonnées de l'objet"
msgid "(click on Metadata button for more details)"
msgstr "(cliquer sur le bouton Métadonnées pour plus de détails)"
-#, fuzzy
msgid "group"
-msgstr "Groupe"
+msgstr "groupe"
msgid "Source objects"
msgstr "Objets source"
@@ -1404,15 +1429,6 @@ msgstr "Paramètres de création"
msgid "Creation"
msgstr "Création"
-msgid "Signal was modified in-place."
-msgstr "Le signal a été modifié sur place."
-
-msgid "Image was modified in-place."
-msgstr "L'image a été modifiée sur place."
-
-msgid "If computation were performed based on this object, they may need to be redone."
-msgstr "Si des calculs ont été effectués sur la base de cet objet, ils devront peut-être être refaits."
-
#, python-format
msgid ""
"Failed to recreate object with new parameters:\n"
@@ -1421,9 +1437,41 @@ msgstr ""
"Échec de la recréation de l'objet avec les nouveaux paramètres :\n"
"%s"
+msgid "Signal was recreated."
+msgstr "Le signal a été recréé."
+
+msgid "Image was recreated."
+msgstr "L'image a été recréée."
+
msgid "Processing Parameters"
msgstr "Paramètres de traitement"
+msgid "Auto-recompute on edit"
+msgstr "Recalcul automatique lors de l'édition"
+
+msgid "Automatically re-run processing when parameters are modified"
+msgstr "Relancer automatiquement le traitement à chaque modification de paramètre"
+
+msgid "Analysis Parameters"
+msgstr "Paramètres d'analyse"
+
+msgid "Analysis metadata is incomplete."
+msgstr "Les métadonnées d'analyse sont incomplètes."
+
+#, python-format
+msgid ""
+"Failed to recompute analysis:\n"
+"%s"
+msgstr ""
+"Échec du recalcul de l'analyse :\n"
+"%s"
+
+msgid "Failed to recompute analysis."
+msgstr "Échec du recalcul de l'analyse."
+
+msgid "Analysis was recomputed."
+msgstr "L'analyse a été recalculée."
+
msgid "No processing object available."
msgstr "Aucun objet de traitement disponible."
@@ -1456,6 +1504,9 @@ msgstr "Le signal a été retraité."
msgid "Image was reprocessed."
msgstr "L'image a été retraitée."
+msgid "Failed to reprocess object."
+msgstr "Échec du retraitement de l'objet."
+
msgid "Geometry results"
msgstr "Résultats géométriques"
@@ -1468,6 +1519,9 @@ msgstr "Autres métadonnées"
msgid "Save to directory"
msgstr "Enregistrer dans un répertoire"
+msgid "Pattern help"
+msgstr "Aide sur le motif"
+
msgid "Directory"
msgstr "Répertoire"
@@ -1519,6 +1573,9 @@ msgstr "Modèle de valeur"
msgid "Conversion"
msgstr "Conversion"
+msgid "Duplicate object or group"
+msgstr "Dupliquer l'objet ou le groupe"
+
msgid "Select what to keep from the clipboard.
Result shapes and annotations, if kept, will be merged with existing ones. All other metadata will be replaced."
msgstr "Sélectionnez ce que vous souhaitez conserver dans le presse-papier.
Les formes graphiques et les annotations, si conservées, seront fusionnées avec celles existantes. Toutes les autres métadonnées seront remplacées."
@@ -1528,6 +1585,9 @@ msgstr "Supprimer le(s) groupe(s)"
msgid "Are you sure you want to delete the selected group(s)?"
msgstr "Êtes-vous sûr de vouloir supprimer le(s) groupe(s) sélectionné(s) ?"
+msgid "Remove selected objects"
+msgstr "Supprimer les objets sélectionnés"
+
#, python-format
msgid "Do you want to delete all objects (%s)?"
msgstr "Souhaitez-vous supprimer tous les objets (%s) ?"
@@ -1538,11 +1598,18 @@ msgstr "Supprimer les métadonnées"
msgid "Some selected objects have regions of interest. Do you want to delete them as well?"
msgstr "Certains objets sélectionnés ont des régions d'intérêt. Souhaitez-vous les supprimer également ?"
+msgid "New group"
+msgstr "Nouveau groupe"
+
msgid "Group name:"
msgstr "Nom du groupe :"
-msgid "New group"
-msgstr "Nouveau groupe"
+#, python-format
+msgid "New group \"%s\""
+msgstr "Nouveau groupe \"%s\""
+
+msgid "Rename selected object or group"
+msgstr "Renommer l'objet ou le groupe sélectionné"
msgid "Rename object"
msgstr "Renommer l'objet"
@@ -1553,6 +1620,10 @@ msgstr "Nom de l'objet :"
msgid "Rename group"
msgstr "Renommer le groupe"
+#, python-format
+msgid "Set current object title to \"%s\""
+msgstr "Définir le titre de l'objet courant à \"%s\""
+
msgid "Reading objects from file"
msgstr "Lecture des objets depuis le fichier"
@@ -1562,6 +1633,22 @@ msgstr "Ajout d'objets à l'espace de travail"
msgid "Scanning directory"
msgstr "Analyse du répertoire"
+#, python-format
+msgid "Load from %d files"
+msgstr "Charger depuis %d fichiers"
+
+#, python-format
+msgid "Load \"%s\""
+msgstr "Charger \"%s\""
+
+#, python-format
+msgid "Save to %d different files"
+msgstr "Enregistrer dans %d fichiers différents"
+
+#, python-format
+msgid "Save to \"%s\""
+msgstr "Enregistrer dans \"%s\""
+
msgid "Save as"
msgstr "Enregistrer sous"
@@ -1574,18 +1661,31 @@ msgstr "Importer une ROI"
msgid "Export ROI"
msgstr "Exporter une ROI"
-msgid "Selected object(s) do not have processing parameters that can be recomputed."
-msgstr "L'objet (ou les objets) sélectionné(s) n'ont pas de paramètres de traitement pouvant être retraités."
+msgid ""
+"Selected object(s) do not have processing or analysis parameters that can be "
+"recomputed."
+msgstr ""
+"L'objet (ou les objets) sélectionné(s) n'ont pas de paramètres de traitement ou "
+"d'analyse pouvant être recalculés."
msgid "Recomputing objects"
msgstr "Retraitement des objets"
+msgid "Recomputing analyses"
+msgstr "Recalcul des analyses"
+
msgid "Failed to recompute object"
msgstr "Échec du retraitement de l'objet"
msgid "Do you want to continue with the next object?"
msgstr "Souhaitez-vous continuer avec l'objet suivant ?"
+msgid "Analysis computation failed."
+msgstr "Le calcul de l'analyse a échoué."
+
+msgid "Failed to recompute analysis"
+msgstr "Échec du recalcul de l'analyse"
+
msgid "Selected object does not have processing metadata."
msgstr "L'objet sélectionné n'a pas de métadonnées de traitement."
@@ -1643,6 +1743,12 @@ msgstr "Tous les objets associés aux résultats doivent avoir les mêmes ROIs p
msgid "Are you sure you want to delete all results of the selected object(s)?"
msgstr "Êtes-vous sûr de vouloir supprimer tous les résultats des objets sélectionnés ?"
+msgid "Add object title to plot"
+msgstr "Ajouter le titre de l'objet au graphique"
+
+msgid "Add label with title"
+msgstr "Ajouter une étiquette avec le titre"
+
msgid "Annotation added"
msgstr "Annotation ajoutée"
@@ -1651,13 +1757,13 @@ msgstr "L'étiquette a été ajoutée comme annotation. Vous pouvez la modifier
#, python-format
msgid "“%s” has dependent operations but no valid source to reconnect to — downstream results are left unchanged."
-msgstr "“%s” a des opérations dépendantes mais aucune source valide à reconnecter — les résultats en aval restent inchangés."
+msgstr "« %s » possède des opérations dépendantes mais aucune source valide à laquelle se reconnecter — les résultats en aval restent inchangés."
msgid "Some operations could not be reconnected after deletion:"
msgstr "Certaines opérations n'ont pas pu être reconnectées après la suppression :"
msgid "The current workspace state is not compatible with the action."
-msgstr "Le statut actuel de l'espace de travail n'est pas compatible avec l'action."
+msgstr "L'état actuel de l'espace de travail n'est pas compatible avec l'action."
msgid "Parameters"
msgstr "Paramètres"
@@ -1668,11 +1774,16 @@ msgstr "Panneau d'historique"
msgid "History files"
msgstr "Fichiers d'historique"
-msgid "Edit mode"
-msgstr "Mode édition"
+#, python-format
+msgid "Action %s could not be fully recomputed."
+msgstr "L'action %s n'a pas pu être entièrement recalculée."
+
+#, python-format
+msgid "Action %s: recompute returned %d output(s), expected %d."
+msgstr "Action %s : le recalcul a renvoyé %d sortie(s), %d attendue(s)."
msgid "Record mode"
-msgstr "Mode enregistrement"
+msgstr "Mode d'enregistrement"
msgid "New session"
msgstr "Nouvelle session"
@@ -1681,49 +1792,49 @@ msgid "Start a new history session"
msgstr "Démarrer une nouvelle session d'historique"
msgid "Open history file..."
-msgstr "Ouvrir des fichiers HDF5..."
+msgstr "Ouvrir un fichier d'historique..."
msgid "Open history from a standalone .dlhist file"
msgstr "Ouvrir l'historique depuis un fichier .dlhist autonome"
msgid "Save history file..."
-msgstr "Enregistrer l'historique dans un fichier HDF5..."
+msgstr "Enregistrer un fichier d'historique..."
msgid "Save history to a standalone .dlhist file"
msgstr "Enregistrer l'historique dans un fichier .dlhist autonome"
msgid "Duplicate selected history action/session"
-msgstr "Dupliquer l'objet %s sélectionné"
+msgstr "Dupliquer l'action ou la session d'historique sélectionnée"
msgid "Previous step"
msgstr "Étape précédente"
msgid "Select the previous action in the current session"
-msgstr "Sélectionner l'action précédente dans la session en cours"
+msgstr "Sélectionner l'action précédente dans la session courante"
msgid "Next step"
msgstr "Étape suivante"
msgid "Select the next action in the current session"
-msgstr "Sélectionner l'action suivante dans la session en cours"
-
-msgid "Generate a Python macro script from history"
-msgstr "Générer un script Python macro à partir de l'historique"
+msgstr "Sélectionner l'action suivante dans la session courante"
msgid "Remove actions incompatible with the current workspace"
-msgstr "Supprimer les actions incompatibles avec l'espace de travail actuel"
-
-msgid "Restore parameters"
-msgstr "Restaurer les paramètres"
-
-msgid "Restore original parameters (discard edit-mode changes)"
-msgstr "Restaurer les paramètres d'origine (ignorer les modifications en mode édition)"
+msgstr "Supprimer les actions incompatibles avec l'espace de travail courant"
msgid "Replay"
msgstr "Rejouer"
+msgid "Replay the selection silently (no parameter dialogs)"
+msgstr "Rejouer la sélection silencieusement (sans boîtes de dialogue de paramètres)"
+
+msgid "Step-by-step"
+msgstr "Pas à pas"
+
+msgid "Replay the selection step by step, editing parameters at each step"
+msgstr "Rejouer la sélection pas à pas, en modifiant les paramètres à chaque étape"
+
msgid "Commit edit mode changes?"
-msgstr "Valider les modifications du mode édition ?"
+msgstr "Valider les modifications du mode d'édition ?"
msgid ""
"You are about to exit Edit mode.\n"
@@ -1733,24 +1844,24 @@ msgid ""
"\n"
"Do you want to continue?"
msgstr ""
-"Vous êtes sur le point de quitter le mode Édition.\n"
+"Vous êtes sur le point de quitter le mode d'édition.\n"
"\n"
-"Toutes les modifications de paramètres effectuées pendant cette session seront conservées de manière permanente.\n"
-"Cette action est irréversible — la restauration ne sera plus disponible.\n"
+"Toutes les modifications de paramètres effectuées lors de cette session seront conservées de façon permanente.\n"
+"Cette action est irréversible — la fonction Restaurer ne sera plus disponible.\n"
"\n"
-"Voulez-vous continuer ?"
+"Souhaitez-vous continuer ?"
#, python-format
msgid "Action %s has been edited but its target output object(s) no longer exist — skipping."
-msgstr "L'action %s a été modifiée mais son ou ses objets de sortie cibles n'existent plus — saut de l'action."
+msgstr "L'action %s a été modifiée mais son ou ses objets de sortie cibles n'existent plus — ignorée."
#, python-format
msgid "Action %s uses pattern %r which is not recomputable yet."
-msgstr "L'action %s utilise le modèle %r qui n'est pas encore retraitable."
+msgstr "L'action %s utilise le motif %r qui n'est pas encore recalculable."
#, python-format
msgid "Recompute failed for action %s: %s"
-msgstr "Le recalcul a échoué pour l'action %s : %s"
+msgstr "Échec du recalcul pour l'action %s : %s"
#, python-format
msgid ""
@@ -1764,29 +1875,28 @@ msgstr ""
#, python-format
msgid "Action %s: source object was deleted — skipping."
-msgstr "L'action %s : l'objet source a été supprimé — saut de l'action."
-
-#, python-format
-msgid "Action %s: all source objects were deleted — skipping."
-msgstr "L'action %s : tous les objets source ont été supprimés — saut de l'action."
+msgstr "Action %s : l'objet source a été supprimé — ignorée."
#, python-format
msgid "Action %s: missing source(s) for output #%d — skipping."
-msgstr "L'action %s : source(s) manquante(s) pour la sortie n°%d — saut de l'action."
+msgstr "Action %s : source(s) manquante(s) pour la sortie n°%d — ignorée."
#, python-format
msgid "Action %s: source object(s) were deleted — skipping."
-msgstr "L'action %s : objet(s) source supprimé(s) — saut de l'action."
+msgstr "Action %s : le ou les objets source ont été supprimés — ignorée."
#, python-format
msgid "Action %s: %d analysed object(s) were deleted — skipping."
-msgstr "L'action %s : %d objet(s) analysé(s) ont été supprimé(s) — saut de l'action."
+msgstr "Action %s : %d objet(s) analysé(s) ont été supprimés — ignorée."
msgid "Cascade recompute"
-msgstr "Retraiter"
+msgstr "Recalcul en cascade"
msgid "Some downstream actions could not be recomputed:"
-msgstr "Certaines actions en aval n'ont pas pu être retraitées :"
+msgstr "Certaines actions en aval n'ont pas pu être recalculées :"
+
+msgid "New image"
+msgstr "Nouvelle image"
msgid "Recent macros"
msgstr "Macros récentes"
@@ -1795,7 +1905,7 @@ msgid "Clear all"
msgstr "Tout effacer"
msgid "Untitled"
-msgstr "(sans titre)"
+msgstr "Sans titre"
msgid "Clear all recent macros?"
msgstr "Effacer toutes les macros récentes ?"
@@ -1881,6 +1991,9 @@ msgstr "Lorsqu'elle est fermée, une macro est détruite de manière définit
msgid "Do you want to continue?"
msgstr "Souhaitez-vous vraiment continuer ?"
+msgid "New signal"
+msgstr "Nouveau signal"
+
msgid "Creating geometric shapes"
msgstr "Création des formes géométriques"
@@ -1921,18 +2034,24 @@ msgstr "Modifier le répertoire"
msgid "Remove directory"
msgstr "Supprimer le répertoire"
+msgid "from"
+msgstr "depuis"
+
msgid "Plugin Configuration"
msgstr "Configuration des plugins"
msgid "Enable/disable plugins"
msgstr "Activer/désactiver les plugins"
-msgid "Plugin search paths"
-msgstr "Chemins de recherche des plugins"
+msgid "Plugin settings"
+msgstr "Paramètres des plugins"
msgid "Apply and reload plugins"
msgstr "Appliquer et recharger les plugins"
+msgid "Third-party plugins are globally disabled."
+msgstr "Les plugins tiers sont désactivés globalement."
+
msgid "Changes will be applied after clicking OK and reloading plugins."
msgstr "Les modifications seront appliquées après avoir cliqué sur OK et rechargé les plugins."
@@ -1958,6 +2077,15 @@ msgstr "Aucun répertoire de plugins supplémentaire n'est configuré."
msgid "Directories provided via the %s environment variable (multiple paths separated by '%s') also appear above as read-only entries. Changes take effect at DataLab startup."
msgstr "Les répertoires fournis via la variable d'environnement %s (plusieurs chemins séparés par '%s') apparaissent également ci-dessus en lecture seule. Les modifications prennent effet au démarrage de DataLab."
+msgid "Compatibility warnings"
+msgstr "Avertissements de compatibilité"
+
+msgid "Hide warnings for incompatible DataLab v0.20 plugins"
+msgstr "Masquer les avertissements pour les plugins DataLab v0.20 incompatibles"
+
+msgid "If enabled, DataLab will not warn you about v0.20 plugins that are no longer compatible with v1.0."
+msgstr "Si activé, DataLab ne vous avertira pas des plugins v0.20 qui ne sont plus compatibles avec v1.0."
+
msgid "Select plugin directory"
msgstr "Sélectionner un répertoire de plugins"
@@ -1979,32 +2107,17 @@ msgstr "Plugins désactivés"
msgid "Plugins with errors"
msgstr "Plugins en erreur"
-msgid "Reload Plugins"
-msgstr "Recharger les plugins"
-
-msgid "Plugin configuration has been saved. Do you want to reload plugins now to apply changes?"
-msgstr "La configuration des plugins a été enregistrée. Voulez-vous recharger les plugins maintenant pour appliquer les modifications ?"
-
-msgid "Plugin settings"
-msgstr "Paramètres des plugins"
-
-msgid "Third-party plugins are globally disabled."
-msgstr "Les plugins tiers sont désactivés globalement."
-
-msgid "Compatibility warnings"
-msgstr "Avertissements de compatibilité"
-
-msgid "Hide warnings for incompatible DataLab v0.20 plugins"
-msgstr "Masquer les avertissements pour les plugins DataLab v0.20 incompatibles"
-
msgid "Disable plugins globally"
msgstr "Désactiver globalement les plugins"
msgid "Enable plugins globally"
msgstr "Activer globalement les plugins"
-msgid "Enable them again from the plugin configuration dialog to use this feature."
-msgstr "Réactivez-les depuis la boîte de dialogue de configuration des plugins pour utiliser cette fonctionnalité."
+msgid "Reload Plugins"
+msgstr "Recharger les plugins"
+
+msgid "Plugin configuration has been saved. Do you want to reload plugins now to apply changes?"
+msgstr "La configuration des plugins a été enregistrée. Voulez-vous recharger les plugins maintenant pour appliquer les modifications ?"
msgid "Failed to deserialize processing parameters from JSON."
msgstr "Échec de la désérialisation des paramètres de traitement depuis le format JSON."
@@ -2050,9 +2163,6 @@ msgstr "En mode 'pairwise', vous devez sélectionner des objets dans au moins de
msgid "In pairwise mode, you need to select the same number of objects in each group."
msgstr "En mode 'pairwise', vous devez sélectionner le même nombre d'objets dans chaque groupe."
-msgid "Parameters"
-msgstr "Paramètres"
-
#, python-format
msgid "Calculating: %s"
msgstr "Calcul : %s"
@@ -2084,9 +2194,6 @@ msgstr "Supprimer la ROI"
msgid "Are you sure you want to remove ROI '%s'?"
msgstr "Êtes-vous sûr de vouloir supprimer la ROI '%s' ?"
-msgid "Regions of interest are already defined for this image.
Creating new ROIs from detection will replace the existing ones, which will be lost.
Do you want to continue?"
-msgstr "Des régions d'intérêt sont déjà définies pour cette image.
La création de nouvelles ROI par détection remplacera les existantes, qui seront perdues.
Voulez-vous continuer ?"
-
msgid "Sum"
msgstr "Addition"
@@ -2866,21 +2973,6 @@ msgstr "Mo"
msgid "Memory threshold below which a warning is displayed before loading any new data"
msgstr "Seuil de mémoire en dessous duquel un avertissement est affiché avant de charger de nouvelles données"
-msgid "Third-party plugins"
-msgstr "Plugins tiers"
-
-msgid "Enable or disable third-party plugins immediately. Changes are applied without restarting DataLab"
-msgstr "Activer ou désactiver immédiatement les plugins tiers. Les changements sont appliqués sans redémarrer DataLab"
-
-msgid "Ignore compatibility issues warning"
-msgstr "Ignorer l'avertissement de compatibilité"
-
-msgid "DataLab v0.20 plugins"
-msgstr "Plugins DataLab v0.20"
-
-msgid "If enabled, DataLab will not warn you about v0.20 plugins that are no longer compatible with v1.0."
-msgstr "Si activé, DataLab ne vous avertira pas des plugins v0.20 qui ne sont plus compatibles avec v1.0."
-
msgid "Settings for internal console, used for debugging or advanced users"
msgstr "Réglages de la console interne, utilisée pour le débogage ou les utilisateurs avancés"
@@ -3357,27 +3449,27 @@ msgid "You can show the tour again, or close this dialog box."
msgstr "Vous pouvez afficher la visite guidée à nouveau, ou fermer cette boîte de dialogue."
msgid "Save history file"
-msgstr "Enregistrer le fichier d'historique"
+msgstr "Enregistrer un fichier d'historique"
msgid "Open history file"
-msgstr "Ouvrir le fichier d'historique"
+msgstr "Ouvrir un fichier d'historique"
msgid "Imported"
-msgstr "Importer"
+msgstr "Importé"
msgid "Replaying compound 'multiple_1_to_1' actions is not supported yet."
msgstr "La relecture des actions composées 'multiple_1_to_1' n'est pas encore prise en charge."
msgid "Cannot replay 2-to-1 action: source object(s) missing."
-msgstr "Impossible de relire l'action 2-à-1 : objet(s) source manquant(s)."
+msgstr "Impossible de rejouer une action 2-à-1 : objet(s) source manquant(s)."
#, python-format
msgid "Failed to deserialize history DataSet kwarg %r."
-msgstr "Echec de la désérialisation de l'argument DataSet de l'historique %r."
+msgstr "Échec de la désérialisation de l'argument DataSet de l'historique %r."
#, python-format
msgid "Failed to deserialize history DataSet-list kwarg %r."
-msgstr "Echec de la désérialisation de l'argument DataSet-list de l'historique %r."
+msgstr "Échec de la désérialisation de l'argument DataSet-list de l'historique %r."
msgid "Session"
msgstr "Session"
@@ -3448,9 +3540,6 @@ msgstr "Créer une image avec un anneau"
msgid "Create image with a grid of gaussian spots"
msgstr "Créer une image avec une grille de spots gaussiens"
-msgid "New signal"
-msgstr "Nouveau signal"
-
msgid "Host application"
msgstr "Application hôte"
@@ -3526,12 +3615,12 @@ msgstr "Cliquer sur OK pour démarrer la démo.
Note : - La dé
msgid "Click OK to end demo."
msgstr "Cliquer sur OK pour terminer la démo."
-msgid "Context"
-msgstr "Contexte"
-
msgid "Error:"
msgstr "Erreur:"
+msgid "Context"
+msgstr "Contexte"
+
#, python-format
msgid "The file %s could not be read:"
msgstr "Le fichier %s n'a pas pu être ouvert :"
@@ -3748,18 +3837,18 @@ msgstr "Déplier la sélection"
msgid "HDF5 Browser"
msgstr "Explorateur HDF5"
-msgid "Value"
-msgstr "Valeur"
-
-msgid "Name"
-msgstr "Nom"
-
msgid "Size"
msgstr "Taille"
msgid "Type"
msgstr "Type"
+msgid "Value"
+msgstr "Valeur"
+
+msgid "Name"
+msgstr "Nom"
+
msgid "Unsupported data"
msgstr "Données non prises en charge"
@@ -3814,6 +3903,14 @@ msgstr "L'action est compatible avec l'état actuel de l'espace de travail."
msgid "Action is not compatible with the current workspace state."
msgstr "L'action n'est pas compatible avec l'état actuel de l'espace de travail."
+#, python-format
+msgid "Processing chain — %d step(s)"
+msgstr "Chaîne de traitement — %d étape(s)"
+
+#, python-brace-format
+msgid "Active recording session ({panel})."
+msgstr "Session d'enregistrement active ({panel})."
+
msgid "Image background selection"
msgstr "Sélection de l'arrière-plan de l'image"
@@ -3856,6 +3953,9 @@ msgstr "±{n} points"
msgid "±{n} rows × ±{n} columns"
msgstr "±{n} lignes × ±{n} colonnes"
+msgid "This image uses an integer data type, so it cannot contain NaN or infinite values. Replace special values is therefore not applicable."
+msgstr "Cette image utilise un type de données entier, elle ne peut donc pas contenir de valeurs NaN ou infinies. Le remplacement des valeurs spéciales n'est donc pas applicable."
+
msgid "Signal baseline selection"
msgstr "Sélection de la ligne de base du signal"
@@ -4095,9 +4195,6 @@ msgstr "Tout sélectionner"
msgid "Adding data to the plot"
msgstr "Ajout des données au graphique"
-msgid "Title"
-msgstr "Titre"
-
msgid "X label"
msgstr "Titre X"
@@ -4200,9 +4297,6 @@ msgstr "Image"
msgid "Dimensions"
msgstr "Dimensions"
-msgid "This image uses an integer data type, so it cannot contain NaN or infinite values. Replace special values is therefore not applicable."
-msgstr "Cette image utilise un type de données entier, elle ne peut donc pas contenir de valeurs NaN ou infinies. Le remplacement des valeurs spéciales n'est donc pas applicable."
-
msgid "Minimum value"
msgstr "Valeur minimum"
@@ -4250,4 +4344,3 @@ msgstr "Aucun contour n'a été trouvé pour la plage de niveaux sélectionnée.
msgid "Show contour plot..."
msgstr "Afficher le tracé de contours..."
-
diff --git a/datalab/objectmodel.py b/datalab/objectmodel.py
index 5976c3ae2..335af0d22 100644
--- a/datalab/objectmodel.py
+++ b/datalab/objectmodel.py
@@ -62,10 +62,20 @@ def set_number(obj: SignalObj | ImageObj | ObjectGroup, number: int) -> None:
def get_uuid(obj: SignalObj | ImageObj | ObjectGroup) -> str:
- """Get object UUID"""
+ """Get object UUID.
+
+ For data objects (signals/images), the UUID is stored in metadata under
+ the ``__uuid`` option. It is materialized on first access (via
+ :func:`set_uuid`) so that the returned value is stable across calls and
+ survives serialization.
+ """
if isinstance(obj, ObjectGroup):
return obj.uuid
- return obj.get_metadata_option("uuid", str(uuid4()))
+ uuid = obj.metadata.get("__uuid")
+ if not uuid:
+ set_uuid(obj)
+ uuid = obj.metadata["__uuid"]
+ return uuid
def set_uuid(obj: SignalObj | ImageObj | ObjectGroup) -> None:
diff --git a/datalab/tests/__init__.py b/datalab/tests/__init__.py
index 70625aac9..12dbdbe6c 100644
--- a/datalab/tests/__init__.py
+++ b/datalab/tests/__init__.py
@@ -58,6 +58,7 @@ def datalab_test_app_context(
save: bool = False,
console: bool | None = None,
exec_loop: bool = True,
+ history: bool = False,
) -> Generator[DLMainWindow, None, None]:
"""Context manager handling DataLab mainwindow creation and Qt event loop
with optional HDF5 file save and other options for testing purposes
@@ -68,6 +69,7 @@ def datalab_test_app_context(
save: whether to save HDF5 file (default: False)
console: whether to show console (default: None)
exec_loop: whether to execute Qt event loop (default: True)
+ history: whether to enable and show history tracking (default: False)
"""
if size is None:
size = 1200, 700
@@ -75,6 +77,10 @@ def datalab_test_app_context(
win: DLMainWindow | None = None
try:
win = DLMainWindow(console=console)
+ if not history:
+ win.historypanel.set_tracking_enabled(False)
+ win.historypanel.setEnabled(False)
+ win.docks[win.historypanel].hide()
if maximized:
win.showMaximized()
else:
diff --git a/datalab/tests/features/common/analysis_parameters_edit_unit_test.py b/datalab/tests/features/common/analysis_parameters_edit_unit_test.py
new file mode 100644
index 000000000..caad812f5
--- /dev/null
+++ b/datalab/tests/features/common/analysis_parameters_edit_unit_test.py
@@ -0,0 +1,83 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Analysis parameters edit unit test
+----------------------------------
+
+Test the editable "Analysis" tab of the Object Properties widget.
+
+This verifies that a 1-to-0 analysis operation (e.g. 2D peak detection) can be
+re-run in place with modified parameters through
+:meth:`ObjectProp.setup_analysis_tab` / :meth:`ObjectProp.apply_analysis_parameters`.
+"""
+
+# pylint: disable=invalid-name # Allows short reference names like x, y, ...
+# guitest: show
+
+from __future__ import annotations
+
+import sigima.params as sigima_param
+from sigima.tests.data import create_peak_image
+
+from datalab.config import Conf
+from datalab.env import execenv
+from datalab.gui.processor.base import extract_analysis_parameters
+from datalab.tests import datalab_test_app_context
+
+
+def test_analysis_parameters_edit_image():
+ """Test editing and re-running a 1-to-0 analysis via the Analysis tab."""
+ with datalab_test_app_context(console=False) as win:
+ execenv.print("Analysis parameters edit test (image peak detection):")
+ panel = win.imagepanel
+
+ # Create a multi-peak image guaranteed to yield detections
+ img = create_peak_image()
+ panel.add_object(img)
+
+ # Run 2D peak detection: a 1-to-0 analysis with an editable parameter
+ det_param = sigima_param.Peak2DDetectionParam.create(
+ create_rois=False, threshold=0.5
+ )
+ with Conf.proc.show_result_dialog.temp(False):
+ panel.processor.run_feature("peak_detection", det_param)
+
+ # The analysis parameters must be stored as a single 1-to-0 dataset
+ proc_params = extract_analysis_parameters(img)
+ assert proc_params is not None, "Analysis parameters should be stored"
+ assert proc_params.pattern == "1-to-0"
+ assert proc_params.param is not None
+ assert not isinstance(proc_params.param, list)
+ assert proc_params.param.threshold == 0.5
+ execenv.print(" ✓ Analysis parameters stored (threshold=0.5)")
+
+ # Set up the editable Analysis tab
+ objprop = panel.objprop
+ assert objprop.setup_analysis_tab(img) is True
+ assert objprop.analysis_param_editor is not None
+ execenv.print(" ✓ Analysis tab set up")
+
+ # Modify the threshold and (deliberately) enable ROI creation to verify
+ # the create_rois guard forces it back to False on apply
+ objprop.analysis_param_editor.dataset.threshold = 0.8
+ objprop.analysis_param_editor.dataset.create_rois = True
+
+ # Apply: re-run the analysis in place with the modified parameters
+ objprop.apply_analysis_parameters(img)
+
+ # The stored analysis parameters must reflect the new threshold
+ proc_params2 = extract_analysis_parameters(img)
+ assert proc_params2 is not None
+ assert proc_params2.param.threshold == 0.8, "Threshold change must be applied"
+ execenv.print(" ✓ Analysis re-ran with new threshold (0.8)")
+
+ # ROI guard: create_rois must have been forced to False (no ROI created)
+ assert proc_params2.param.create_rois is False, (
+ "create_rois must be forced to False on re-analysis"
+ )
+ assert not img.roi, "No ROI should be created on re-analysis"
+ execenv.print(" ✓ ROI creation guard held (create_rois=False)")
+
+
+if __name__ == "__main__":
+ test_analysis_parameters_edit_image()
diff --git a/datalab/tests/features/common/auto_analysis_recompute_unit_test.py b/datalab/tests/features/common/auto_analysis_recompute_unit_test.py
index 326cf66a8..fcb8fdc3d 100644
--- a/datalab/tests/features/common/auto_analysis_recompute_unit_test.py
+++ b/datalab/tests/features/common/auto_analysis_recompute_unit_test.py
@@ -1,14 +1,18 @@
# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
"""
-Unit test for automatic recomputation of 1-to-0 analysis operations.
+Unit test for on-demand recomputation of 1-to-0 analysis operations.
-This test verifies that analysis results (like centroid) are automatically updated
-when data changes through various methods:
+Analysis results (like centroid) are no longer recomputed automatically when the
+underlying data changes. Instead, the user refreshes them explicitly through the
+manual "Recompute" action, which is backed by
+``BaseProcessor.recompute_analysis``. This test verifies that, once triggered,
+analysis results are correctly updated after data changes through various methods:
- ROI modifications (adding, deleting ROIs)
- Data transformations via recompute_1_to_1 (modifying processing parameters)
-The tests create a Gaussian image, compute its centroid, then verify that:
+The tests create a Gaussian image, compute its centroid, then verify that, after an
+explicit recompute:
1. The centroid changes when a ROI is added to restrict the calculation region
2. Two centroid rows are generated when two ROIs are added
3. One centroid row remains after deleting the first ROI
@@ -46,7 +50,7 @@ def get_centroid_coords(obj) -> tuple[float, float] | None:
def test_analysis_recompute_after_roi_change():
- """Test automatic recomputation of analysis results when ROI changes."""
+ """Test on-demand recomputation of analysis results when ROI changes."""
with datalab_test_app_context(console=False) as win:
panel = win.imagepanel
@@ -64,13 +68,36 @@ def test_analysis_recompute_after_roi_change():
x0, y0 = centroid
print(f"\nInitial centroid (full image): ({x0:.1f}, {y0:.1f})")
+ call_count = [0]
+ original_compute_1_to_0 = panel.processor.compute_1_to_0
+
+ def counting_compute_1_to_0(*args, **kwargs):
+ call_count[0] += 1
+ return original_compute_1_to_0(*args, **kwargs)
+
+ panel.processor.compute_1_to_0 = counting_compute_1_to_0
+
+ new_title = f"{img.title} (edited)"
+ panel.objprop.properties.dataset.title = new_title
+ panel.properties_changed()
+ assert img.title == new_title
+ assert call_count[0] == 0
+ assert get_centroid_coords(img) == (x0, y0), (
+ "Property changes must preserve stale analysis results"
+ )
+
# Step 1: Add ROI (simulating edit_roi_graphically)
# Add a rectangular ROI in the upper-left quadrant
roi = create_image_roi("rectangle", [25, 25, 50, 50]) # x0, y0, width, height
img.roi = roi
panel.refresh_plot("selected", update_items=True)
- # Trigger auto-recompute by simulating ROI modification
- panel.processor.auto_recompute_analysis(img)
+ assert call_count[0] == 0
+ assert get_centroid_coords(img) == (x0, y0), (
+ "ROI changes must preserve stale analysis results until recomputation"
+ )
+ # Explicitly recompute the analysis (manual "Recompute" action)
+ panel.processor.recompute_analysis(img)
+ assert call_count[0] == 1
# Verify centroid was updated
centroid = get_centroid_coords(img)
@@ -93,7 +120,13 @@ def test_analysis_recompute_after_roi_change():
roi1.add_roi(roi2) # Combine both ROIs
img.roi = roi1
panel.refresh_plot("selected", update_items=True)
- panel.processor.auto_recompute_analysis(img)
+ stale_centroid = get_centroid_coords(img)
+ assert call_count[0] == 1
+ assert stale_centroid == (x1, y1), (
+ "ROI changes must not update existing analysis results automatically"
+ )
+ panel.processor.recompute_analysis(img)
+ assert call_count[0] == 2
# Verify centroid now has TWO rows (one for each ROI)
adapter = GeometryAdapter.from_obj(img, "centroid")
@@ -108,8 +141,14 @@ def test_analysis_recompute_after_roi_change():
f"ROI 1 (lower-right): ({x2_roi1:.1f}, {y2_roi1:.1f})"
)
- # Step 3: Delete the first ROI using delete_single_roi
+ # Step 3: Delete the first ROI using delete_single_roi, then recompute
panel.processor.delete_single_roi(roi_index=0)
+ assert call_count[0] == 2
+ assert np.array_equal(get_centroid_coords(img), coords[0]), (
+ "ROI deletion must preserve stale analysis results until recomputation"
+ )
+ panel.processor.recompute_analysis(img)
+ assert call_count[0] == 3
# Verify centroid now has ONE row (for the remaining ROI)
adapter = GeometryAdapter.from_obj(img, "centroid")
@@ -128,8 +167,15 @@ def test_analysis_recompute_after_roi_change():
f"Y centroid should be close to {y2_roi1:.1f}, got {y3:.1f}"
)
- # Step 4: Delete all remaining ROIs using delete_regions_of_interest
+ # Step 4: Delete all remaining ROIs using delete_regions_of_interest, then
+ # recompute
panel.processor.delete_regions_of_interest()
+ assert call_count[0] == 3
+ assert get_centroid_coords(img) == (x3, y3), (
+ "Deleting ROIs must not update analysis results automatically"
+ )
+ panel.processor.recompute_analysis(img)
+ assert call_count[0] == 4
# Verify centroid was updated back to original
centroid = get_centroid_coords(img)
@@ -144,12 +190,12 @@ def test_analysis_recompute_after_roi_change():
f"Y centroid should return to {y0:.1f}, got {y4:.1f}"
)
- print("\n✓ All ROI auto-recompute tests passed!")
+ print("\n✓ All ROI on-demand recompute tests passed!")
def test_analysis_recompute_after_recompute_1_to_1():
- """Test automatic recomputation of analysis after processing parameter changes."""
- with datalab_test_app_context(console=False) as win:
+ """Test on-demand recomputation of analysis after processing parameter changes."""
+ with datalab_test_app_context(console=False, history=True) as win:
panel = win.imagepanel
# Create a Gaussian image offset from center
@@ -199,13 +245,37 @@ def test_analysis_recompute_after_recompute_1_to_1():
editor = panel.objprop.processing_param_editor
editor.dataset.angle = 90.0 # Change from 45° to 90°
+ # In-place recompute happens when the History panel is in edit mode
+ # (otherwise a new object is created).
+ win.historypanel.toggle_edit_mode(True)
+
+ call_count = [0]
+ original_compute_1_to_0 = panel.processor.compute_1_to_0
+
+ def counting_compute_1_to_0(*args, **kwargs):
+ call_count[0] += 1
+ return original_compute_1_to_0(*args, **kwargs)
+
+ panel.processor.compute_1_to_0 = counting_compute_1_to_0
+
# Apply the modified parameters (this triggers recompute_1_to_1)
report = panel.objprop.apply_processing_parameters(interactive=False)
assert report.success, f"Recompute failed: {report.message}"
+ assert call_count[0] == 0, (
+ "Processing data/property changes must not recompute analysis automatically"
+ )
+ assert get_centroid_coords(img_rotated) == (x0, y0), (
+ "Processing data/property changes must preserve stale analysis results"
+ )
print("Processing parameters recomputed with new angle (90°)")
- # Verify centroid was automatically recomputed
+ # Analysis results are no longer refreshed automatically: trigger the manual
+ # recompute explicitly (as the "Recompute" action does)
+ panel.processor.recompute_analysis(img_rotated)
+ assert call_count[0] == 1
+
+ # Verify centroid was recomputed on the updated data
centroid = get_centroid_coords(img_rotated)
assert centroid is not None, "Centroid should still exist after recompute"
x1, y1 = centroid
@@ -232,18 +302,18 @@ def test_analysis_recompute_after_recompute_1_to_1():
f"to ({x1:.1f}, {y1:.1f}) after changing rotation angle"
)
- print("\n✓ Recompute_1_to_1 auto-analysis test passed!")
+ print("\n✓ Recompute_1_to_1 analysis test passed!")
def test_analysis_recompute_avoids_redundant_calculations():
- """Test that auto-recompute doesn't cause O(n²) redundant calculations.
+ """Test that per-object recompute doesn't cause O(n²) redundant calculations.
- This test verifies that when multiple objects have ROIs modified simultaneously,
- the analysis is recomputed only once per object, not once per object × number
- of selected objects.
+ This test verifies that when the analysis is recomputed for multiple objects,
+ it is computed only once per object, not once per object × number of selected
+ objects.
Regression test for bug: When N images were selected with statistics computed,
- adding a ROI would trigger N × N = N² calculations instead of N.
+ recomputing would trigger N × N = N² calculations instead of N.
"""
with datalab_test_app_context(console=False) as win:
panel = win.imagepanel
@@ -275,7 +345,7 @@ def test_analysis_recompute_avoids_redundant_calculations():
print(f"\nInitial statistics computed for {n_images} images")
- # Track how many times compute_1_to_0 is called during auto-recompute
+ # Track how many times compute_1_to_0 is called during recompute
# by counting the calls via a wrapper
call_count = [0] # Use list to allow modification in closure
original_compute_1_to_0 = panel.processor.compute_1_to_0
@@ -292,8 +362,8 @@ def counting_compute_1_to_0(*args, **kwargs):
roi = create_image_roi("rectangle", [25, 25, 50, 50])
for img in images:
img.roi = roi
- # Simulate the auto-recompute that happens after ROI modification
- panel.processor.auto_recompute_analysis(img)
+ # Explicitly recompute the analysis for this object
+ panel.processor.recompute_analysis(img)
# With the fix, compute_1_to_0 should be called exactly N times
# (once per object), not N² times
@@ -312,7 +382,7 @@ def counting_compute_1_to_0(*args, **kwargs):
# Restore the original method
panel.processor.compute_1_to_0 = original_compute_1_to_0
- print(f"\n✓ Auto-recompute correctly called {n_images} times (no O(n²) issue)")
+ print(f"\n✓ Recompute correctly called {n_images} times (no O(n²) issue)")
if __name__ == "__main__":
diff --git a/datalab/tests/features/common/history_model_unit_test.py b/datalab/tests/features/common/history_model_unit_test.py
new file mode 100644
index 000000000..348108336
--- /dev/null
+++ b/datalab/tests/features/common/history_model_unit_test.py
@@ -0,0 +1,145 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Pure unit contracts for history persistence, copying and replay mapping."""
+
+from __future__ import annotations
+
+import os
+import tempfile
+
+from datalab.h5.native import NativeH5Writer
+from datalab.history.action import HistoryAction
+from datalab.history.core import HISTORY_ACTION_SCHEMA_VERSION, HISTORY_SCHEMA_VERSION
+from datalab.history.session import HistorySession
+from datalab.tests.features.common.history_test_helpers import (
+ build_history_action,
+ build_replay_map,
+ build_workspace_state,
+ delete_hdf5_items_by_name,
+ read_history_sessions,
+)
+
+
+def test_action_hdf5_current_and_legacy_contract() -> None:
+ """Round-trip current fields and apply all legacy defaults."""
+ action = build_history_action()
+ action.plugin_origin = {
+ "module": "example.plugin",
+ "metadata": {"entry_points": ["difference"]},
+ }
+ action.snapshot_kwargs()
+ action.kwargs["pairwise"] = True
+ session = HistorySession(number=1)
+ session.add_action(action)
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = os.path.join(tmpdir, "history.dlhist")
+ with NativeH5Writer(path) as writer:
+ writer.write_object_list([session], "history_session")
+ current = read_history_sessions(path)[0].actions[0]
+ for attribute in ("selection", "states", "titles"):
+ values = getattr(action.state, attribute)
+ setattr(action.state, attribute, {"Signal Panel": values["signal"]})
+ with NativeH5Writer(path) as writer:
+ writer.write_object_list([session], "history_session")
+ for field in ("schema_version", "uuid", "saved_kwargs", "output_uuids"):
+ delete_hdf5_items_by_name(writer.h5, field)
+ delete_hdf5_items_by_name(writer.h5, "object_metadata")
+ legacy = read_history_sessions(path)[0].actions[0]
+ assert current.uuid == action.uuid
+ assert current.schema_version == HISTORY_ACTION_SCHEMA_VERSION
+ assert current.output_uuids == ["output-uuid"]
+ assert current.plugin_origin == action.plugin_origin
+ assert current.has_pending_edits and bool(current.kwargs["pairwise"])
+ assert legacy.schema_version == HISTORY_SCHEMA_VERSION
+ assert legacy.uuid != action.uuid and legacy.output_uuids == []
+ assert not legacy.has_pending_edits and legacy.state.object_metadata == {}
+ assert legacy.state.selection == {"signal": ["source-uuid"]}
+ assert legacy.state.states == {"signal": ["(10,)"]}
+ assert legacy.state.titles == {"signal": ["Source"]}
+
+
+def test_action_copy_remaps_all_uuid_references() -> None:
+ """Copy an action independently and rewrite every captured UUID field."""
+ action = build_history_action()
+ action.plugin_origin = {
+ "module": "example.plugin",
+ "metadata": {"entry_points": ["difference"]},
+ }
+ copied = action.copy_with_uuid_remap(
+ {
+ "signal": {
+ "source-uuid": "new-source",
+ "second-uuid": "new-second",
+ "output-uuid": "new-output",
+ }
+ }
+ )
+ assert copied is not action and copied.uuid != action.uuid
+ assert copied.state.selection == {"signal": ["new-source"]}
+ assert copied.state.object_metadata == {
+ "signal": {"new-source": {"shape": [10], "ndim": 1, "title": "Source"}}
+ }
+ assert copied.kwargs["obj2_uuids"] == "new-second"
+ assert copied.output_uuids == ["new-output"]
+ assert copied.plugin_origin == action.plugin_origin
+ copied.state.object_metadata["signal"]["new-source"]["shape"] = [20]
+ copied.plugin_origin["metadata"]["entry_points"].append("average")
+ assert action.state.object_metadata["signal"]["source-uuid"]["shape"] == [10]
+ assert action.plugin_origin["metadata"]["entry_points"] == ["difference"]
+
+
+def test_replay_uuid_map_matches_exact_title_and_position() -> None:
+ """Match replay inputs by exact UUID, unique title, then panel position."""
+ replay_map, _signal_model, _image_model = build_replay_map(
+ [("same", "Exact"), ("new-position", "Other"), ("new-title", "Named")]
+ )
+ replay_map.unclaimed["signal"] = ["same", "new-position", "new-title"]
+ state = build_workspace_state(
+ ["same", "old-title", "old-position"], ["Exact", "Named", "Recorded"]
+ )
+ action = HistoryAction(
+ kind=HistoryAction.KIND_COMPUTE, panel_str="signal", state=state
+ )
+ replay_map.claim_action_inputs(action)
+ assert replay_map.mapping["signal"] == {
+ "same": "same",
+ "old-title": "new-title",
+ "old-position": "new-position",
+ }
+ assert replay_map.unclaimed["signal"] == []
+
+
+def test_replay_uuid_map_preserves_operands_and_tracks_changes() -> None:
+ """Claim obj2 before primary inputs and track panel-local object changes."""
+ replay_map, signal_model, image_model = build_replay_map(
+ [("new-second", "Second"), ("new-primary", "Primary")],
+ [("image-old", "Image")],
+ )
+ replay_map.unclaimed["signal"] = ["new-second", "new-primary"]
+ action = HistoryAction(
+ kind=HistoryAction.KIND_COMPUTE,
+ panel_str="signal",
+ pattern="2_to_1",
+ kwargs={"obj2_uuids": ["old-second"]},
+ state=build_workspace_state(["old-primary"], ["Primary"]),
+ )
+ replay_map.claim_action_inputs(action)
+ assert replay_map.mapping["signal"] == {
+ "old-second": "new-second",
+ "old-primary": "new-primary",
+ }
+ before = replay_map.snapshot_object_ids()
+ signal_model.add("signal-new", "Created")
+ replay_map.capture_changes(
+ HistoryAction(
+ kind=HistoryAction.KIND_UI,
+ state=build_workspace_state(["old-new"]),
+ ),
+ before,
+ )
+ assert replay_map.mapping["signal"]["old-new"] == "signal-new"
+ before = replay_map.snapshot_object_ids()
+ signal_model.remove("signal-new")
+ replay_map.capture_changes(HistoryAction(), before)
+ assert "old-new" not in replay_map.mapping["signal"]
+ assert image_model.get_object_ids() == ["image-old"]
diff --git a/datalab/tests/features/common/history_panel_app_test.py b/datalab/tests/features/common/history_panel_app_test.py
new file mode 100644
index 000000000..917132362
--- /dev/null
+++ b/datalab/tests/features/common/history_panel_app_test.py
@@ -0,0 +1,66 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+History Panel application test
+(essentially for the screenshot...)
+
+Records a representative sequence of UI and computation actions and grabs
+a screenshot of the history panel, used in the documentation
+(:ref:`historypanel`).
+"""
+
+# guitest: show
+
+import sigima.objects
+import sigima.proc.signal as sips
+
+from datalab import config
+from datalab.tests import datalab_test_app_context
+from datalab.utils import qthelpers as qth
+
+
+def test_history_panel(screenshots: bool = False) -> None:
+ """Record a representative session and grab the History Panel screenshot."""
+ config.reset() # Reset configuration (remove configuration file and initialize it)
+ with datalab_test_app_context(
+ console=False, exec_loop=not screenshots, history=True
+ ) as win:
+ history = win.historypanel
+ history.toggle_record_mode(True)
+
+ panel = win.signalpanel
+
+ # [New Voigt, New Lorentzian, New Lorentzian]
+ panel.new_object(param=sigima.objects.VoigtParam(), edit=False)
+ panel.new_object(param=sigima.objects.LorentzParam(), edit=False)
+ panel.new_object(param=sigima.objects.LorentzParam(), edit=False)
+
+ # Remove the third signal
+ panel.objview.select_objects([3])
+ panel.remove_object(force=True)
+
+ # New Gaussian
+ panel.new_object(param=sigima.objects.GaussParam(), edit=False)
+
+ # Average of the 3 remaining signals
+ panel.objview.select_objects([1, 2, 3])
+ panel.processor.run_feature(sips.average)
+
+ # Add Gaussian noise to the average
+ noise_param = sigima.objects.NormalDistributionParam()
+ noise_param.sigma = 0.05
+ panel.objview.select_objects([4])
+ panel.processor.run_feature(sips.add_gaussian_noise, noise_param)
+
+ # Gaussian fit
+ panel.processor.run_feature(sips.gaussian_fit)
+
+ # Make sure the History Panel dock is raised over the Macro Panel
+ win.docks[history].raise_()
+
+ if screenshots:
+ qth.grab_save_window(history, "history_panel", add_timestamp=False)
+
+
+if __name__ == "__main__":
+ test_history_panel()
diff --git a/datalab/tests/features/common/history_panel_test.py b/datalab/tests/features/common/history_panel_test.py
new file mode 100644
index 000000000..0644cc830
--- /dev/null
+++ b/datalab/tests/features/common/history_panel_test.py
@@ -0,0 +1,158 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""History panel replay and cross-panel navigation contracts."""
+
+from __future__ import annotations
+
+import sigima.proc.image as sipi
+import sigima.proc.signal as sips
+from qtpy import QtCore as QC
+from qtpy import QtWidgets as QW
+from sigima.tests.data import create_paracetamol_signal, create_sincos_image
+
+from datalab.gui.panel.history import HistoryTree
+from datalab.objectmodel import get_uuid
+from datalab.tests import datalab_test_app_context
+from datalab.tests.features.common.history_test_helpers import (
+ build_signal_chain,
+ get_tree_item,
+ is_session_bold,
+ select_tree_entry,
+)
+
+
+def test_panel_replay_restores_selection_without_outputs() -> None:
+ """Use panel replay to restore selection without recording or new output."""
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ history.toggle_record_mode(True)
+ panel.add_object(create_paracetamol_signal())
+ source_uuid = get_uuid(panel.objmodel.get_object_from_number(1))
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.derivative)
+ action = history[len(history)]
+ output_uuid = action.output_uuids[0]
+ select_tree_entry(history, action.uuid)
+ assert panel.objview.get_sel_object_uuids() == [output_uuid]
+ object_count, action_count = len(panel.objmodel), len(history)
+ history.replay_restore_actions(replay=True, restore_selection=True)
+ assert (
+ len(panel.objmodel),
+ len(history),
+ panel.objview.get_sel_object_uuids(),
+ ) == (object_count, action_count, [source_uuid])
+ assert action in history.history_sessions[-1].actions and (
+ history.runtime.objects.action_output_uuids[action.uuid] == [output_uuid]
+ )
+ assert (
+ output_uuid in panel.objmodel.get_object_ids()
+ and history.runtime.objects.output_to_action[output_uuid] == action.uuid
+ )
+ panel.objview.select_objects([output_uuid])
+ panel.remove_object(force=True)
+ assert (
+ action in history.history_sessions[-1].actions
+ and output_uuid not in panel.objmodel.get_object_ids()
+ and action.uuid not in history.runtime.objects.action_output_uuids
+ and output_uuid not in history.runtime.objects.output_to_action
+ )
+ select_tree_entry(history, action.uuid)
+ history.replay_restore_actions(replay=False, restore_selection=True)
+ assert panel.objview.get_sel_object_uuids() == [source_uuid]
+
+
+def test_cross_panel_sessions_navigation_and_tree_state() -> None:
+ """Coordinate active sessions, navigation, tree state and selection fallback."""
+ with datalab_test_app_context(history=True) as win:
+ history = win.historypanel
+ signal_panel, image_panel = win.signalpanel, win.imagepanel
+ history.toggle_record_mode(True)
+ signal_chain = build_signal_chain(signal_panel, history)
+ first_signal_action, middle_signal_action, last_signal_action = (
+ signal_chain.actions
+ )
+ signal_uuid = first_signal_action.state.selection["signal"][0]
+ signal_session = next(
+ session
+ for session in history.history_sessions
+ if first_signal_action in session.actions
+ )
+ assert all(action in signal_session.actions for action in signal_chain.actions)
+ navigation_states = []
+ select_tree_entry(history, first_signal_action.uuid)
+ navigation_states.append(
+ (
+ history.ui.actions["step_prev"].isEnabled(),
+ history.ui.actions["step_next"].isEnabled(),
+ )
+ )
+ select_tree_entry(history, middle_signal_action.uuid)
+ navigation_states.append(
+ (
+ history.ui.actions["step_prev"].isEnabled(),
+ history.ui.actions["step_next"].isEnabled(),
+ )
+ )
+ select_tree_entry(history, last_signal_action.uuid)
+ navigation_states.append(
+ (
+ history.ui.actions["step_prev"].isEnabled(),
+ history.ui.actions["step_next"].isEnabled(),
+ )
+ )
+ assert navigation_states == [(False, True), (True, True), (True, False)]
+ image_panel.add_object(create_sincos_image())
+ image_panel.objview.select_objects([1])
+ image_panel.processor.run_feature(sipi.inverse)
+ image_action = history[len(history)]
+ image_session = history.navigation.get_active_session("image")
+ assert (
+ history.navigation.get_active_session("signal") is signal_session
+ and image_session is not None
+ and signal_session is not image_session
+ )
+ bold_before = (
+ is_session_bold(history, signal_session),
+ is_session_bold(history, image_session),
+ )
+ history.tree.populate_tree(history.history_sessions)
+ assert bold_before == (True, True) and (
+ is_session_bold(history, signal_session),
+ is_session_bold(history, image_session),
+ ) == (True, True)
+ output_uuid = first_signal_action.output_uuids[0]
+ select_tree_entry(history, first_signal_action.uuid)
+ assert signal_panel.objview.get_sel_object_uuids() == [output_uuid]
+ signal_panel.objview.select_objects([output_uuid])
+ signal_panel.remove_object(force=True)
+ history.toggle_record_mode(False)
+ image_panel.objview.select_objects([1])
+ image_panel.remove_object(force=True)
+ history.refresh_compatibility_items()
+ tree_action_uuids = set()
+ iterator = QW.QTreeWidgetItemIterator(history.tree)
+ while iterator.value():
+ uuid = iterator.value().data(0, QC.Qt.UserRole)
+ if uuid is not None:
+ tree_action_uuids.add(uuid)
+ iterator += 1
+ image_item = get_tree_item(history, image_action.uuid)
+ assert (
+ all(
+ first_signal_action not in session.actions
+ for session in history.history_sessions
+ )
+ and middle_signal_action in signal_session.actions
+ and last_signal_action in signal_session.actions
+ and middle_signal_action.state.selection["signal"] == [signal_uuid]
+ and first_signal_action.uuid
+ not in history.runtime.objects.action_output_uuids
+ and output_uuid not in history.runtime.objects.output_to_action
+ and first_signal_action.uuid not in tree_action_uuids
+ and {middle_signal_action.uuid, last_signal_action.uuid}.issubset(
+ tree_action_uuids
+ )
+ and image_item.data(0, HistoryTree.COMPATIBILITY_ROLE) is False
+ and image_item.foreground(0).color().isValid()
+ and image_item.data(0, QC.Qt.UserRole) == image_action.uuid
+ )
diff --git a/datalab/tests/features/common/history_test_helpers.py b/datalab/tests/features/common/history_test_helpers.py
new file mode 100644
index 000000000..d486a607d
--- /dev/null
+++ b/datalab/tests/features/common/history_test_helpers.py
@@ -0,0 +1,186 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Typed builders and selectors for History panel tests."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from types import SimpleNamespace
+from typing import TYPE_CHECKING, Any, cast
+
+import sigima.params
+import sigima.proc.signal as sips
+from qtpy import QtCore as QC
+from qtpy import QtWidgets as QW
+from sigima.tests.data import create_paracetamol_signal
+
+from datalab.h5.native import NativeH5Reader
+from datalab.history.action import HistoryAction
+from datalab.history.replaymap import ReplayUuidMap
+from datalab.history.session import HistorySession
+from datalab.history.workspace_state import WorkspaceState
+
+if TYPE_CHECKING:
+ from datalab.gui.panel.history import HistoryPanel
+ from datalab.gui.panel.signal import SignalPanel
+
+
+@dataclass(frozen=True)
+class SignalChain:
+ """Objects and actions produced by a three-step signal chain."""
+
+ actions: tuple[HistoryAction, HistoryAction, HistoryAction]
+ outputs: tuple[Any, Any, Any]
+
+
+class ReplayObjectModel:
+ """Minimal ordered object model used by replay-map tests."""
+
+ def __init__(self, objects: list[tuple[str, str]]) -> None:
+ self.objects = {uuid: SimpleNamespace(title=title) for uuid, title in objects}
+
+ def __getitem__(self, uuid: str) -> SimpleNamespace:
+ """Return an object by UUID."""
+ return self.objects[uuid]
+
+ def get_object_ids(self) -> list[str]:
+ """Return object UUIDs in panel order."""
+ return list(self.objects)
+
+ def add(self, uuid: str, title: str) -> None:
+ """Add an object to the model."""
+ self.objects[uuid] = SimpleNamespace(title=title)
+
+ def remove(self, uuid: str) -> None:
+ """Remove an object from the model."""
+ self.objects.pop(uuid)
+
+
+def build_replay_map(
+ signal_objects: list[tuple[str, str]],
+ image_objects: list[tuple[str, str]] | None = None,
+) -> tuple[ReplayUuidMap, ReplayObjectModel, ReplayObjectModel]:
+ """Build a replay map backed by minimal signal and image panels."""
+ signal_model = ReplayObjectModel(signal_objects)
+ image_model = ReplayObjectModel(image_objects or [])
+ signal_panel = SimpleNamespace(PANEL_STR_ID="signal", objmodel=signal_model)
+ image_panel = SimpleNamespace(PANEL_STR_ID="image", objmodel=image_model)
+ panels = cast(Any, (signal_panel, image_panel))
+ return ReplayUuidMap(panels), signal_model, image_model
+
+
+def build_workspace_state(
+ selection: list[str], titles: list[str] | None = None
+) -> WorkspaceState:
+ """Build a signal workspace state with stable object metadata."""
+ state = WorkspaceState()
+ state.selection = {"signal": selection}
+ state.states = {"signal": ["(10,)"] * len(selection)}
+ state.titles = {"signal": titles or [f"Object {index}" for index in selection]}
+ state.object_metadata = {
+ "signal": {
+ uuid: {"shape": [10], "ndim": 1, "title": title}
+ for uuid, title in zip(selection, state.titles["signal"])
+ }
+ }
+ return state
+
+
+def build_history_action() -> HistoryAction:
+ """Build a serializable compute action containing every UUID reference."""
+ action = HistoryAction(
+ title="Difference",
+ kind=HistoryAction.KIND_COMPUTE,
+ panel_str="signal",
+ func_name="difference",
+ pattern="2_to_1",
+ kwargs={"obj2_uuids": ["second-uuid"], "pairwise": False},
+ state=build_workspace_state(["source-uuid"], ["Source"]),
+ )
+ action.output_uuids = ["output-uuid"]
+ return action
+
+
+def add_paracetamol_signals(panel: SignalPanel, count: int) -> list[str]:
+ """Add paracetamol signals and return their UUIDs in panel order."""
+ for _index in range(count):
+ panel.add_object(create_paracetamol_signal())
+ return panel.objmodel.get_object_ids()[-count:]
+
+
+def build_signal_chain(panel: SignalPanel, history: HistoryPanel) -> SignalChain:
+ """Build Gaussian, derivative and moving-average processing outputs."""
+ add_paracetamol_signals(panel, 1)
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(
+ sips.gaussian_filter, sigima.params.GaussianParam.create(sigma=1.5)
+ )
+ first_action = history[len(history)]
+ first_output = panel.objmodel.get_object_from_number(2)
+ panel.objview.select_objects([2])
+ panel.processor.run_feature(sips.derivative)
+ second_action = history[len(history)]
+ second_output = panel.objmodel.get_object_from_number(3)
+ panel.objview.select_objects([3])
+ parameter = sigima.params.MovingAverageParam.create(n=3)
+ panel.processor.run_feature(sips.moving_average, parameter)
+ third_action = history[len(history)]
+ third_output = panel.objmodel.get_object_from_number(4)
+ return SignalChain(
+ (first_action, second_action, third_action),
+ (first_output, second_output, third_output),
+ )
+
+
+def read_history_sessions(
+ path: str, section: str = "history_session"
+) -> list[HistorySession]:
+ """Read serialized history sessions from a file."""
+ with NativeH5Reader(path) as reader:
+ return reader.read_object_list(section, HistorySession)
+
+
+def delete_hdf5_items_by_name(group: Any, item_name: str) -> None:
+ """Delete HDF5 attributes and groups with a name recursively."""
+ if item_name in group.attrs:
+ del group.attrs[item_name]
+ if not hasattr(group, "keys"):
+ return
+ for key in list(group.keys()):
+ if key == item_name:
+ del group[key]
+ else:
+ delete_hdf5_items_by_name(group[key], item_name)
+
+
+def get_tree_item(history: HistoryPanel, uuid: str) -> QW.QTreeWidgetItem:
+ """Return the tree item identified by an entry UUID."""
+ iterator = QW.QTreeWidgetItemIterator(history.tree)
+ while iterator.value():
+ item = iterator.value()
+ if item.data(0, QC.Qt.UserRole) == uuid:
+ return item
+ iterator += 1
+ raise LookupError(uuid)
+
+
+def select_tree_entry(history: HistoryPanel, uuid: str) -> None:
+ """Select the tree item identified by an entry UUID."""
+ item = get_tree_item(history, uuid)
+ history.tree.clearSelection()
+ history.tree.setCurrentItem(item)
+ item.setSelected(True)
+
+
+def select_tree_session(history: HistoryPanel, session: HistorySession) -> None:
+ """Select a session's top-level tree item."""
+ item = history.tree.topLevelItem(history.history_sessions.index(session))
+ history.tree.clearSelection()
+ history.tree.setCurrentItem(item)
+ item.setSelected(True)
+
+
+def is_session_bold(history: HistoryPanel, session: HistorySession) -> bool:
+ """Return whether a session's tree item uses a bold font."""
+ item = history.tree.topLevelItem(history.history_sessions.index(session))
+ return item is not None and item.font(0).bold()
diff --git a/datalab/tests/features/common/history_workflow_test.py b/datalab/tests/features/common/history_workflow_test.py
new file mode 100644
index 000000000..1a35ab1b4
--- /dev/null
+++ b/datalab/tests/features/common/history_workflow_test.py
@@ -0,0 +1,1004 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Application workflow contracts for the History panel."""
+
+from __future__ import annotations
+
+import os
+import tempfile
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import numpy as np
+import sigima.params
+import sigima.proc.signal as sips
+from sigima.tests.data import create_paracetamol_signal
+
+from datalab.adapters_metadata.common import ResultData
+from datalab.gui import historytools_ops as htools
+from datalab.gui.panel.history import HistoryAction
+from datalab.gui.panel.history import interactive_replay as hireplay
+from datalab.gui.panel.history import recompute as hrec
+from datalab.gui.panel.history.chainmodel import (
+ build_session_chains,
+ remap_processing_parameters,
+)
+from datalab.gui.processor.base import (
+ ProcessingParameters,
+ extract_analysis_parameters,
+ extract_processing_parameters,
+ insert_processing_parameters,
+)
+from datalab.gui.processor.catcher import CompOut
+from datalab.h5.native import NativeH5Reader, NativeH5Writer
+from datalab.objectmodel import get_uuid
+from datalab.tests import datalab_test_app_context
+from datalab.tests.features.common.history_test_helpers import (
+ add_paracetamol_signals,
+ build_signal_chain,
+ read_history_sessions,
+ select_tree_entry,
+ select_tree_session,
+)
+
+
+def assert_compute_action(
+ action: HistoryAction, pattern: str, selection: list[str]
+) -> None:
+ """Check the reusable recording invariant for a compute action."""
+ assert action.kind == HistoryAction.KIND_COMPUTE
+ assert action.pattern == pattern
+ assert action.state.selection["signal"] == selection
+ assert action.output_uuids
+
+
+def assert_duplicate_head(history, panel, session) -> None:
+ """Check the synthetic head of an operation-rooted duplicate."""
+ head = session.actions[0]
+ assert head.kind == HistoryAction.KIND_UI
+ assert head.method_name == "new_object"
+ assert not head.kwargs and not head.state.selection
+ assert len(head.output_uuids) == 1
+ clone_uuid = head.output_uuids[0]
+ assert history.runtime.objects.action_output_uuids[head.uuid] == [clone_uuid]
+ assert history.runtime.objects.output_to_action[clone_uuid] == head.uuid
+ assert clone_uuid in panel.objmodel.get_object_ids()
+
+
+def build_independent_signal_branch(panel, history) -> tuple[HistoryAction, ...]:
+ """Build a three-action branch using UUID-based selections."""
+ source_uuid = add_paracetamol_signals(panel, 1)[0]
+ panel.objview.select_objects([source_uuid])
+ panel.processor.run_feature(
+ sips.gaussian_filter, sigima.params.GaussianParam.create(sigma=1.5)
+ )
+ first_action = history[len(history)]
+ panel.objview.select_objects(first_action.output_uuids)
+ panel.processor.run_feature(sips.derivative)
+ second_action = history[len(history)]
+ panel.objview.select_objects(second_action.output_uuids)
+ panel.processor.run_feature(
+ sips.moving_average, sigima.params.MovingAverageParam.create(n=3)
+ )
+ return first_action, second_action, history[len(history)]
+
+
+def test_remap_processing_parameters_preserves_plugin_origin() -> None:
+ """Preserve plugin provenance while remapping processing source UUIDs."""
+ plugin_origin = {"module": "test_plugin.operations", "directory": "test_plugin"}
+ parameters = ProcessingParameters(
+ func_name="difference",
+ pattern="2-to-1",
+ source_uuids=["source-1", "source-2"],
+ plugin_origin=plugin_origin,
+ )
+
+ remapped = remap_processing_parameters(
+ parameters, {"source-1": "copy-1", "source-2": "copy-2"}
+ )
+
+ assert remapped.source_uuids == ["copy-1", "copy-2"]
+ assert remapped.plugin_origin == plugin_origin
+
+
+def test_history_recording_contract_and_output_index() -> None:
+ """Record producing patterns and index every output without replay entries."""
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ history.toggle_record_mode(True)
+ source_uuids = add_paracetamol_signals(panel, 2)
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.derivative)
+ derivative = history[len(history)]
+ panel.objview.select_objects([1, 2])
+ panel.processor.run_feature(sips.average)
+ average = history[len(history)]
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(
+ sips.difference, panel.objmodel.get_object_from_number(2)
+ )
+ difference = history[len(history)]
+ assert_compute_action(derivative, "1_to_1", [source_uuids[0]])
+ assert_compute_action(average, "n_to_1", source_uuids)
+ assert_compute_action(difference, "2_to_1", [source_uuids[0]])
+ assert difference.kwargs["obj2_uuids"] == [source_uuids[1]]
+ for action in (derivative, average, difference):
+ for output_uuid in action.output_uuids:
+ assert (
+ history.runtime.objects.output_to_action[output_uuid] == action.uuid
+ )
+ count_before = len(history)
+ derivative.replay(win, restore_selection=True, edit=False)
+ assert len(history) == count_before
+
+
+def test_session_replay_remaps_distinct_processing_patterns() -> None:
+ """Replay chained 1-to-1, n-to-1 and ordered 2-to-1 computations."""
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ history.toggle_record_mode(True)
+ add_paracetamol_signals(panel, 2)
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.derivative)
+ panel.objview.select_objects([2])
+ panel.processor.run_feature(sips.derivative)
+ panel.objview.select_objects([3, 4])
+ panel.processor.run_feature(sips.average)
+ panel.objview.select_objects([3])
+ panel.processor.run_feature(
+ sips.difference, panel.objmodel.get_object_from_number(4)
+ )
+ session = history.history_sessions[-1]
+ difference = session.actions[-1]
+ original_title = panel.objmodel[difference.output_uuids[0]].title
+ assert original_title.index("s003") < original_title.index("s004")
+ object_count = len(panel.objmodel)
+ action_count = len(history)
+ session.replay(win, restore_selection=False, edit=False)
+ assert len(panel.objmodel) == object_count + 4
+ assert len(history) == action_count
+ replayed = panel.objmodel.get_object_from_number(len(panel.objmodel))
+ assert "s" in replayed.title and "-" in replayed.title
+
+
+def test_analysis_replay_requires_explicit_edit() -> None:
+ """Skip an analysis during ordinary replay but allow explicit edit replay."""
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ history.toggle_record_mode(True)
+ add_paracetamol_signals(panel, 1)
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.stats)
+ analysis_action = history[len(history)]
+ assert analysis_action.pattern == "1_to_0"
+ with patch.object(panel.processor, "run_feature") as run_feature:
+ analysis_action.replay(win, restore_selection=True, edit=False)
+ run_feature.assert_not_called()
+ analysis_action.replay(win, restore_selection=True, edit=True)
+ run_feature.assert_called_once()
+
+
+def test_replay_resolves_feature_with_plugin_origin_and_paramclass() -> None:
+ """Resolve replayed features with their plugin and parameter identities."""
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ history.toggle_record_mode(True)
+ add_paracetamol_signals(panel, 1)
+ panel.objview.select_objects([1])
+ param = sigima.params.GaussianParam.create(sigma=1.5)
+ panel.processor.run_feature(sips.gaussian_filter, param)
+ action = history[len(history)]
+ plugin_origin = {
+ "module": "test_plugin.operations",
+ "directory": "test_plugin",
+ }
+ action.plugin_origin = plugin_origin
+
+ with patch.object(
+ panel.processor, "get_feature", wraps=panel.processor.get_feature
+ ) as get_feature:
+ action.replay(win, restore_selection=True, edit=False)
+
+ get_feature.assert_called_once_with(
+ action.func_name,
+ plugin_origin=plugin_origin,
+ paramclass_name=type(param).__name__,
+ )
+
+
+def test_replay_1_to_n_resolves_feature_with_first_paramclass() -> None:
+ """Resolve a 1-to-n feature with the first stored parameter identity."""
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ history.toggle_record_mode(True)
+ add_paracetamol_signals(panel, 1)
+ panel.objview.select_objects([1])
+ params = [sigima.params.GaussianParam.create(sigma=1.5)]
+ action = HistoryAction()
+ action.kind = HistoryAction.KIND_COMPUTE
+ action.pattern = "1_to_n"
+ action.target = "signalpanel"
+ action.panel_str = "signal"
+ action.func_name = sips.gaussian_filter.__name__
+ action.kwargs = {"params": params}
+
+ with (
+ patch.object(
+ panel.processor, "get_feature", return_value=sips.gaussian_filter
+ ) as get_feature,
+ patch.object(panel.processor, "run_feature"),
+ ):
+ action.replay_compute(win, edit=False)
+
+ get_feature.assert_called_once_with(
+ action.func_name,
+ plugin_origin=None,
+ paramclass_name=type(params[0]).__name__,
+ )
+
+
+def test_history_hdf5_pristine_load_and_nonempty_import() -> None:
+ """Distinguish pristine loading, non-empty import and missing history."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ history_path = os.path.join(tmpdir, "session.dlhist")
+ empty_path = os.path.join(tmpdir, "without_history.h5")
+ with datalab_test_app_context(history=True) as source:
+ history, panel = source.historypanel, source.signalpanel
+ history.toggle_record_mode(True)
+ add_paracetamol_signals(panel, 1)
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.derivative)
+ titles = [action.title for action in history]
+ assert history.save_to_dlhist_file(history_path)
+ with NativeH5Writer(empty_path) as writer:
+ panel.serialize_to_hdf5(writer)
+ with datalab_test_app_context(history=True) as target:
+ history, panel = target.historypanel, target.signalpanel
+ with NativeH5Reader(empty_path) as reader:
+ history.deserialize_from_hdf5(reader)
+ assert len(history) == 0
+ assert history.open_dlhist_file(history_path)
+ assert [action.title for action in history] == titles
+ assert history.runtime.objects.action_output_uuids
+ assert history.runtime.objects.output_to_action
+ with NativeH5Reader(empty_path) as reader:
+ history.deserialize_from_hdf5(reader)
+ assert not history.history_sessions
+ assert not history.runtime.objects.action_output_uuids
+ assert not history.runtime.objects.output_to_action
+ pristine_counts = (len(history.history_sessions), len(panel.objmodel))
+ panel.add_object(create_paracetamol_signal())
+ assert history.open_dlhist_file(history_path)
+ assert len(history.history_sessions) > pristine_counts[0]
+ assert len(panel.objmodel) > pristine_counts[1] + 1
+
+
+def test_duplicate_creation_and_operation_rooted_chains() -> None:
+ """Duplicate both root kinds and synthesize a head only when required."""
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ history.toggle_record_mode(True)
+ win.add_object(create_paracetamol_signal())
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.derivative)
+ first_source = history.history_sessions[-1]
+ first_source.actions[-1].plugin_origin = {
+ "module": "example.plugin",
+ "metadata": {"entry_points": ["derivative"]},
+ }
+ history.create_new_session(panel_str="signal")
+ win.add_object(create_paracetamol_signal())
+ panel.objview.select_objects([3])
+ panel.processor.run_feature(sips.derivative)
+ second_source = history.history_sessions[-1]
+ history.tree.clearSelection()
+ for source in (first_source, second_source):
+ source_item = history.tree.topLevelItem(
+ history.history_sessions.index(source)
+ )
+ source_item.setSelected(True)
+ htools.duplicate_selected_entries(history)
+ first_duplicate = history.history_sessions[1]
+ second_duplicate = history.history_sessions[3]
+ assert history.history_sessions == [
+ first_source,
+ first_duplicate,
+ second_source,
+ second_duplicate,
+ ]
+ duplicate = first_duplicate
+ assert len(duplicate.actions) == len(first_source.actions)
+ assert duplicate.actions[0].method_name == "new_object"
+ assert duplicate.actions[0].uuid != first_source.actions[0].uuid
+ assert set(duplicate.actions[0].output_uuids).isdisjoint(
+ first_source.actions[0].output_uuids
+ )
+ duplicate_compute = duplicate.actions[-1]
+ assert duplicate_compute.plugin_origin == first_source.actions[-1].plugin_origin
+ duplicate_compute.plugin_origin["metadata"]["entry_points"].append("average")
+ assert first_source.actions[-1].plugin_origin["metadata"]["entry_points"] == [
+ "derivative"
+ ]
+ duplicate_output = panel.objmodel[duplicate_compute.output_uuids[0]]
+ processing = extract_processing_parameters(duplicate_output)
+ assert processing is not None
+ assert processing.source_uuid == duplicate.actions[0].output_uuids[0]
+ assert history.runtime.objects.output_to_action[get_uuid(duplicate_output)] == (
+ duplicate_compute.uuid
+ )
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ history.toggle_record_mode(True)
+ add_paracetamol_signals(panel, 1)
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(sips.derivative)
+ original = history.history_sessions[-1]
+ select_tree_session(history, original)
+ htools.duplicate_selected_entries(history)
+ duplicate = history.history_sessions[-1]
+ assert len(duplicate.actions) == len(original.actions) + 1
+ assert_duplicate_head(history, panel, duplicate)
+ chains = build_session_chains(duplicate)
+ assert len(chains) == 1 and chains[0].root is duplicate.actions[0]
+
+
+def test_edit_cascade_preserves_identity_and_action_state() -> None:
+ """Cascade in place while preserving identities, metadata and edit baseline."""
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ history.toggle_record_mode(True)
+ history.toggle_edit_mode(True)
+ chain = build_signal_chain(panel, history)
+ root_action, middle_action, leaf_action = chain.actions
+ root_output, middle_output, leaf_output = chain.outputs
+ plugin_origin = {
+ "module": "test_plugin.operations",
+ "directory": "test_plugin",
+ }
+ middle_action.plugin_origin = plugin_origin
+ leaf_action.plugin_origin = plugin_origin
+ panel.objview.select_objects([leaf_output])
+ panel.processor.run_feature(sips.stats)
+ analysis_action = history[len(history)]
+ assert analysis_action.pattern == "1_to_0"
+ middle_parameters = extract_processing_parameters(middle_output)
+ assert middle_parameters is not None
+ middle_parameters.plugin_origin = plugin_origin
+ insert_processing_parameters(middle_output, middle_parameters)
+ middle_action.plugin_origin = None
+ analysis_action.plugin_origin = None
+ analysis_parameters = extract_analysis_parameters(leaf_output)
+ assert analysis_parameters is not None
+ analysis_parameters.plugin_origin = plugin_origin
+ leaf_output.set_metadata_option(
+ "analysis_parameters", analysis_parameters.to_dict()
+ )
+ leaf_uuid = get_uuid(leaf_output)
+ leaf_number = panel.objmodel.get_number(leaf_output)
+ leaf_data = leaf_output.xydata.copy()
+ leaf_output.metadata["user_marker"] = 123
+ panel.objview.select_objects([2])
+ assert panel.objprop.setup_processing_tab(root_output, reset_params=False)
+ editor = panel.objprop.processing_param_editor
+ assert editor is not None
+ editor.dataset.sigma = 7.0
+ with patch.object(
+ panel.processor,
+ "recompute_1_to_0",
+ wraps=panel.processor.recompute_1_to_0,
+ ) as recompute_analysis:
+ report = panel.objprop.apply_processing_parameters(
+ root_output, interactive=False
+ )
+ recompute_analysis.assert_called_once()
+ assert recompute_analysis.call_args.kwargs["plugin_origin"] == plugin_origin
+ assert report.success and root_action.has_pending_edits
+ assert root_action.kwargs["param"].sigma == 7.0
+ assert get_uuid(panel.objmodel[leaf_uuid]) == leaf_uuid
+ assert panel.objmodel.get_number(panel.objmodel[leaf_uuid]) == leaf_number
+ assert panel.objmodel[leaf_uuid].metadata["user_marker"] == 123
+ assert not np.array_equal(panel.objmodel[leaf_uuid].xydata, leaf_data)
+ for output in (middle_output, leaf_output):
+ parameters = extract_processing_parameters(output)
+ assert parameters is not None
+ assert parameters.plugin_origin == plugin_origin
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = os.path.join(tmpdir, "edited.dlhist")
+ assert history.save_to_dlhist_file(path)
+ sessions = read_history_sessions(path, history.H5_PREFIX)
+ restored = next(
+ action
+ for session in sessions
+ for action in session.actions
+ if action.uuid == root_action.uuid
+ )
+ assert restored.has_pending_edits and restored.kwargs["param"].sigma == 7.0
+ restored.restore_kwargs()
+ assert restored.kwargs["param"].sigma == 1.5
+ assert leaf_action.is_stale is False
+
+
+def test_edit_cascade_stops_after_failed_descendant() -> None:
+ """Keep a failed action and unexecuted analysis stale after cascade failure."""
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ history.toggle_record_mode(True)
+ add_paracetamol_signals(panel, 1)
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(
+ sips.gaussian_filter, sigima.params.GaussianParam.create(sigma=1.5)
+ )
+ first_action = history[len(history)]
+ panel.objview.select_objects([2])
+ panel.processor.run_feature(sips.derivative)
+ failed_action = history[len(history)]
+ failed_output = panel.objmodel[failed_action.output_uuids[0]]
+ failed_data = failed_output.xydata.copy()
+ panel.objview.select_objects([failed_action.output_uuids[0]])
+ panel.processor.run_feature(sips.stats)
+ analysis_action = history[len(history)]
+ first_action.is_stale = True
+ original_recompute = panel.processor.recompute_1_to_1
+ call_count = 0
+
+ def fail_second_recompute(*args, **kwargs):
+ nonlocal call_count
+ call_count += 1
+ if call_count == 2:
+ return CompOut(error_msg="expected cascade failure")
+ return original_recompute(*args, **kwargs)
+
+ with (
+ patch.object(
+ panel.processor,
+ "recompute_1_to_1",
+ side_effect=fail_second_recompute,
+ ),
+ patch.object(panel.processor, "recompute_1_to_0") as recompute_analysis,
+ ):
+ history.recompute_cascade(first_action)
+
+ recompute_analysis.assert_not_called()
+ assert first_action.is_stale is False
+ assert failed_action.is_stale is True
+ assert analysis_action.is_stale is True
+ assert np.array_equal(failed_output.xydata, failed_data)
+
+
+def test_multi_action_edit_recomputes_selected_descendants_once() -> None:
+ """Recompute selected ancestors and their analysis descendant exactly once."""
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ history.toggle_record_mode(True)
+ history.toggle_edit_mode(True)
+ chain = build_signal_chain(panel, history)
+ panel.objview.select_objects([chain.outputs[-1]])
+ panel.processor.run_feature(sips.stats)
+ analysis_action = history[len(history)]
+ selected = [chain.actions[0], chain.actions[1]]
+ expected = [*chain.actions, analysis_action]
+
+ with (
+ patch.object(
+ hireplay, "prompt_edit_action_params", return_value=True
+ ) as prompt,
+ patch.object(
+ hrec, "recompute_action_in_place", return_value=True
+ ) as recompute,
+ ):
+ hireplay.edit_mode_replay_actions(history, selected)
+
+ assert [call.args[1] for call in prompt.call_args_list] == selected
+ assert [call.args[1] for call in recompute.call_args_list] == expected
+ assert all(action.is_stale is False for action in expected)
+
+
+def test_multi_action_edit_cascades_across_independent_sessions() -> None:
+ """Recompute edited branches from multiple sessions in global order."""
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ history.toggle_record_mode(True)
+ history.toggle_edit_mode(True)
+ first_chain = build_independent_signal_branch(panel, history)
+ history.create_new_session(panel_str="signal")
+ second_chain = build_independent_signal_branch(panel, history)
+ selected = [second_chain[0], first_chain[0]]
+ expected = [*first_chain, *second_chain]
+
+ with (
+ patch.object(hireplay, "prompt_edit_action_params", return_value=True),
+ patch.object(
+ hrec, "recompute_action_in_place", return_value=True
+ ) as recompute,
+ ):
+ hireplay.edit_mode_replay_actions(history, selected)
+
+ assert [call.args[1] for call in recompute.call_args_list] == expected
+ assert all(action.is_stale is False for action in expected)
+
+
+def test_multi_action_edit_failure_skips_dependents_and_continues() -> None:
+ """Leave a failed branch stale while recomputing an independent session."""
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ history.toggle_record_mode(True)
+ history.toggle_edit_mode(True)
+ failed_chain = build_independent_signal_branch(panel, history)
+ history.create_new_session(panel_str="signal")
+ successful_chain = build_independent_signal_branch(panel, history)
+ failed_root = failed_chain[0]
+ recomputed: list[HistoryAction] = []
+
+ def recompute_action(_panel, action):
+ recomputed.append(action)
+ return action is not failed_root
+
+ with (
+ patch.object(hireplay, "prompt_edit_action_params", return_value=True),
+ patch.object(
+ hrec, "recompute_action_in_place", side_effect=recompute_action
+ ),
+ ):
+ hireplay.edit_mode_replay_actions(
+ history, [failed_root, successful_chain[0]]
+ )
+
+ assert recomputed == [failed_root, *successful_chain]
+ assert all(action.is_stale is True for action in failed_chain)
+ assert all(action.is_stale is False for action in successful_chain)
+
+
+def test_multi_action_edit_cancel_restores_entry_pending_edit() -> None:
+ """Restore current kwargs and their saved baseline after a later cancel."""
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ history.toggle_record_mode(True)
+ history.toggle_edit_mode(True)
+ first_action, second_action = build_signal_chain(panel, history).actions[:2]
+ first_action.snapshot_kwargs()
+ first_action.kwargs["param"].sigma = 2.5
+
+ def prompt(_panel, action):
+ if action is first_action:
+ action.kwargs["param"].sigma = 3.5
+ return True
+ return False
+
+ with patch.object(hireplay, "prompt_edit_action_params", side_effect=prompt):
+ hireplay.edit_mode_replay_actions(history, [first_action, second_action])
+
+ assert first_action.kwargs["param"].sigma == 2.5
+ assert first_action.saved_kwargs["param"].sigma == 1.5
+
+
+def test_multi_action_edit_cancel_skips_deferred_ui_replay() -> None:
+ """Do not replay noncompute UI actions when a later dialog is cancelled."""
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ history.toggle_record_mode(True)
+ history.toggle_edit_mode(True)
+ compute_action = build_signal_chain(panel, history).actions[0]
+ ui_action = HistoryAction(
+ title="Select next",
+ kind=HistoryAction.KIND_UI,
+ target="signalpanel",
+ method_name="select_next",
+ )
+
+ with (
+ patch.object(ui_action, "replay") as replay,
+ patch.object(hireplay, "prompt_edit_action_params", return_value=False),
+ ):
+ hireplay.edit_mode_replay_actions(history, [ui_action, compute_action])
+
+ replay.assert_not_called()
+
+
+def test_multi_action_edit_preserves_mixed_ui_compute_order() -> None:
+ """Execute deferred UI and planned compute actions in global session order."""
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ history.toggle_record_mode(True)
+ history.toggle_edit_mode(True)
+ source_uuid = add_paracetamol_signals(panel, 1)[0]
+ panel.objview.select_objects([source_uuid])
+ panel.processor.run_feature(sips.derivative)
+ first_compute = history[len(history)]
+ ui_action = history.add_ui_entry("Select next", "signalpanel", "select_next")
+ assert ui_action is not None
+ panel.objview.select_objects(first_compute.output_uuids)
+ panel.processor.run_feature(sips.derivative)
+ second_compute = history[len(history)]
+ execution_order = []
+
+ def recompute(_panel, action):
+ execution_order.append(action)
+ return True
+
+ def replay_ui(*_args, **_kwargs):
+ execution_order.append(ui_action)
+
+ with (
+ patch.object(hireplay, "prompt_edit_action_params", return_value=True),
+ patch.object(hrec, "recompute_action_in_place", side_effect=recompute),
+ patch.object(ui_action, "replay", side_effect=replay_ui),
+ ):
+ hireplay.edit_mode_replay_actions(history, [first_compute, ui_action])
+
+ assert execution_order == [first_compute, ui_action, second_compute]
+
+
+def test_multi_action_edit_flushes_cascade_warnings_once() -> None:
+ """Flush warnings exactly once after executing a custom replay plan."""
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ history.toggle_record_mode(True)
+ history.toggle_edit_mode(True)
+ action = build_signal_chain(panel, history).actions[0]
+
+ def recompute(_panel, _action):
+ history.runtime.execution.cascade_warnings.append("expected warning")
+ return True
+
+ with (
+ patch.object(hireplay, "prompt_edit_action_params", return_value=True),
+ patch.object(hrec, "recompute_action_in_place", side_effect=recompute),
+ patch.object(
+ hrec,
+ "flush_cascade_warnings",
+ wraps=hrec.flush_cascade_warnings,
+ ) as flush,
+ ):
+ hireplay.edit_mode_replay_actions(history, [action])
+
+ flush.assert_called_once_with(history)
+ assert history.runtime.execution.cascade_warnings == []
+
+
+def test_restore_failure_marks_action_stale_without_cascade() -> None:
+ """Stop restore recomputation when its root action fails."""
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ history.toggle_record_mode(True)
+ action = build_signal_chain(panel, history).actions[0]
+ action.snapshot_kwargs()
+ action.kwargs["param"].sigma = 7.0
+
+ with (
+ patch.object(hrec, "recompute_action_in_place", return_value=False),
+ patch.object(hrec, "recompute_cascade") as recompute_cascade,
+ ):
+ hireplay.restore_action_params(history, action)
+
+ recompute_cascade.assert_not_called()
+ assert action.is_stale is True
+
+
+def test_empty_analysis_result_is_successful() -> None:
+ """Treat an executed analysis with no detections as successful."""
+ with datalab_test_app_context(history=True) as win:
+ panel = win.signalpanel
+ panel.new_object(edit=False)
+ signal = panel.objview.get_current_object()
+ assert signal is not None
+
+ with patch.object(panel.processor, "compute_1_to_0", return_value=ResultData()):
+ success = panel.processor.recompute_1_to_0("stats", signal)
+
+ assert success is True
+
+
+def test_legacy_resultdata_defaults_execution_success() -> None:
+ """Use the dataclass default when legacy state lacks execution_success."""
+ result = ResultData()
+ del result.__dict__["execution_success"]
+
+ assert result.execution_success is True
+
+
+def test_2_to_1_failure_does_not_partially_mutate_outputs() -> None:
+ """Stage every pairwise result before mutating existing outputs."""
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ history.toggle_record_mode(True)
+ add_paracetamol_signals(panel, 4)
+ actions = []
+ for first, second in ((1, 2), (3, 4)):
+ panel.objview.select_objects([first])
+ panel.processor.run_feature(
+ sips.difference, panel.objmodel.get_object_from_number(second)
+ )
+ actions.append(history[len(history)])
+ action = actions[0]
+ action.output_uuids.extend(actions[1].output_uuids)
+ history.runtime.objects.action_output_uuids[action.uuid] = list(
+ action.output_uuids
+ )
+ outputs = [panel.objmodel[uuid] for uuid in action.output_uuids]
+ original_data = [obj.xydata.copy() for obj in outputs]
+ staged_result = outputs[0].copy()
+ staged_result.xydata = staged_result.xydata * 0.0
+
+ with patch.object(
+ panel.processor,
+ "recompute_2_to_1",
+ side_effect=[staged_result, None],
+ ):
+ success = hrec.recompute_action_in_place(history, action)
+
+ assert success is False
+ for output, data in zip(outputs, original_data):
+ assert np.array_equal(output.xydata, data)
+
+
+def test_2_to_1_refresh_failure_rolls_back_and_resyncs_outputs() -> None:
+ """Refresh only after commit and resync every target after rollback."""
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ history.toggle_record_mode(True)
+ add_paracetamol_signals(panel, 4)
+ actions = []
+ for first, second in ((1, 2), (3, 4)):
+ panel.objview.select_objects([first])
+ panel.processor.run_feature(
+ sips.difference, panel.objmodel.get_object_from_number(second)
+ )
+ actions.append(history[len(history)])
+ action = actions[0]
+ action.output_uuids.extend(actions[1].output_uuids)
+ history.runtime.objects.action_output_uuids[action.uuid] = list(
+ action.output_uuids
+ )
+ outputs = [panel.objmodel[uuid] for uuid in action.output_uuids]
+ identities = [id(obj) for obj in outputs]
+ original_titles = [obj.title for obj in outputs]
+ original_data = [obj.xydata.copy() for obj in outputs]
+ original_metadata = [obj.metadata.copy() for obj in outputs]
+ original_sources = [
+ extract_processing_parameters(obj).source_uuids for obj in outputs
+ ]
+ staged_results = [obj.copy() for obj in outputs]
+ staged_titles = []
+ for index, result in enumerate(staged_results):
+ result.title = f"staged-{index}"
+ staged_titles.append(result.title)
+ result.xydata = result.xydata * 0.0
+ refresh_effects = []
+
+ def refresh_with_failure(_panel, output_uuid):
+ refresh_effects.append(
+ (
+ output_uuid,
+ [obj.title for obj in outputs],
+ [
+ extract_processing_parameters(obj).source_uuids
+ for obj in outputs
+ ],
+ )
+ )
+ if len(refresh_effects) in (2, 3):
+ raise RuntimeError(f"refresh failed #{len(refresh_effects)}")
+
+ with (
+ patch.object(
+ panel.processor,
+ "recompute_2_to_1",
+ side_effect=staged_results,
+ ),
+ patch.object(
+ hrec,
+ "refresh_target",
+ side_effect=refresh_with_failure,
+ ),
+ ):
+ success = hrec.recompute_action_in_place(history, action)
+
+ assert success is False
+ assert [effect[0] for effect in refresh_effects] == [
+ action.output_uuids[0],
+ action.output_uuids[1],
+ action.output_uuids[0],
+ action.output_uuids[1],
+ ]
+ assert refresh_effects[0][1] == staged_titles
+ assert refresh_effects[0][2] == original_sources
+ assert refresh_effects[2][1] == original_titles
+ for index, output in enumerate(outputs):
+ assert id(output) == identities[index]
+ assert output.title == original_titles[index]
+ assert np.array_equal(output.xydata, original_data[index])
+ assert output.metadata == original_metadata[index]
+
+
+def test_1_to_n_refresh_failure_rolls_back_and_resyncs_outputs() -> None:
+ """Commit all 1-to-n outputs before refresh and fully roll back on failure."""
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ history.toggle_record_mode(True)
+ source_uuid = add_paracetamol_signals(panel, 1)[0]
+ actions = []
+ for _index in range(2):
+ panel.objview.select_objects([source_uuid])
+ panel.processor.run_feature(sips.derivative)
+ actions.append(history[len(history)])
+ action = actions[0]
+ action.pattern = "1_to_n"
+ action.kwargs = {
+ "params": [
+ sigima.params.GaussianParam.create(sigma=1.5),
+ sigima.params.GaussianParam.create(sigma=2.5),
+ ]
+ }
+ action.output_uuids.extend(actions[1].output_uuids)
+ history.runtime.objects.action_output_uuids[action.uuid] = list(
+ action.output_uuids
+ )
+ outputs = [panel.objmodel[uuid] for uuid in action.output_uuids]
+ identities = [id(obj) for obj in outputs]
+ original_titles = [obj.title for obj in outputs]
+ original_data = [obj.xydata.copy() for obj in outputs]
+ original_metadata = [obj.metadata.copy() for obj in outputs]
+ staged_results = [obj.copy() for obj in outputs]
+ staged_titles = []
+ for index, result in enumerate(staged_results):
+ result.title = f"staged-{index}"
+ staged_titles.append(result.title)
+ result.xydata = result.xydata * 0.0
+ refresh_effects = []
+
+ def refresh_with_failure(_panel, output_uuid):
+ refresh_effects.append((output_uuid, [obj.title for obj in outputs]))
+ if len(refresh_effects) in (2, 3):
+ raise RuntimeError(f"refresh failed #{len(refresh_effects)}")
+
+ with (
+ patch.object(
+ panel.processor,
+ "recompute_1_to_n",
+ return_value=staged_results,
+ ),
+ patch.object(hrec, "refresh_target", side_effect=refresh_with_failure),
+ ):
+ success = hrec.recompute_action_in_place(history, action)
+
+ assert success is False
+ assert [effect[0] for effect in refresh_effects] == [
+ action.output_uuids[0],
+ action.output_uuids[1],
+ action.output_uuids[0],
+ action.output_uuids[1],
+ ]
+ assert refresh_effects[0][1] == staged_titles
+ assert refresh_effects[2][1] == original_titles
+ for index, output in enumerate(outputs):
+ assert id(output) == identities[index]
+ assert output.title == original_titles[index]
+ assert np.array_equal(output.xydata, original_data[index])
+ assert output.metadata == original_metadata[index]
+
+
+def test_1_to_0_failure_rolls_back_all_source_metadata() -> None:
+ """Restore every analysis source when a later recomputation fails."""
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ history.toggle_record_mode(True)
+ source_uuids = add_paracetamol_signals(panel, 2)
+ panel.objview.select_objects([1, 2])
+ panel.processor.run_feature(sips.stats)
+ action = history[len(history)]
+ sources = [panel.objmodel[uuid] for uuid in source_uuids]
+ for index, source in enumerate(sources):
+ source.metadata["user_marker"] = index
+
+ call_count = 0
+
+ def fail_second_analysis(_func_name, source, _param, plugin_origin=None):
+ del plugin_origin
+ nonlocal call_count
+ call_count += 1
+ source.metadata["temporary_analysis"] = call_count
+ return call_count == 1
+
+ with patch.object(
+ panel.processor,
+ "recompute_1_to_0",
+ side_effect=fail_second_analysis,
+ ):
+ success = hrec.recompute_action_in_place(history, action)
+
+ assert success is False
+ for index, source in enumerate(sources):
+ assert source.metadata["user_marker"] == index
+ assert "temporary_analysis" not in source.metadata
+
+
+def test_1_to_0_cascade_uses_roi_safe_parameter_copy() -> None:
+ """Disable ROI creation on a copy during analysis cascade recomputation."""
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ source_uuid = add_paracetamol_signals(panel, 1)[0]
+ param = SimpleNamespace(create_rois=True)
+ action = HistoryAction()
+ action.kind = HistoryAction.KIND_COMPUTE
+ action.pattern = "1_to_0"
+ action.target = "signalpanel"
+ action.panel_str = "signal"
+ action.func_name = "analysis"
+ action.kwargs = {"param": param}
+ action.state.selection = {panel.PANEL_STR_ID: [source_uuid]}
+
+ with patch.object(
+ panel.processor, "recompute_1_to_0", return_value=True
+ ) as recompute:
+ success = hrec.recompute_action_in_place(history, action)
+
+ assert success is True
+ passed_param = recompute.call_args.args[2]
+ assert passed_param is not param
+ assert passed_param.create_rois is False
+ assert action.kwargs["param"].create_rois is True
+
+
+def test_deletion_reconnects_and_splices_chain() -> None:
+ """Reconnect after data deletion, then splice the producing action."""
+ with datalab_test_app_context(history=True) as win:
+ history, panel = win.historypanel, win.signalpanel
+ history.toggle_record_mode(True)
+ win.add_object(create_paracetamol_signal())
+ source_uuid = get_uuid(panel.objmodel.get_object_from_number(1))
+ panel.objview.select_objects([1])
+ panel.processor.run_feature(
+ sips.normalize, sigima.params.NormalizeParam.create(method="maximum")
+ )
+ normalize = history[len(history)]
+ intermediate_uuid = normalize.output_uuids[0]
+ panel.objview.select_objects([2])
+ panel.processor.run_feature(sips.derivative)
+ derivative = history[len(history)]
+ panel.objview.select_objects([2])
+ panel.remove_object(force=True)
+ assert intermediate_uuid not in derivative.state.selection["signal"]
+ assert source_uuid in derivative.state.selection["signal"]
+ panel.objview.select_objects([source_uuid])
+ panel.processor.run_feature(
+ sips.normalize, sigima.params.NormalizeParam.create(method="maximum")
+ )
+ action_to_delete = history[len(history)]
+ panel.objview.select_objects([action_to_delete.output_uuids[0]])
+ panel.processor.run_feature(sips.derivative)
+ downstream_action = history[len(history)]
+ session = history.history_sessions[-1]
+ object_count = len(panel.objmodel)
+ select_tree_entry(history, action_to_delete.uuid)
+ htools.delete_selected(history)
+ assert action_to_delete not in session.actions
+ assert action_to_delete.uuid not in history.runtime.objects.action_output_uuids
+ assert (
+ action_to_delete.output_uuids[0]
+ not in history.runtime.objects.output_to_action
+ )
+ assert derivative in session.actions and downstream_action in session.actions
+ assert len(panel.objmodel) == object_count + 1
+ assert (
+ action_to_delete.output_uuids[0]
+ not in downstream_action.state.selection["signal"]
+ )
+ chains = build_session_chains(session)
+ assert sum(len(chain.actions) for chain in chains) == len(session.actions)
+ removed_action_uuids = [action.uuid for action in session.actions]
+ removed_output_uuids = [
+ output_uuid
+ for action in session.actions
+ for output_uuid in action.output_uuids
+ ]
+ select_tree_session(history, session)
+ htools.delete_selected(history)
+ assert session not in history.history_sessions
+ assert all(
+ action_uuid not in history.runtime.objects.action_output_uuids
+ for action_uuid in removed_action_uuids
+ )
+ assert all(
+ output_uuid not in history.runtime.objects.output_to_action
+ for output_uuid in removed_output_uuids
+ )
diff --git a/datalab/tests/features/common/interactive_processing_test.py b/datalab/tests/features/common/interactive_processing_test.py
index 9ea1351c3..bd84ad34a 100644
--- a/datalab/tests/features/common/interactive_processing_test.py
+++ b/datalab/tests/features/common/interactive_processing_test.py
@@ -20,9 +20,12 @@
from __future__ import annotations
+from unittest.mock import patch
+
import numpy as np
from guidata.dataset import json_to_dataset
from guidata.qthelpers import qt_app_context, qt_wait
+from qtpy import QtWidgets as QW
from sigima.objects import Gauss2DParam, GaussParam, create_image_roi
from sigima.params import (
BinningParam,
@@ -33,9 +36,17 @@
)
from sigima.proc.image import RadialProfileParam
+from datalab.adapters_metadata.common import ResultData
+from datalab.env import execenv
from datalab.gui.newobject import CREATION_PARAMETERS_OPTION
-from datalab.gui.processor.base import PROCESSING_PARAMETERS_OPTION
-from datalab.objectmodel import get_uuid
+from datalab.gui.processor.base import (
+ PROCESSING_PARAMETERS_OPTION,
+ _detect_plugin_origin,
+ extract_analysis_parameters,
+ extract_processing_parameters,
+)
+from datalab.gui.processor.catcher import CompOut
+from datalab.objectmodel import get_short_id, get_uuid
from datalab.tests import datalab_test_app_context
@@ -149,7 +160,7 @@ def test_processing_without_parameters():
def test_recompute():
"""Test recompute feature for signals"""
with qt_app_context():
- with datalab_test_app_context() as win:
+ with datalab_test_app_context(history=True) as win:
panel = win.signalpanel
processor = panel.processor
@@ -164,10 +175,14 @@ def test_recompute():
filtered_sig = panel.objview.get_current_object()
original_data = filtered_sig.y.copy()
+ # In-place recompute requires History panel edit mode (otherwise a
+ # new object is created instead of mutating the existing one).
+ win.historypanel.toggle_edit_mode(True)
+
# Recompute with different input signal data
constant = 1.23098765
signal.y += constant
- panel.recompute_processing()
+ panel.recompute_selected()
assert np.allclose(filtered_sig.y, original_data + constant)
@@ -178,6 +193,310 @@ def test_recompute():
assert option_dict["func_name"] == "gaussian_filter"
+def test_recompute_selected_skips_analysis_when_1_to_1_cancelled():
+ """Test that analysis recompute is skipped when 1-to-1 is cancelled."""
+ with qt_app_context():
+ with datalab_test_app_context() as win:
+ panel = win.imagepanel
+ processor = panel.processor
+
+ # Create a source image and a 1-to-1 processed result
+ panel.new_object()
+ processor.run_feature(
+ "moving_average", param=MovingAverageParam.create(n=5)
+ )
+ processed_image = panel.objview.get_current_object()
+
+ # Add a 1-to-0 analysis result to the same object
+ processor.run_feature("centroid")
+
+ # Ensure this object is eligible for both processing and analysis passes
+ proc_params = extract_processing_parameters(processed_image)
+ analysis_params = extract_analysis_parameters(processed_image)
+ assert proc_params is not None and proc_params.pattern == "1-to-1"
+ assert analysis_params is not None and analysis_params.pattern == "1-to-0"
+
+ panel.objview.select_objects([processed_image])
+
+ # Simulate cancellation at the actual low-level contract boundary.
+ original_recompute_1_to_1 = processor.recompute_1_to_1
+ original_recompute_analysis = processor.recompute_analysis
+ processing_called = []
+ analysis_called = []
+
+ def cancel_recompute_1_to_1(*args, **kwargs):
+ processing_called.append((args, kwargs))
+ return CompOut(cancelled=True)
+
+ def record_recompute_analysis(*args, **kwargs):
+ analysis_called.append((args, kwargs))
+
+ processor.recompute_1_to_1 = cancel_recompute_1_to_1
+ processor.recompute_analysis = record_recompute_analysis
+
+ try:
+ panel.recompute_selected()
+ finally:
+ processor.recompute_1_to_1 = original_recompute_1_to_1
+ processor.recompute_analysis = original_recompute_analysis
+
+ assert len(processing_called) == 1
+ assert not analysis_called, (
+ "1-to-0 analysis recompute should not run when 1-to-1 processing "
+ "is cancelled"
+ )
+
+
+def test_plugin_analysis_origin_is_stored_and_reused():
+ """Test plugin provenance storage and reuse for 1-to-0 analyses."""
+ with qt_app_context():
+ with datalab_test_app_context(history=True) as win:
+ panel = win.signalpanel
+ processor = panel.processor
+ objprop = panel.objprop
+ plugin_origin = {
+ "plugin_class": "TestPlugin",
+ "module": "test_plugin.operations",
+ "directory": "test_plugin",
+ "version": "1.0",
+ }
+
+ stats_func = processor.get_feature("stats").function
+ feature = processor.register_1_to_0(stats_func, "Plugin statistics")
+ feature.plugin_origin = plugin_origin
+
+ panel.new_object(edit=False)
+ signal = panel.objview.get_current_object()
+ assert signal is not None
+ processor.run_feature(feature)
+
+ proc_params = extract_analysis_parameters(signal)
+ assert proc_params is not None
+ assert proc_params.plugin_origin == plugin_origin
+
+ calls = []
+ original_recompute_1_to_0 = processor.recompute_1_to_0
+
+ def record_recompute_1_to_0(*args, **kwargs):
+ calls.append((args, kwargs))
+ return original_recompute_1_to_0(*args, **kwargs)
+
+ processor.recompute_1_to_0 = record_recompute_1_to_0
+ try:
+ objprop.apply_analysis_parameters(signal, interactive=False)
+ processor.recompute_analysis(signal)
+ finally:
+ processor.recompute_1_to_0 = original_recompute_1_to_0
+
+ assert len(calls) == 2
+ for _args, kwargs in calls:
+ assert kwargs["plugin_origin"] == plugin_origin
+
+
+def test_wrapped_plugin_origin_uses_inner_callable_file() -> None:
+ """Use the origin candidate for both module and directory detection."""
+
+ def plugin_function():
+ pass
+
+ plugin_function.__module__ = "wrapped_plugin.operations"
+
+ def wrapper():
+ pass
+
+ wrapper.__module__ = "datalab.gui.processor.base"
+ wrapper.__wrapped__ = plugin_function
+
+ with (
+ patch("datalab.plugins.PluginRegistry.get_plugins", return_value=[]),
+ patch(
+ "datalab.gui.processor.base.inspect.getfile",
+ return_value="/plugins/wrapped_plugin/operations.py",
+ ) as getfile,
+ ):
+ origin = _detect_plugin_origin(wrapper)
+
+ assert origin is not None
+ assert origin["module"] == "wrapped_plugin.operations"
+ assert origin["directory"] == "wrapped_plugin"
+ getfile.assert_called_once_with(plugin_function)
+
+
+def test_analysis_persistence_failure_is_isolated_per_object() -> None:
+ """Roll back one failed analysis and continue with the next object."""
+ with qt_app_context():
+ with datalab_test_app_context() as win:
+ panel = win.signalpanel
+ processor = panel.processor
+ panel.new_object(edit=False)
+ first = panel.objview.get_current_object()
+ panel.new_object(edit=False)
+ second = panel.objview.get_current_object()
+ assert first is not None and second is not None
+ first.metadata["existing"] = {"value": 1}
+ first_metadata = first.metadata.copy()
+ appended = []
+ first_append_lengths = None
+ original_append = ResultData.append
+
+ def fail_after_first_append(rdata, adapter, obj):
+ nonlocal first_append_lengths
+ original_append(rdata, adapter, obj)
+ appended.append((adapter, obj))
+ if obj is first:
+ first_append_lengths = (
+ len(rdata.results),
+ len(rdata.ylabels),
+ len(rdata.short_ids),
+ )
+ raise RuntimeError("Expected persistence error")
+
+ stats_func = processor.get_feature("stats").function
+ with (
+ execenv.context(catcher_test=True),
+ patch.object(
+ ResultData,
+ "append",
+ new=fail_after_first_append,
+ ),
+ patch("datalab.gui.processor.base.show_warning_error") as show_error,
+ ):
+ result = processor.compute_1_to_0(
+ stats_func,
+ edit=False,
+ target_objs=[first, second],
+ )
+
+ assert result is not None
+ assert result.execution_success is False
+ assert [obj for _adapter, obj in appended] == [first, second]
+ assert first_append_lengths is not None
+ assert all(count > 0 for count in first_append_lengths)
+ assert first.metadata == first_metadata
+ assert extract_analysis_parameters(first) is None
+ assert extract_analysis_parameters(second) is not None
+ second_adapter = appended[1][0]
+ second_short_id = get_short_id(second)
+ assert result.results == [second_adapter]
+ assert result.ylabels == [f"{second_adapter.func_name}({second_short_id})"]
+ assert result.short_ids == [second_short_id]
+ error_calls = [
+ call for call in show_error.call_args_list if call.args[1] == "error"
+ ]
+ assert len(error_calls) == 1
+ assert "Expected persistence error" in error_calls[0].args[3]
+
+
+def test_recompute_selected_continues_after_1_to_1_error():
+ """Test that an ordinary 1-to-1 error is not treated as cancellation."""
+ with qt_app_context():
+ with datalab_test_app_context() as win:
+ panel = win.imagepanel
+ processor = panel.processor
+ processed_images = []
+ for _index in range(2):
+ panel.new_object()
+ processor.run_feature(
+ "moving_average", param=MovingAverageParam.create(n=5)
+ )
+ processed_image = panel.objview.get_current_object()
+ processor.run_feature("centroid")
+ processed_images.append(processed_image)
+
+ panel.objview.select_objects(processed_images)
+ original_recompute_1_to_1 = processor.recompute_1_to_1
+ original_recompute_analysis = processor.recompute_analysis
+ processing_count = [0]
+ analysis_objects = []
+
+ def recompute_1_to_1_with_first_error(
+ _func_name, source_obj, _param, plugin_origin=None
+ ):
+ del plugin_origin
+ processing_count[0] += 1
+ if processing_count[0] == 1:
+ return CompOut(error_msg="Expected computation error")
+ return CompOut(result=source_obj.copy())
+
+ def record_recompute_analysis(obj, *args, **kwargs):
+ analysis_objects.append(obj)
+ return True
+
+ processor.recompute_1_to_1 = recompute_1_to_1_with_first_error
+ processor.recompute_analysis = record_recompute_analysis
+
+ try:
+ with (
+ execenv.context(unattended=False),
+ patch(
+ "datalab.gui.panel.base.QW.QMessageBox.warning",
+ return_value=QW.QMessageBox.Yes,
+ ) as warning,
+ ):
+ panel.recompute_selected()
+ finally:
+ processor.recompute_1_to_1 = original_recompute_1_to_1
+ processor.recompute_analysis = original_recompute_analysis
+
+ assert processing_count[0] == 2
+ warning.assert_called_once()
+ assert analysis_objects == [processed_images[1]]
+
+
+def test_apply_analysis_parameters_failure_preserves_action() -> None:
+ """Do not record or announce a failed direct analysis recomputation."""
+ with qt_app_context():
+ with datalab_test_app_context(history=True) as win:
+ panel = win.signalpanel
+ history = win.historypanel
+ history.toggle_record_mode(True)
+ panel.new_object(edit=False)
+ signal = panel.objview.get_current_object()
+ assert signal is not None
+ panel.processor.run_feature("stats")
+ action = history[len(history)]
+ original_kwargs = action.kwargs.copy()
+ status_messages = []
+ panel.SIG_STATUS_MESSAGE.connect(status_messages.append)
+
+ with patch.object(panel.processor, "recompute_1_to_0", return_value=False):
+ success = panel.objprop.apply_analysis_parameters(
+ signal, interactive=False
+ )
+
+ assert success is False
+ assert action.kwargs == original_kwargs
+ assert status_messages == []
+
+
+def test_recompute_analyses_continues_after_failure_unattended() -> None:
+ """Continue with later analyses after an unattended object failure."""
+ with qt_app_context():
+ with datalab_test_app_context() as win:
+ panel = win.signalpanel
+ panel.new_object(edit=False)
+ first = panel.objview.get_current_object()
+ panel.new_object(edit=False)
+ second = panel.objview.get_current_object()
+ assert first is not None and second is not None
+
+ with (
+ execenv.context(unattended=True),
+ patch.object(
+ panel.processor,
+ "recompute_analysis",
+ side_effect=[False, True],
+ ) as recompute_analysis,
+ ):
+ recomputed, interrupted = panel.recompute_1_to_0_objects(
+ [first, second]
+ )
+
+ assert recompute_analysis.call_count == 2
+ assert recomputed == {get_uuid(second)}
+ assert interrupted is False
+
+
def test_apply_creation_parameters_signal():
"""Test apply_creation_parameters for signals"""
with qt_app_context():
@@ -366,7 +685,7 @@ def test_no_creation_parameters_for_base_classes():
def test_apply_processing_parameters_signal():
"""Test apply_processing_parameters for signals"""
with qt_app_context():
- with datalab_test_app_context() as win:
+ with datalab_test_app_context(history=True) as win:
panel = win.signalpanel
processor = panel.processor
objprop = panel.objprop
@@ -402,6 +721,10 @@ def test_apply_processing_parameters_signal():
# Change constant from 5.0 to 15.0
editor.dataset.value = v1 = 15.0
+ # In-place update requires History panel edit mode (otherwise a new
+ # object is created instead of mutating the existing one).
+ win.historypanel.toggle_edit_mode(True)
+
# Apply the new processing parameters
report = objprop.apply_processing_parameters()
@@ -428,7 +751,7 @@ def test_apply_processing_parameters_signal():
def test_apply_processing_parameters_image():
"""Test apply_processing_parameters for images"""
with qt_app_context():
- with datalab_test_app_context() as win:
+ with datalab_test_app_context(history=True) as win:
panel = win.imagepanel
processor = panel.processor
objprop = panel.objprop
@@ -463,6 +786,10 @@ def test_apply_processing_parameters_image():
# Change constant from 7.0 to 20.0
editor.dataset.value = v1 = 20.0
+ # In-place update requires History panel edit mode (otherwise a new
+ # object is created instead of mutating the existing one).
+ win.historypanel.toggle_edit_mode(True)
+
# Apply the new processing parameters
report = objprop.apply_processing_parameters()
@@ -486,6 +813,99 @@ def test_apply_processing_parameters_image():
assert stored_param.value == v1
+def test_apply_processing_parameters_explicit_param():
+ """apply_processing_parameters honors an explicit param, ignoring the editor."""
+ with qt_app_context():
+ with datalab_test_app_context(history=True) as win:
+ panel = win.signalpanel
+ processor = panel.processor
+ objprop = panel.objprop
+
+ param = GaussParam.create(mu=250.0, sigma=20.0, a=100.0, y0=10.0, size=500)
+ panel.new_object(param=param, edit=False)
+ signal = panel.objview.get_current_object()
+ assert signal is not None
+ signal_uuid = get_uuid(signal)
+ original_signal_data = signal.y.copy()
+
+ v0 = 5.0
+ processor.run_feature("addition_constant", ConstantParam.create(value=v0))
+ processed_sig = panel.objview.get_current_object()
+ assert processed_sig is not None
+ processed_uuid = get_uuid(processed_sig)
+ assert np.allclose(processed_sig.y, original_signal_data + v0)
+
+ # Select the processed signal to populate the Processing tab editor.
+ panel.objview.set_current_object(processed_sig)
+ assert objprop.processing_param_editor is not None
+ editor = objprop.processing_param_editor
+
+ # Put a DECOY value in the editor: it must be ignored because an
+ # explicit param is passed to apply_processing_parameters.
+ editor.dataset.value = 99.0
+
+ win.historypanel.toggle_edit_mode(True)
+
+ # Apply with an EXPLICIT param (not the editor's decoy value).
+ v1 = 15.0
+ report = objprop.apply_processing_parameters(
+ param=ConstantParam.create(value=v1)
+ )
+ assert report.success, f"Reprocessing failed: {report.message}"
+ assert report.obj_uuid == processed_uuid
+ assert get_uuid(processed_sig) == processed_uuid
+
+ # Output must reflect the EXPLICIT param (original + 15.0), proving
+ # the editor decoy (99.0) was ignored -> editor-independent.
+ assert np.allclose(processed_sig.y, original_signal_data + v1)
+
+ pp_dict = processed_sig.get_metadata_option(PROCESSING_PARAMETERS_OPTION)
+ assert pp_dict["source_uuid"] == signal_uuid
+ assert pp_dict["func_name"] == "addition_constant"
+ stored_param = json_to_dataset(pp_dict["param_json"])
+ assert stored_param.value == v1
+
+ # When applying parameters to an object other than the one attached
+ # to the editor, use that object's stored parameters and origin.
+ plugin_origin = {
+ "plugin_class": "TestPlugin",
+ "module": "test_plugin.operations",
+ "directory": "test_plugin",
+ "version": "1.0",
+ }
+ pp_dict["plugin_origin"] = plugin_origin
+ processed_sig.set_metadata_option(PROCESSING_PARAMETERS_OPTION, pp_dict)
+ editor.dataset.value = 99.0
+ objprop.current_processing_obj = signal
+
+ calls = []
+ original_recompute_1_to_1 = processor.recompute_1_to_1
+
+ def record_recompute_1_to_1(*args, **kwargs):
+ calls.append((args, kwargs))
+ return original_recompute_1_to_1(*args, **kwargs)
+
+ processor.recompute_1_to_1 = record_recompute_1_to_1
+ try:
+ report = objprop.apply_processing_parameters(
+ processed_sig, interactive=False
+ )
+ assert report.success, f"Reprocessing failed: {report.message}"
+
+ win.historypanel.toggle_edit_mode(False)
+ report = objprop.apply_processing_parameters(
+ processed_sig, interactive=False
+ )
+ assert report.success, f"Reprocessing failed: {report.message}"
+ finally:
+ processor.recompute_1_to_1 = original_recompute_1_to_1
+
+ assert len(calls) == 2
+ for args, kwargs in calls:
+ assert args[2].value == v1
+ assert kwargs["plugin_origin"] == plugin_origin
+
+
def test_no_duplicate_processing_tabs():
"""Test that applying processing parameters multiple times doesn't create
duplicate tabs.
@@ -611,7 +1031,7 @@ def test_apply_processing_parameters_missing_source():
def test_cross_panel_image_to_signal():
"""Test cross-panel processing: Image → Signal (radial profile)"""
with qt_app_context():
- with datalab_test_app_context() as win:
+ with datalab_test_app_context(history=True) as win:
image_panel = win.imagepanel
signal_panel = win.signalpanel
image_processor = image_panel.processor
@@ -658,6 +1078,10 @@ def test_cross_panel_image_to_signal():
editor.dataset.x0 = 40
editor.dataset.y0 = 40
+ # In-place update + in-place recompute require History panel edit
+ # mode (otherwise new objects are created instead of mutating).
+ win.historypanel.toggle_edit_mode(True)
+
# Apply the new processing parameters
report = signal_panel.objprop.apply_processing_parameters()
@@ -675,7 +1099,7 @@ def test_cross_panel_image_to_signal():
original_signal_data = signal.y.copy()
# Recompute the radial profile
- signal_panel.recompute_processing()
+ signal_panel.recompute_selected()
# The signal should have changed (doubled intensity)
assert not np.allclose(signal.y, original_signal_data)
@@ -1025,7 +1449,7 @@ def test_roi_mask_invalidation_on_processing_change():
5. Verify ROI mask is properly recomputed
"""
with qt_app_context():
- with datalab_test_app_context() as win:
+ with datalab_test_app_context(history=True) as win:
panel = win.imagepanel
objprop = panel.objprop
@@ -1065,6 +1489,10 @@ def test_roi_mask_invalidation_on_processing_change():
editor.dataset.sx = 4
editor.dataset.sy = 4
+ # In-place update requires History panel edit mode (otherwise a new
+ # object is created instead of mutating the existing one).
+ win.historypanel.toggle_edit_mode(True)
+
# Apply the new processing parameters
report = objprop.apply_processing_parameters(binned)
assert report.success
diff --git a/datalab/tests/features/common/metadata_io_unit_test.py b/datalab/tests/features/common/metadata_io_unit_test.py
index 03cac70d2..9bc462296 100644
--- a/datalab/tests/features/common/metadata_io_unit_test.py
+++ b/datalab/tests/features/common/metadata_io_unit_test.py
@@ -19,6 +19,7 @@
from datalab.adapters_metadata import GeometryAdapter, TableAdapter
from datalab.env import execenv
+from datalab.objectmodel import get_uuid
from datalab.tests import datalab_test_app_context, helpers
@@ -42,12 +43,19 @@ def test_metadata_io_unit():
TableAdapter(table).add_to(ima)
panel.add_object(ima)
+ orig_uuid = get_uuid(ima)
orig_metadata = ima.metadata.copy()
panel.export_metadata_from_file(fname)
panel.delete_metadata()
- # The +1 is for the "number" metadata option which has no default:
- assert len(ima.metadata) == len(ima.get_metadata_options_defaults()) + 1
+ assert get_uuid(ima) == orig_uuid
+ assert panel.objmodel[orig_uuid] is ima
+ assert ima.metadata["__number"] == panel.objmodel.get_number(ima)
+ expected_options = {
+ f"__{name}" for name in ima.get_metadata_options_defaults()
+ }
+ expected_options.update({"__uuid", "__number"})
+ assert set(ima.metadata) == expected_options
panel.import_metadata_from_file(fname)
execenv.print("Check metadata export <--> import features:")
diff --git a/datalab/tests/features/common/result_deletion_unit_test.py b/datalab/tests/features/common/result_deletion_unit_test.py
index b99429195..adace13cd 100644
--- a/datalab/tests/features/common/result_deletion_unit_test.py
+++ b/datalab/tests/features/common/result_deletion_unit_test.py
@@ -95,8 +95,8 @@ def test_delete_results_signal():
def test_delete_results_clears_analysis_parameters():
"""Test that deleting results also clears analysis parameters.
- This prevents auto_recompute_analysis from attempting to recompute
- deleted analyses when ROI changes.
+ This prevents recompute_analysis from attempting to recompute
+ deleted analyses when the user triggers a manual recompute.
"""
with datalab_test_app_context(console=False) as win:
execenv.print("Test delete_results clears analysis parameters:")
@@ -136,11 +136,11 @@ def test_delete_results_clears_analysis_parameters():
)
execenv.print(" ✓ Analysis parameters cleared")
- # Now add a ROI and verify no auto-recompute happens (no new results)
- execenv.print(" Adding ROI to verify no auto-recompute...")
+ # Now add a ROI and verify no recompute happens (no new results)
+ execenv.print(" Adding ROI to verify no recompute...")
roi = create_image_roi("rectangle", [25, 25, 100, 100])
img_after.roi = roi
- panel.processor.auto_recompute_analysis(img_after)
+ panel.processor.recompute_analysis(img_after)
# Verify that no new results were created
adapter_after_roi = GeometryAdapter.from_obj(img_after, "centroid")
@@ -148,9 +148,7 @@ def test_delete_results_clears_analysis_parameters():
"No centroid result should be created after ROI change "
"because analysis parameters were cleared"
)
- execenv.print(
- " ✓ No auto-recompute after ROI change (analysis params cleared)"
- )
+ execenv.print(" ✓ No recompute after ROI change (analysis params cleared)")
execenv.print("\n✓ All tests passed!")
diff --git a/datalab/tests/features/control/set_object_unit_test.py b/datalab/tests/features/control/set_object_unit_test.py
index 38b8fe1c9..7232c3e8e 100644
--- a/datalab/tests/features/control/set_object_unit_test.py
+++ b/datalab/tests/features/control/set_object_unit_test.py
@@ -16,6 +16,7 @@
from sigima.tests.data import get_test_image, get_test_signal
+from datalab.objectmodel import get_uuid
from datalab.tests import datalab_in_background_context
@@ -28,13 +29,18 @@ def test_set_object() -> None:
proxy.set_current_panel("signal")
sig_uuid = proxy.get_object_uuids("signal")[0]
+ proxy.select_objects([sig_uuid], panel="signal")
+ proxy.delete_metadata(refresh_plot=False, keep_roi=False)
sig = proxy.get_object(sig_uuid)
+ assert get_uuid(sig) == sig_uuid
+ assert sig_uuid in proxy.get_object_uuids("signal")
original_title = sig.title
sig.title = "Modified signal title"
sig.yunit = "modified_unit"
proxy.set_object(sig)
sig_back = proxy.get_object(sig_uuid)
+ assert get_uuid(sig_back) == sig_uuid
assert sig_back.title == "Modified signal title"
assert sig_back.yunit == "modified_unit"
diff --git a/datalab/tests/features/image/annotations_unit_test.py b/datalab/tests/features/image/annotations_unit_test.py
index 11a0d0699..60af9c69d 100644
--- a/datalab/tests/features/image/annotations_unit_test.py
+++ b/datalab/tests/features/image/annotations_unit_test.py
@@ -14,11 +14,13 @@
from plotpy.builder import make
from plotpy.items import AnnotatedShape, PolygonShape
from plotpy.plot import BasePlot
+from qtpy import QtCore as QC
from qtpy import QtWidgets as QW
from sigima.tests import data as test_data
from datalab.adapters_plotpy import create_adapter_from_object
from datalab.env import execenv
+from datalab.objectmodel import get_uuid
from datalab.tests import datalab_test_app_context
@@ -76,5 +78,36 @@ def test_annotations_unit():
execenv.print("OK")
+def test_open_separate_view_without_main_plot_item() -> None:
+ """Open a separate view when the object has no item in the main plot."""
+ with datalab_test_app_context() as win:
+ panel = win.imagepanel
+ reference = test_data.create_multigaussian_image()
+ target = test_data.create_annotated_image()
+ panel.add_object(reference)
+ panel.add_object(target)
+
+ target_uuid = get_uuid(target)
+ panel.plothandler.remove_item(target_uuid)
+ assert panel.plothandler.get(target_uuid) is None
+
+ existing_uuids = panel.plothandler.get_existing_oids()
+ existing_items = {uuid: panel.plothandler.get(uuid) for uuid in existing_uuids}
+ visibility = {uuid: item.isVisible() for uuid, item in existing_items.items()}
+ dialog = panel.open_separate_view(oids=[target_uuid])
+ assert dialog is not None
+ dialog.close()
+ QW.QApplication.sendPostedEvents(None, QC.QEvent.Type.DeferredDelete)
+ QW.QApplication.processEvents()
+
+ assert panel.plothandler.get_existing_oids() == existing_uuids
+ assert all(
+ panel.plothandler.get(uuid) is item for uuid, item in existing_items.items()
+ )
+ assert {
+ uuid: panel.plothandler.get(uuid).isVisible() for uuid in existing_uuids
+ } == visibility
+
+
if __name__ == "__main__":
test_annotations_unit()
diff --git a/datalab/tests/features/image/detection_roi_merge_unit_test.py b/datalab/tests/features/image/detection_roi_merge_unit_test.py
new file mode 100644
index 000000000..1565d0751
--- /dev/null
+++ b/datalab/tests/features/image/detection_roi_merge_unit_test.py
@@ -0,0 +1,229 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Detection ROI merge test
+
+Testing the following:
+ - When create_rois=True and the image has no ROI, detection creates ROIs
+ - When create_rois=True and the image already has ROIs, the newly detected
+ ROIs are appended to the existing ones (non-destructive, no dialog)
+ - When create_rois=False, existing ROIs are left untouched
+ - contour_shape with create_rois=True creates ROIs in DataLab integration
+ - recompute_analysis does not recreate ROIs deleted/edited by the user
+ - The ROI modification loop is broken (no infinite re-creation cycle)
+"""
+
+# guitest: show
+
+from __future__ import annotations
+
+import sigima.params
+from sigima.enums import ContourShape
+from sigima.objects import NewImageParam, create_image_roi
+from sigima.tests.data import create_multigaussian_image, create_peak_image
+
+from datalab.tests import datalab_test_app_context
+from datalab.tests.features.image.roi_app_test import IROI1, IROI2
+
+
+def _create_image_with_roi():
+ """Return a multigaussian image that already has ROIs defined."""
+ newparam = NewImageParam.create(height=200, width=200)
+ ima = create_multigaussian_image(newparam)
+ roi = create_image_roi("rectangle", IROI1)
+ roi.add_roi(create_image_roi("circle", IROI2))
+ ima.roi = roi
+ return ima
+
+
+def test_create_rois_no_existing_roi():
+ """ROIs are created normally when the image has no pre-existing ROI."""
+ with datalab_test_app_context() as win:
+ panel = win.imagepanel
+ ima = create_peak_image()
+ assert ima.roi is None
+ panel.add_object(ima)
+
+ param = sigima.params.Peak2DDetectionParam.create(create_rois=True)
+ result = panel.processor.compute_peak_detection(param)
+ assert result is not None, "Peak detection should return results"
+
+ obj = panel.objview.get_current_object()
+ assert obj.roi is not None, "ROI should be created when create_rois=True"
+ assert not obj.roi.is_empty(), "Created ROI should not be empty"
+
+
+def test_create_rois_appends_to_existing_roi():
+ """Newly detected ROIs are appended to existing ones, not replacing them."""
+ with datalab_test_app_context() as win:
+ panel = win.imagepanel
+ ima = _create_image_with_roi()
+ panel.add_object(ima)
+
+ obj = panel.objview.get_current_object()
+ existing_rois = list(obj.roi.single_rois)
+ n_existing = len(existing_rois)
+ assert n_existing > 0, "Test image must have pre-existing ROIs"
+
+ param = sigima.params.Peak2DDetectionParam.create(create_rois=True)
+ result = panel.processor.compute_peak_detection(param)
+ assert result is not None, "Peak detection should return results"
+
+ obj = panel.objview.get_current_object()
+ assert obj.roi is not None, "ROI should be present after detection"
+ # Pre-existing ROIs must still be there
+ for roi in existing_rois:
+ assert roi in obj.roi.single_rois, (
+ "Existing ROIs must be preserved when detection creates new ROIs"
+ )
+ # New ROIs must have been appended on top of the existing ones
+ assert len(obj.roi.single_rois) > n_existing, (
+ "Detected ROIs must be appended to the existing ROIs"
+ )
+
+
+def test_create_rois_false_preserves_existing_roi():
+ """When create_rois=False, existing ROIs are never touched."""
+ with datalab_test_app_context() as win:
+ panel = win.imagepanel
+ ima = _create_image_with_roi()
+ panel.add_object(ima)
+
+ obj = panel.objview.get_current_object()
+ roi_before = obj.roi.copy()
+
+ param = sigima.params.Peak2DDetectionParam.create(create_rois=False)
+ panel.processor.compute_peak_detection(param)
+
+ obj = panel.objview.get_current_object()
+ assert obj.roi == roi_before, (
+ "Existing ROI must not be modified when create_rois=False"
+ )
+
+
+def test_auto_recompute_does_not_replace_rois():
+ """recompute_analysis must not recreate ROIs deleted by the user.
+
+ Scenario:
+ 1. Run peak detection with create_rois=True → ROIs are created and the
+ analysis parameters (including create_rois=True) are stored in the
+ object's metadata.
+ 2. The user deletes the ROIs manually.
+ 3. recompute_analysis is triggered explicitly (manual "Recompute" action).
+ 4. The ROIs must NOT be recreated: recompute_analysis disables
+ create_rois before calling compute_1_to_0.
+ """
+ with datalab_test_app_context() as win:
+ panel = win.imagepanel
+ ima = create_peak_image()
+ panel.add_object(ima)
+
+ # Step 1: run detection with ROI creation to store analysis params
+ param = sigima.params.Peak2DDetectionParam.create(create_rois=True)
+ panel.processor.compute_peak_detection(param)
+
+ obj = panel.objview.get_current_object()
+ assert obj.roi is not None, "Peak detection should have created ROIs"
+
+ # Step 2: user deletes the ROIs
+ obj.roi = None
+
+ # Step 3 & 4: recompute must NOT recreate the ROIs
+ panel.processor.recompute_analysis(obj)
+
+ obj = panel.objview.get_current_object()
+ assert obj.roi is None, (
+ "recompute_analysis must not recreate ROIs "
+ "(create_rois is disabled during recompute)"
+ )
+
+
+def test_contour_shape_creates_rois_in_datalab():
+ """Integration test: contour_shape with create_rois=True creates ROIs via DataLab.
+
+ This verifies the full DataLab integration path: the contour_shape
+ computation function is called through run_feature, and the postprocess
+ hook (apply_detection_rois) creates the ROIs on the image object.
+ """
+ with datalab_test_app_context() as win:
+ panel = win.imagepanel
+ newparam = NewImageParam.create(height=200, width=200)
+ ima = create_multigaussian_image(newparam)
+ panel.add_object(ima)
+
+ for shape in ContourShape:
+ # Reset: remove ROIs from previous iteration
+ obj = panel.objview.get_current_object()
+ obj.roi = None
+
+ param = sigima.params.ContourShapeParam.create(
+ shape=shape, create_rois=True
+ )
+ panel.processor.run_feature("contour_shape", param)
+
+ obj = panel.objview.get_current_object()
+ assert obj.roi is not None, (
+ f"contour_shape({shape.name}) with create_rois=True "
+ "must create ROIs in DataLab"
+ )
+ assert not obj.roi.is_empty(), (
+ f"contour_shape({shape.name}) ROI must not be empty"
+ )
+
+
+def test_no_infinite_roi_recreation_loop():
+ """The ROI creation → recompute cycle must not loop infinitely.
+
+ Full scenario matching the real user workflow:
+ 1. Run detection with create_rois=True → ROIs are created.
+ 2. Simulate what happens when the user edits ROIs then triggers a manual
+ recompute: recompute_analysis is called.
+ 3. Verify that recompute_analysis does NOT recreate ROIs.
+ 4. Repeat step 2 a second time to confirm stability.
+
+ This test guards against the semi-infinite loop described in issue #329:
+ recomputing the analysis re-runs the detection function. If create_rois
+ stays True in the recompute path, the detection would overwrite the user's
+ ROI edit, triggering another recompute, etc.
+ """
+ with datalab_test_app_context() as win:
+ panel = win.imagepanel
+ ima = create_peak_image()
+ panel.add_object(ima)
+
+ # Step 1: detection with ROI creation
+ param = sigima.params.Peak2DDetectionParam.create(create_rois=True)
+ panel.processor.compute_peak_detection(param)
+
+ obj = panel.objview.get_current_object()
+ assert obj.roi is not None, "Initial detection should create ROIs"
+
+ # Step 2: simulate user editing ROIs (replace with a single rectangle)
+ obj.roi = create_image_roi("rectangle", [10, 10, 50, 50])
+ user_roi = obj.roi
+
+ # Step 3: manual recompute fires
+ panel.processor.recompute_analysis(obj)
+
+ obj = panel.objview.get_current_object()
+ # The ROI must be the user's edited ROI, NOT a new set from detection
+ assert obj.roi is user_roi, (
+ "recompute must not replace the user's manually edited ROI"
+ )
+
+ # Step 4: a second recompute must also be stable
+ panel.processor.recompute_analysis(obj)
+
+ obj = panel.objview.get_current_object()
+ assert obj.roi is user_roi, (
+ "Second recompute must still preserve the user's ROI (no oscillation)"
+ )
+
+
+if __name__ == "__main__":
+ test_create_rois_no_existing_roi()
+ test_create_rois_appends_to_existing_roi()
+ test_create_rois_false_preserves_existing_roi()
+ test_auto_recompute_does_not_replace_rois()
+ test_contour_shape_creates_rois_in_datalab()
+ test_no_infinite_roi_recreation_loop()
diff --git a/datalab/tests/features/image/detection_roi_replace_unit_test.py b/datalab/tests/features/image/detection_roi_replace_unit_test.py
deleted file mode 100644
index a78832d4b..000000000
--- a/datalab/tests/features/image/detection_roi_replace_unit_test.py
+++ /dev/null
@@ -1,326 +0,0 @@
-# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
-
-"""
-Detection ROI replacement confirmation test
-
-Testing the following:
- - When create_rois=True and image already has ROIs, the preprocess hook
- runs before the progress bar and can abort the computation
- - In unattended mode (automated tests) the dialog is skipped and ROIs
- are always replaced
- - When create_rois=False, existing ROIs are left untouched
- - When no existing ROIs are present, ROI creation proceeds normally
- - When the user cancels the confirmation dialog, existing ROIs are preserved
- - The confirmation dialog is shown only when ROIs already exist
- - contour_shape with create_rois=True creates ROIs in DataLab integration
- - ROI modification loop is broken (no infinite re-creation cycle)
-"""
-
-# guitest: show
-
-from __future__ import annotations
-
-from unittest.mock import patch
-
-import sigima.params
-import sigima.proc.image as sipi
-from qtpy import QtWidgets as QW
-from sigima.enums import ContourShape
-from sigima.objects import NewImageParam, create_image_roi
-from sigima.tests.data import create_multigaussian_image, create_peak_image
-
-from datalab.env import execenv
-from datalab.tests import datalab_test_app_context
-from datalab.tests.features.image.roi_app_test import IROI1, IROI2
-
-
-def _create_image_with_roi():
- """Return a multigaussian image that already has ROIs defined."""
- newparam = NewImageParam.create(height=200, width=200)
- ima = create_multigaussian_image(newparam)
- roi = create_image_roi("rectangle", IROI1)
- roi.add_roi(create_image_roi("circle", IROI2))
- ima.roi = roi
- return ima
-
-
-def test_create_rois_no_existing_roi():
- """ROIs are created normally when the image has no pre-existing ROI."""
- with datalab_test_app_context() as win:
- panel = win.imagepanel
- ima = create_peak_image()
- assert ima.roi is None
- panel.add_object(ima)
-
- param = sigima.params.Peak2DDetectionParam.create(create_rois=True)
- result = panel.processor.compute_peak_detection(param)
- assert result is not None, "Peak detection should return results"
-
- obj = panel.objview.get_current_object()
- assert obj.roi is not None, "ROI should be created when create_rois=True"
- assert not obj.roi.is_empty(), "Created ROI should not be empty"
-
-
-def test_create_rois_with_existing_roi_unattended():
- """In unattended mode, existing ROIs are silently replaced (no dialog)."""
- with datalab_test_app_context() as win:
- panel = win.imagepanel
- ima = _create_image_with_roi()
- initial_roi = ima.roi
- panel.add_object(ima)
-
- # execenv.unattended is True in the test suite: the dialog is skipped
- assert execenv.unattended, "This test requires unattended mode"
-
- param = sigima.params.Peak2DDetectionParam.create(create_rois=True)
- result = panel.processor.compute_peak_detection(param)
- assert result is not None, "Peak detection should return results"
-
- obj = panel.objview.get_current_object()
- assert obj.roi is not None, "ROI should be present after detection"
- assert obj.roi != initial_roi, (
- "Existing ROI should have been replaced in unattended mode"
- )
-
-
-def test_create_rois_false_preserves_existing_roi():
- """When create_rois=False, existing ROIs are never touched."""
- with datalab_test_app_context() as win:
- panel = win.imagepanel
- ima = _create_image_with_roi()
- panel.add_object(ima)
-
- obj = panel.objview.get_current_object()
- roi_before = obj.roi
-
- param = sigima.params.Peak2DDetectionParam.create(create_rois=False)
- panel.processor.compute_peak_detection(param)
-
- obj = panel.objview.get_current_object()
- assert obj.roi == roi_before, (
- "Existing ROI must not be modified when create_rois=False"
- )
-
-
-def test_dialog_shown_only_when_roi_exists():
- """The confirmation dialog is triggered only when the image already has ROIs.
-
- - With existing ROIs and create_rois=True: preprocess_1_to_0 calls the dialog
- - Without existing ROIs and create_rois=True: preprocess_1_to_0 returns True
- directly, without opening any dialog
- """
- with datalab_test_app_context() as win:
- panel = win.imagepanel
- param = sigima.params.Peak2DDetectionParam.create(create_rois=True)
-
- # Case 1: image with no ROI — dialog must NOT be shown
- ima_no_roi = create_peak_image()
- panel.add_object(ima_no_roi)
- objs_no_roi = panel.objview.get_sel_objects(include_groups=True)
-
- with patch.object(QW.QMessageBox, "question") as mock_question:
- execenv.unattended = False
- try:
- result = panel.processor.preprocess_1_to_0(
- sipi.peak_detection, param, objs_no_roi
- )
- finally:
- execenv.unattended = True
-
- assert result is True, "Should proceed when no existing ROIs"
- mock_question.assert_not_called()
-
- # Case 2: image with existing ROI — dialog MUST be shown
- ima_with_roi = _create_image_with_roi()
- panel.add_object(ima_with_roi)
- objs_with_roi = panel.objview.get_sel_objects(include_groups=True)
-
- with patch.object(
- QW.QMessageBox, "question", return_value=QW.QMessageBox.Yes
- ) as mock_question:
- execenv.unattended = False
- try:
- result = panel.processor.preprocess_1_to_0(
- sipi.peak_detection, param, objs_with_roi
- )
- finally:
- execenv.unattended = True
-
- assert result is True, "Should proceed when user confirms"
- mock_question.assert_called_once()
-
-
-def test_cancel_dialog_preserves_existing_roi():
- """When the user cancels the confirmation dialog, existing ROIs are preserved."""
- with datalab_test_app_context() as win:
- panel = win.imagepanel
- ima = _create_image_with_roi()
- panel.add_object(ima)
-
- obj = panel.objview.get_current_object()
- roi_before = obj.roi
-
- param = sigima.params.Peak2DDetectionParam.create(create_rois=True)
-
- # Simulate the user clicking "No" in the confirmation dialog
- with patch.object(QW.QMessageBox, "question", return_value=QW.QMessageBox.No):
- execenv.unattended = False
- try:
- result = panel.processor.compute_peak_detection(param)
- finally:
- execenv.unattended = True
-
- assert result is None, "Computation should be aborted when user cancels"
- obj = panel.objview.get_current_object()
- assert obj.roi == roi_before, (
- "Existing ROI must be preserved when user cancels the dialog"
- )
-
-
-def test_preprocess_hook_abort_skipped_in_unattended():
- """preprocess_1_to_0 returns True in unattended mode (no blocking dialog)."""
- with datalab_test_app_context() as win:
- panel = win.imagepanel
- ima = _create_image_with_roi()
- panel.add_object(ima)
-
- assert execenv.unattended, "This test requires unattended mode"
-
- param = sigima.params.Peak2DDetectionParam.create(create_rois=True)
- objs = panel.objview.get_sel_objects(include_groups=True)
-
- # In unattended mode the hook must always return True (no dialog shown)
- result = panel.processor.preprocess_1_to_0(sipi.peak_detection, param, objs)
- assert result is True, "preprocess_1_to_0 must return True in unattended mode"
-
-
-def test_auto_recompute_does_not_replace_rois():
- """auto_recompute_analysis must not recreate ROIs deleted by the user.
-
- Scenario:
- 1. Run peak detection with create_rois=True → ROIs are created and the
- analysis parameters (including create_rois=True) are stored in the
- object's metadata.
- 2. The user deletes the ROIs manually.
- 3. auto_recompute_analysis is triggered (e.g. after a data change).
- 4. The ROIs must NOT be recreated: auto_recompute_analysis disables
- create_rois before calling compute_1_to_0.
- """
- with datalab_test_app_context() as win:
- panel = win.imagepanel
- ima = create_peak_image()
- panel.add_object(ima)
-
- # Step 1: run detection with ROI creation to store analysis params
- param = sigima.params.Peak2DDetectionParam.create(create_rois=True)
- panel.processor.compute_peak_detection(param)
-
- obj = panel.objview.get_current_object()
- assert obj.roi is not None, "Peak detection should have created ROIs"
-
- # Step 2: user deletes the ROIs
- obj.roi = None
-
- # Step 3 & 4: auto-recompute must NOT recreate the ROIs
- panel.processor.auto_recompute_analysis(obj)
-
- obj = panel.objview.get_current_object()
- assert obj.roi is None, (
- "auto_recompute_analysis must not recreate ROIs "
- "(create_rois is disabled during auto-recompute)"
- )
-
-
-def test_contour_shape_creates_rois_in_datalab():
- """Integration test: contour_shape with create_rois=True creates ROIs via DataLab.
-
- This verifies the full DataLab integration path for the Sigima
- feature/20-contour-to-roi feature: the contour_shape computation function
- is called through run_feature, and the postprocess hook
- (apply_detection_rois) creates the ROIs on the image object.
- """
- with datalab_test_app_context() as win:
- panel = win.imagepanel
- newparam = NewImageParam.create(height=200, width=200)
- ima = create_multigaussian_image(newparam)
- panel.add_object(ima)
-
- for shape in ContourShape:
- # Reset: remove ROIs from previous iteration
- obj = panel.objview.get_current_object()
- obj.roi = None
-
- param = sigima.params.ContourShapeParam.create(
- shape=shape, create_rois=True
- )
- panel.processor.run_feature("contour_shape", param)
-
- obj = panel.objview.get_current_object()
- assert obj.roi is not None, (
- f"contour_shape({shape.name}) with create_rois=True "
- "must create ROIs in DataLab"
- )
- assert not obj.roi.is_empty(), (
- f"contour_shape({shape.name}) ROI must not be empty"
- )
-
-
-def test_no_infinite_roi_recreation_loop():
- """The ROI creation → auto-recompute cycle must not loop infinitely.
-
- Full scenario matching the real user workflow:
- 1. Run detection with create_rois=True → ROIs are created.
- 2. Simulate what happens when the user edits ROIs: auto_recompute_analysis
- is called (as DataLab does after ROI graphical editing).
- 3. Verify that auto_recompute_analysis does NOT recreate ROIs.
- 4. Repeat step 2 a second time to confirm stability.
-
- This test guards against the semi-infinite loop described in issue #329:
- modifying ROIs triggers auto-recompute, which re-runs the detection
- function. If create_rois stays True in the recompute path, the detection
- would overwrite the user's ROI edit, triggering another recompute, etc.
- """
- with datalab_test_app_context() as win:
- panel = win.imagepanel
- ima = create_peak_image()
- panel.add_object(ima)
-
- # Step 1: detection with ROI creation
- param = sigima.params.Peak2DDetectionParam.create(create_rois=True)
- panel.processor.compute_peak_detection(param)
-
- obj = panel.objview.get_current_object()
- assert obj.roi is not None, "Initial detection should create ROIs"
-
- # Step 2: simulate user editing ROIs (replace with a single rectangle)
- obj.roi = create_image_roi("rectangle", [10, 10, 50, 50])
- user_roi = obj.roi
-
- # Step 3: auto-recompute fires (as DataLab does after ROI edit)
- panel.processor.auto_recompute_analysis(obj)
-
- obj = panel.objview.get_current_object()
- # The ROI must be the user's edited ROI, NOT a new set from detection
- assert obj.roi is user_roi, (
- "auto_recompute must not replace the user's manually edited ROI"
- )
-
- # Step 4: a second auto-recompute must also be stable
- panel.processor.auto_recompute_analysis(obj)
-
- obj = panel.objview.get_current_object()
- assert obj.roi is user_roi, (
- "Second auto_recompute must still preserve the user's ROI (no oscillation)"
- )
-
-
-if __name__ == "__main__":
- test_create_rois_no_existing_roi()
- test_create_rois_with_existing_roi_unattended()
- test_create_rois_false_preserves_existing_roi()
- test_dialog_shown_only_when_roi_exists()
- test_cancel_dialog_preserves_existing_roi()
- test_preprocess_hook_abort_skipped_in_unattended()
- test_auto_recompute_does_not_replace_rois()
- test_contour_shape_creates_rois_in_datalab()
- test_no_infinite_roi_recreation_loop()
diff --git a/datalab/tests/features/image/profile_recompute_multiobject_app_test.py b/datalab/tests/features/image/profile_recompute_multiobject_app_test.py
new file mode 100644
index 000000000..5d99d537b
--- /dev/null
+++ b/datalab/tests/features/image/profile_recompute_multiobject_app_test.py
@@ -0,0 +1,123 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Multi-object recompute - parameter independence regression test
+
+Non-regression test for a bug found on ``develop`` while investigating issue
+#322: when recomputing *several* selected objects at once through the manual
+"Recompute" action, the previous code read the processing parameters from the
+shared Processing-tab editor (``ObjectProp.processing_param_editor``). During
+the multi-object loop that editor lagged behind the object actually being
+recomputed (it is only rebuilt through the asynchronous selection-changed
+signal). As a result, one object could be recomputed with another object's
+parameters, and its stored metadata got overwritten.
+
+On this branch the recompute logic was refactored so that the multi-object
+loop calls ``processor.recompute_processing(obj)`` without a parameter
+override, making each object fall back to *its own* stored parameters. This
+test locks that behaviour in by recomputing two different profiles selected
+together and checking that each one keeps its own selection.
+"""
+
+# pylint: disable=invalid-name # Allows short reference names like x, y, ...
+# guitest: show
+
+import numpy as np
+import sigima.params
+from guidata.qthelpers import qt_app_context
+from sigima.tests.data import create_noisy_gaussian_image
+
+from datalab.gui.processor.base import extract_processing_parameters
+from datalab.objectmodel import get_uuid
+from datalab.tests import datalab_test_app_context
+
+
+def test_multiobject_recompute_parameter_independence():
+ """Recomputing several selected profiles at once must recompute each one
+ with its own stored parameters (no cross-contamination)."""
+ with qt_app_context():
+ with datalab_test_app_context() as win:
+ image_panel = win.imagepanel
+ signal_panel = win.signalpanel
+ proc = image_panel.processor
+
+ # Source image
+ image = create_noisy_gaussian_image(
+ center=(0.0, 0.0), add_annotations=False
+ )
+ image_panel.add_object(image)
+
+ # First profile (selection A: horizontal band)
+ coords_a = {
+ "direction": "horizontal",
+ "row1": 10,
+ "col1": 10,
+ "row2": 40,
+ "col2": 60,
+ }
+ proc.compute_average_profile(
+ sigima.params.AverageProfileParam.create(**coords_a)
+ )
+ profile_a = signal_panel.objview.get_current_object()
+ assert profile_a is not None
+ data_a = profile_a.y.copy()
+
+ # Second profile (selection B: vertical band, different shape)
+ coords_b = {
+ "direction": "vertical",
+ "row1": 200,
+ "col1": 150,
+ "row2": 400,
+ "col2": 350,
+ }
+ proc.compute_average_profile(
+ sigima.params.AverageProfileParam.create(**coords_b)
+ )
+ profile_b = signal_panel.objview.get_current_object()
+ assert profile_b is not None
+ data_b = profile_b.y.copy()
+
+ # Sanity: the two profiles have different shapes (A: 51, B: 201)
+ assert profile_a.y.shape != profile_b.y.shape
+
+ # Modify the source image by a known offset so that recompute
+ # produces a detectable, deterministic change and we can prove the
+ # recompute actually ran.
+ offset = 5.0
+ image.data = image.data + offset
+ image.invalidate_maskdata_cache()
+
+ # Select BOTH profiles and recompute them together (the multi-object
+ # path that used to leak one profile's parameters into the other).
+ signal_panel.objview.select_objects([profile_a, profile_b])
+ signal_panel.recompute_selected()
+
+ # Each profile must keep its own selection: same shape as before,
+ # and values shifted by the offset applied to the source image.
+ assert profile_a.y.shape == data_a.shape
+ assert profile_b.y.shape == data_b.shape
+ assert np.allclose(profile_a.y, data_a + offset)
+ assert np.allclose(profile_b.y, data_b + offset)
+
+ # Stored parameters must still describe each profile's own selection.
+ pp_a = extract_processing_parameters(profile_a)
+ pp_b = extract_processing_parameters(profile_b)
+ assert pp_a is not None and pp_a.param is not None
+ assert pp_b is not None and pp_b.param is not None
+ assert pp_a.param.direction == coords_a["direction"]
+ assert (pp_a.param.col1, pp_a.param.row1) == (
+ coords_a["col1"],
+ coords_a["row1"],
+ )
+ assert pp_b.param.direction == coords_b["direction"]
+ assert (pp_b.param.col1, pp_b.param.row1) == (
+ coords_b["col1"],
+ coords_b["row1"],
+ )
+
+ # And the two profiles must remain distinct objects.
+ assert get_uuid(profile_a) != get_uuid(profile_b)
+
+
+if __name__ == "__main__":
+ test_multiobject_recompute_parameter_independence()
diff --git a/datalab/tests/features/signal/fitdialog_unit_test.py b/datalab/tests/features/signal/fitdialog_unit_test.py
index ca0c65d2f..06d812677 100644
--- a/datalab/tests/features/signal/fitdialog_unit_test.py
+++ b/datalab/tests/features/signal/fitdialog_unit_test.py
@@ -8,9 +8,11 @@
# pylint: disable=invalid-name # Allows short reference names like x, y, ...
# guitest: show
+import numpy as np
from guidata.qthelpers import qt_app_context
from sigima.objects import NormalDistribution1DParam
from sigima.tests.data import create_noisy_signal, get_test_signal
+from sigima.tools.signal import pulse
from sigima.tools.signal.peakdetection import peak_indices
from datalab.env import execenv
@@ -46,5 +48,45 @@ def test_fit_dialog():
ep(fdlg.piecewiseexponential_fit(s4.x, s4.y, name=tn("12")))
+def test_evaluate_fit_matches_models():
+ """Test the GUI-free deterministic fit evaluator (no dialog)."""
+ x = np.linspace(-5, 5, 50)
+
+ # Polynomial (degree 2)
+ poly_values = [2.0, -1.0, 3.0]
+ assert np.allclose(
+ fdlg.evaluate_fit("polynomial", x, poly_values),
+ np.polyval(poly_values, x),
+ )
+
+ # Gaussian: [amp, sigma, x0, y0]
+ gauss_values = [5.0, 1.5, 0.3, 0.2]
+ assert np.allclose(
+ fdlg.evaluate_fit("gaussian", x, gauss_values),
+ pulse.GaussianModel.func(x, *gauss_values),
+ )
+
+ # Multi-Gaussian: [A1, σ1, A2, σ2, y0] + fixed peak abscissas
+ multi_values = [1.0, 0.5, 2.0, 0.4, 0.1]
+ a_x0 = [-1.0, 1.5]
+ assert np.allclose(
+ fdlg.evaluate_fit("multigaussian", x, multi_values, extra={"a_x0": a_x0}),
+ fdlg.multigaussian(x, *multi_values, a_x0=np.array(a_x0)),
+ )
+
+ # Canonical mapping helper
+ assert fdlg.fit_type_from_dlgfunc_name("gaussian_fit") == "gaussian"
+ assert fdlg.fit_type_from_dlgfunc_name("unknown") is None
+
+ # Unknown fit type raises
+ try:
+ fdlg.evaluate_fit("nope", x, [])
+ except ValueError:
+ pass
+ else:
+ raise AssertionError("evaluate_fit should raise ValueError for unknown type")
+
+
if __name__ == "__main__":
test_fit_dialog()
+ test_evaluate_fit_matches_models()
diff --git a/datalab/widgets/fitdialog.py b/datalab/widgets/fitdialog.py
index aaac07ace..f9eebf2c9 100644
--- a/datalab/widgets/fitdialog.py
+++ b/datalab/widgets/fitdialog.py
@@ -4,6 +4,8 @@
# pylint: disable=invalid-name # Allows short reference names like x, y, ...
+from __future__ import annotations
+
import numpy as np
from guidata.configtools import get_icon
from guidata.qthelpers import exec_dialog
@@ -786,3 +788,74 @@ def fitfunc(x, params):
)
if values:
return fitfunc(x, values), params
+
+
+# --- Deterministic fit evaluation (GUI-free) ----------------------------------
+
+# Canonical fit-type identifiers, used to record and replay curve fits
+# deterministically (without reopening the interactive dialog).
+FIT_TYPE_BY_DLGFUNC = {
+ "polynomial_fit": "polynomial",
+ "linear_fit": "polynomial",
+ "gaussian_fit": "gaussian",
+ "lorentzian_fit": "lorentzian",
+ "voigt_fit": "voigt",
+ "multigaussian_fit": "multigaussian",
+ "multilorentzian_fit": "multilorentzian",
+}
+
+
+def fit_type_from_dlgfunc_name(name: str) -> str | None:
+ """Return the canonical fit-type id for a fit dialog function name.
+
+ Args:
+ name: ``__name__`` of the fit dialog function (e.g. ``"gaussian_fit"``).
+
+ Returns:
+ The canonical fit-type id (e.g. ``"gaussian"``), or ``None`` if the
+ function is not a recognised deterministic fit.
+ """
+ return FIT_TYPE_BY_DLGFUNC.get(name)
+
+
+def evaluate_fit(
+ fit_type: str,
+ x: np.ndarray,
+ values: list[float],
+ extra: dict | None = None,
+) -> np.ndarray:
+ """Evaluate a recorded curve fit deterministically (no GUI).
+
+ Args:
+ fit_type: Canonical fit-type id (see :data:`FIT_TYPE_BY_DLGFUNC`).
+ x: Abscissa values of the source signal.
+ values: Final fit parameter values, in the same order produced by the
+ corresponding interactive fit function.
+ extra: Optional structural data required by some models
+ (``{"a_x0": [...]}`` for multi-peak fits).
+
+ Returns:
+ The fitted ordinate array ``y`` evaluated at ``x``.
+
+ Raises:
+ ValueError: If ``fit_type`` is unknown or required ``extra`` is missing.
+ """
+ x = np.asarray(x, dtype=float)
+ vals = list(values)
+ extra = extra or {}
+ if fit_type == "polynomial":
+ return np.polyval(vals, x)
+ if fit_type == "gaussian":
+ return pulse.GaussianModel.func(x, *vals)
+ if fit_type == "lorentzian":
+ return pulse.LorentzianModel.func(x, *vals)
+ if fit_type == "voigt":
+ return pulse.VoigtModel.func(x, *vals)
+ if fit_type in ("multigaussian", "multilorentzian"):
+ a_x0 = extra.get("a_x0")
+ if a_x0 is None:
+ raise ValueError(f"Missing 'a_x0' in extra for {fit_type!r} fit evaluation")
+ a_x0 = np.asarray(a_x0, dtype=float)
+ func = multigaussian if fit_type == "multigaussian" else multilorentzian
+ return func(x, *vals, a_x0=a_x0)
+ raise ValueError(f"Unknown fit_type {fit_type!r}")
diff --git a/datalab/widgets/historydescription.py b/datalab/widgets/historydescription.py
new file mode 100644
index 000000000..e2ca7945a
--- /dev/null
+++ b/datalab/widgets/historydescription.py
@@ -0,0 +1,92 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Collapsible description widget used by the History panel."""
+
+from __future__ import annotations
+
+import html
+
+from qtpy import QtCore as QC
+from qtpy import QtWidgets as QW
+
+from datalab.config import _
+
+
+class CollapsibleDescriptionWidget(QW.QWidget):
+ """Compact, expandable cell widget for the history Description column.
+
+ Shows a single-line summary by default; a chevron toggle reveals the full
+ HTML description (mirroring the *Properties* tab rendering).
+ """
+
+ toggled = QC.Signal(bool)
+
+ def __init__(
+ self,
+ summary: str,
+ html_text: str,
+ expanded: bool = False,
+ parent: QW.QWidget | None = None,
+ ) -> None:
+ super().__init__(parent)
+ self._summary = summary
+ self._html = html_text
+ self._expanded = expanded
+
+ self._toggle = QW.QToolButton(self)
+ self._toggle.setAutoRaise(True)
+ self._toggle.setCheckable(True)
+ self._toggle.setFocusPolicy(QC.Qt.NoFocus)
+ self._toggle.setArrowType(QC.Qt.RightArrow)
+ self._toggle.setToolTip(_("Show details"))
+
+ self._label = QW.QLabel(self)
+ self._label.setTextFormat(QC.Qt.RichText)
+ self._label.setWordWrap(True)
+ self._label.setTextInteractionFlags(QC.Qt.TextSelectableByMouse)
+ self._label.setAlignment(QC.Qt.AlignTop | QC.Qt.AlignLeft)
+
+ layout = QW.QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(2)
+ layout.addWidget(self._toggle, 0, QC.Qt.AlignTop)
+ layout.addWidget(self._label, 1)
+
+ # Hide the toggle when there is nothing more to show than the summary.
+ if not self._html or self.html_matches_summary():
+ self._toggle.setVisible(False)
+
+ self._toggle.toggled.connect(self.on_toggled)
+ self.refresh_widget()
+
+ def html_matches_summary(self) -> bool:
+ """Return True when the HTML rendering would not add information."""
+ return self._html.strip() == html.escape(self._summary).strip()
+
+ def on_toggled(self, checked: bool) -> None:
+ """Handle the expand/collapse toggle being toggled."""
+ self._expanded = checked
+ self.refresh_widget()
+ self.toggled.emit(checked)
+
+ def refresh_widget(self) -> None:
+ """Refresh the widget contents to match the current expanded state."""
+ if self._expanded:
+ self._toggle.setArrowType(QC.Qt.DownArrow)
+ self._toggle.setToolTip(_("Hide details"))
+ self._label.setText(self._html or html.escape(self._summary))
+ else:
+ self._toggle.setArrowType(QC.Qt.RightArrow)
+ self._toggle.setToolTip(_("Show details"))
+ self._label.setText(html.escape(self._summary))
+ self.updateGeometry()
+
+ def is_expanded(self) -> bool:
+ """Return current expanded state."""
+ return self._expanded
+
+ def set_expanded(self, expanded: bool) -> None:
+ """Programmatically set the expanded state."""
+ if expanded == self._expanded:
+ return
+ self._toggle.setChecked(expanded)
diff --git a/datalab/widgets/historytree.py b/datalab/widgets/historytree.py
new file mode 100644
index 000000000..928516882
--- /dev/null
+++ b/datalab/widgets/historytree.py
@@ -0,0 +1,319 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""History tree widget used by the History panel."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from guidata.configtools import get_icon
+from qtpy import QtCore as QC
+from qtpy import QtGui as QG
+from qtpy import QtWidgets as QW
+
+from datalab.config import _
+from datalab.gui.panel.history.chainmodel import build_session_chains
+from datalab.history import HistoryAction, HistorySession
+from datalab.widgets.historydescription import CollapsibleDescriptionWidget
+
+if TYPE_CHECKING:
+ from datalab.gui.main import DLMainWindow
+ from datalab.gui.panel.history.panel import HistoryPanel
+
+
+class HistoryTree(QW.QTreeWidget):
+ """Tree widget for the history panel"""
+
+ DESCRIPTION_COLUMN = 2
+ COMPATIBILITY_ROLE = QC.Qt.UserRole + 1
+ SESSION_NUMBER_ROLE = QC.Qt.UserRole + 2
+ ITEM_KIND_ROLE = QC.Qt.UserRole + 3
+ ITEM_SESSION = "session"
+ ITEM_CHAIN = "chain"
+ ITEM_ACTION = "action"
+
+ def __init__(self, parent: QW.QWidget) -> None:
+ """Create a new history tree widget"""
+ super().__init__(parent)
+ self._panel: HistoryPanel = parent
+ self.setHeaderLabels([_("Title"), _("Date and time"), _("Description")])
+ self.setContextMenuPolicy(QC.Qt.CustomContextMenu)
+ self.setSelectionMode(QW.QAbstractItemView.ContiguousSelection)
+ self.setUniformRowHeights(False)
+ header = self.header()
+ header.setSectionResizeMode(self.DESCRIPTION_COLUMN, QW.QHeaderView.Stretch)
+ # Per-action expanded state, preserved across repopulate (delete/replay).
+ self.__expanded_state: dict[str, bool] = {}
+ # Session numbers currently flagged as active recording sessions,
+ # mapped to their panel id ('signal'/'image'). Used to highlight the
+ # active session(s) and survive tree repopulation.
+ self.__active_session_numbers: dict[int, str] = {}
+
+ def on_description_toggled(self, uuid: str, expanded: bool) -> None:
+ """Remember the expanded state of a description cell."""
+ self.__expanded_state[uuid] = expanded
+ # Force the tree to recompute row heights now that the label content
+ # has changed.
+ self.scheduleDelayedItemsLayout()
+
+ def install_description_widget(
+ self, item: QW.QTreeWidgetItem, action: HistoryAction
+ ) -> None:
+ """Attach the collapsible description widget to ``item`` (column 2).
+
+ The item must already be inserted in the tree before calling this.
+ """
+ expanded = self.__expanded_state.get(action.uuid, False)
+ widget = CollapsibleDescriptionWidget(
+ action.description_summary,
+ action.description_html,
+ expanded=expanded,
+ parent=self,
+ )
+ widget.toggled.connect(
+ lambda checked, uuid=action.uuid: self.on_description_toggled(uuid, checked)
+ )
+ # Clear any text the item may carry for that column to avoid double
+ # rendering behind the widget.
+ item.setText(self.DESCRIPTION_COLUMN, "")
+ self.setItemWidget(item, self.DESCRIPTION_COLUMN, widget)
+
+ @classmethod
+ def action_to_tree_item(cls, action: HistoryAction) -> QW.QTreeWidgetItem:
+ """Convert an action to a tree item
+
+ Args:
+ action: Action to convert
+
+ Returns:
+ QW.QTreeWidgetItem: Tree item
+ """
+ # Description column is left empty: a CollapsibleDescriptionWidget is
+ # installed by ``HistoryTree`` once the item is inserted in the tree.
+ item = QW.QTreeWidgetItem([action.title, action.dtstr, ""])
+ item.setData(0, QC.Qt.UserRole, action.uuid)
+ item.setData(0, cls.COMPATIBILITY_ROLE, True)
+ item.setData(0, cls.ITEM_KIND_ROLE, cls.ITEM_ACTION)
+ return item
+
+ def update_compatibility_states(
+ self, history_sessions: list[HistorySession], mainwindow: DLMainWindow
+ ) -> None:
+ """Update action item visual state from workspace compatibility."""
+ default_brush = QG.QBrush()
+ disabled_brush = QG.QBrush(
+ self.palette().color(QG.QPalette.Disabled, QG.QPalette.Text)
+ )
+ compatible_tip = _("Action is compatible with the current workspace state.")
+ incompatible_tip = _(
+ "Action is not compatible with the current workspace state."
+ )
+ actions_by_uuid = {
+ action.uuid: action
+ for session in history_sessions
+ for action in session.actions
+ }
+ iterator = QW.QTreeWidgetItemIterator(self)
+ while iterator.value():
+ item = iterator.value()
+ if item.data(0, self.ITEM_KIND_ROLE) == self.ITEM_ACTION:
+ uuid = item.data(0, QC.Qt.UserRole)
+ action = actions_by_uuid.get(uuid)
+ # The tree can transiently reference an action that was just
+ # removed from the model (e.g. mid-cascade during
+ # reconnect_chain_after_removal, before the final repopulate).
+ # Skip such stale items instead of crashing.
+ if action is None:
+ iterator += 1
+ continue
+ compatible = action.is_current_state_compatible(
+ mainwindow, restore_selection=True
+ )
+ item.setData(0, self.COMPATIBILITY_ROLE, compatible)
+ brush = default_brush if compatible else disabled_brush
+ icon = get_icon("apply.svg") if compatible else get_icon("delete.svg")
+ item.setIcon(0, icon)
+ for col in range(self.columnCount()):
+ item.setForeground(col, brush)
+ item.setToolTip(
+ col, compatible_tip if compatible else incompatible_tip
+ )
+ iterator += 1
+
+ def forget_orphan_expanded_states(
+ self, history_sessions: list[HistorySession]
+ ) -> None:
+ """Drop expanded-state entries for actions that no longer exist."""
+ live_uuids = {
+ action.uuid for session in history_sessions for action in session.actions
+ }
+ self.__expanded_state = {
+ uuid: state
+ for uuid, state in self.__expanded_state.items()
+ if uuid in live_uuids
+ }
+
+ def populate_tree(self, history_sessions: list[HistorySession]) -> None:
+ """Populate the history tree widget
+
+ Args:
+ history_sessions: List of history sessions
+ """
+ self.forget_orphan_expanded_states(history_sessions)
+ self.clear()
+ for session in history_sessions:
+ ritem = QW.QTreeWidgetItem([session.title, session.dtstr])
+ ritem.setData(0, self.COMPATIBILITY_ROLE, True)
+ ritem.setData(0, self.SESSION_NUMBER_ROLE, session.number)
+ ritem.setData(0, self.ITEM_KIND_ROLE, self.ITEM_SESSION)
+ self.addTopLevelItem(ritem)
+ self.build_session_children(ritem, session)
+ self.expandAll()
+ for col in (0, 1):
+ self.resizeColumnToContents(col)
+ self.__apply_active_highlight()
+
+ def build_session_children(
+ self, session_item: QW.QTreeWidgetItem, session: HistorySession
+ ) -> None:
+ """(Re)build the chain/action subtree under ``session_item``.
+
+ Args:
+ session_item: Top-level tree item for ``session``.
+ session: History session whose actions are grouped into chains.
+ """
+ session_item.takeChildren()
+ chains = build_session_chains(session)
+ for chain in chains:
+ chain_item = QW.QTreeWidgetItem([chain.root.title, ""])
+ chain_item.setData(0, self.ITEM_KIND_ROLE, self.ITEM_CHAIN)
+ chain_item.setData(0, self.COMPATIBILITY_ROLE, True)
+ font = chain_item.font(0)
+ font.setBold(True)
+ chain_item.setFont(0, font)
+ chain_item.setToolTip(
+ 0, _("Processing chain \u2014 %d step(s)") % len(chain.actions)
+ )
+ session_item.addChild(chain_item)
+ for action in chain.actions:
+ child = self.action_to_tree_item(action)
+ chain_item.addChild(child)
+ self.install_description_widget(child, action)
+
+ def set_active_sessions(self, active_session_numbers: dict[int, str]) -> None:
+ """Flag the active recording session(s) by session number and panel.
+
+ Args:
+ active_session_numbers: Mapping ``{session.number: panel_str}`` of
+ the active recording session for each panel.
+ """
+ self.__active_session_numbers = dict(active_session_numbers)
+ self.__apply_active_highlight()
+
+ def __apply_active_highlight(self) -> None:
+ """Bold + tint the top-level items of the active recording sessions."""
+ hl = self.palette().color(QG.QPalette.Highlight)
+ hl.setAlpha(60)
+ active_brush = QG.QBrush(hl)
+ normal_brush = QG.QBrush()
+ tip = _("Active recording session ({panel}).")
+ for i in range(self.topLevelItemCount()):
+ item = self.topLevelItem(i)
+ number = item.data(0, self.SESSION_NUMBER_ROLE)
+ panel_str = self.__active_session_numbers.get(number)
+ is_active = panel_str is not None
+ font = item.font(0)
+ font.setBold(is_active)
+ for col in (0, 1):
+ item.setFont(col, font)
+ item.setBackground(col, active_brush if is_active else normal_brush)
+ item.setToolTip(col, tip.format(panel=panel_str) if is_active else "")
+
+ def rearrange_tree(self) -> None:
+ """Rearrange the history tree widget"""
+ self.expandAll()
+ for col in (0, 1):
+ self.resizeColumnToContents(col)
+
+ def rebuild_session(self, session_index: int) -> None:
+ """Rebuild the tree subtree for the session at ``session_index``.
+
+ Args:
+ session_index: Top-level session item index.
+ """
+ ritem = self.topLevelItem(session_index)
+ if ritem is None:
+ return
+ session = self._panel.history_sessions[session_index]
+ self.build_session_children(ritem, session)
+ ritem.setExpanded(True)
+
+ def refresh_action_item(self, action: HistoryAction) -> None:
+ """Refresh the tree item corresponding to ``action``.
+
+ Re-installs the description widget so it reflects the current
+ ``action.kwargs`` (e.g. after the user edited a ``param`` via the
+ Processing tab of the Signal/Image panel). Also applies a light
+ orange background when ``action.is_stale`` is True, to signal that
+ the action is currently being recomputed in a cascade.
+ """
+ target_uuid = action.uuid
+ stale_brush = QG.QBrush(QG.QColor(255, 220, 150)) # light orange
+ normal_brush = QG.QBrush()
+ iterator = QW.QTreeWidgetItemIterator(self)
+ while iterator.value():
+ item = iterator.value()
+ if item.data(0, QC.Qt.UserRole) == target_uuid:
+ # Remove and re-install the collapsible description widget so
+ # it reflects the mutated ``action.kwargs``.
+ self.removeItemWidget(item, self.DESCRIPTION_COLUMN)
+ self.install_description_widget(item, action)
+ brush = stale_brush if action.is_stale else normal_brush
+ for col in range(self.columnCount()):
+ item.setBackground(col, brush)
+ self.scheduleDelayedItemsLayout()
+ return
+ iterator += 1
+
+ def get_action_from_uuid(
+ self, uuid: str, history_sessions: list[HistorySession]
+ ) -> HistoryAction:
+ """Get the action from its UUID
+
+ Args:
+ uuid: Action UUID
+ history_sessions: List of history sessions
+
+ Returns:
+ HistoryAction: Action
+ """
+ for session in history_sessions:
+ for action in session.actions:
+ if action.uuid == uuid:
+ return action
+ raise ValueError("Action not found")
+
+ def get_selected_actions_or_sessions(
+ self, history_sessions: list[HistorySession]
+ ) -> list[HistoryAction | HistorySession]:
+ """Get the selected actions or sessions
+
+ Args:
+ history_sessions: List of history sessions
+
+ Returns:
+ list[HistoryAction | HistorySession]: List of selected actions or sessions
+ """
+ selected: list[HistoryAction | HistorySession] = []
+ for item in self.selectedItems():
+ if item.parent() is None:
+ index = self.indexOfTopLevelItem(item)
+ selected.append(history_sessions[index])
+ elif item.data(0, self.ITEM_KIND_ROLE) == self.ITEM_ACTION:
+ uuid = item.data(0, QC.Qt.UserRole)
+ try:
+ selected.append(self.get_action_from_uuid(uuid, history_sessions))
+ except ValueError:
+ continue
+ # chain-header items are containers only: ignored here.
+ return selected
diff --git a/datalab/widgets/workspacestate_widget.py b/datalab/widgets/workspacestate_widget.py
new file mode 100644
index 000000000..50b81c9e8
--- /dev/null
+++ b/datalab/widgets/workspacestate_widget.py
@@ -0,0 +1,82 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Workspace state display widget used by the History panel."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from qtpy import QtCore as QC
+from qtpy import QtWidgets as QW
+
+from datalab.config import _
+
+if TYPE_CHECKING:
+ from datalab.history import WorkspaceState
+
+
+class WorkspaceStateWidget(QW.QWidget):
+ """Side-by-side tables showing the workspace state captured by a history action.
+
+ Left table: signals (title + data shape).
+ Right table: images (title + data shape/dimensions).
+ """
+
+ def __init__(self, parent: QW.QWidget | None = None) -> None:
+ super().__init__(parent)
+ self._signal_table = QW.QTableWidget(0, 2, self)
+ self._signal_table.setHorizontalHeaderLabels([_("Signal"), _("Shape")])
+ self._signal_table.horizontalHeader().setStretchLastSection(True)
+ self._signal_table.setEditTriggers(QW.QAbstractItemView.NoEditTriggers)
+ self._signal_table.setSelectionMode(QW.QAbstractItemView.NoSelection)
+ self._signal_table.verticalHeader().hide()
+
+ self._image_table = QW.QTableWidget(0, 2, self)
+ self._image_table.setHorizontalHeaderLabels([_("Image"), _("Dimensions")])
+ self._image_table.horizontalHeader().setStretchLastSection(True)
+ self._image_table.setEditTriggers(QW.QAbstractItemView.NoEditTriggers)
+ self._image_table.setSelectionMode(QW.QAbstractItemView.NoSelection)
+ self._image_table.verticalHeader().hide()
+
+ splitter = QW.QSplitter(QC.Qt.Horizontal, self)
+ splitter.addWidget(self._signal_table)
+ splitter.addWidget(self._image_table)
+ layout = QW.QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(splitter)
+
+ def update_from_state(self, state: WorkspaceState | None) -> None:
+ """Populate tables from a WorkspaceState."""
+ self._signal_table.setRowCount(0)
+ self._image_table.setRowCount(0)
+ if state is None:
+ return
+ self.populate_table(self._signal_table, state, "signal")
+ self.populate_table(self._image_table, state, "image")
+
+ @staticmethod
+ def populate_table(
+ table: QW.QTableWidget, state: WorkspaceState, panel_key: str
+ ) -> None:
+ """Fill a table from the state for a given panel key."""
+ titles = state.titles.get(panel_key, [])
+ shapes = state.states.get(panel_key, [])
+ metadata = state.object_metadata.get(panel_key, {})
+ uuids = state.selection.get(panel_key, [])
+ # Use metadata keyed by UUID when available
+ rows: list[tuple[str, str]] = []
+ for i, uuid in enumerate(uuids):
+ title = titles[i] if i < len(titles) else uuid[:8]
+ meta = metadata.get(uuid, {})
+ shape = meta.get("shape")
+ if shape is not None:
+ shape_str = " × ".join(str(s) for s in shape)
+ elif i < len(shapes):
+ shape_str = shapes[i]
+ else:
+ shape_str = "—"
+ rows.append((title, shape_str))
+ table.setRowCount(len(rows))
+ for row_idx, (title, shape_str) in enumerate(rows):
+ table.setItem(row_idx, 0, QW.QTableWidgetItem(title))
+ table.setItem(row_idx, 1, QW.QTableWidgetItem(shape_str))
diff --git a/doc/features/common/historypanel.rst b/doc/features/common/historypanel.rst
new file mode 100644
index 000000000..5d369b438
--- /dev/null
+++ b/doc/features/common/historypanel.rst
@@ -0,0 +1,243 @@
+.. _historypanel:
+
+History Panel
+=============
+
+.. meta::
+ :description: History Panel in DataLab, the open-source scientific data analysis and visualization platform
+ :keywords: DataLab, history, record, replay, session, scientific, data, analysis, visualization, platform
+
+Overview
+--------
+
+The "History Panel" records the sequence of actions performed by the user on
+signals and images, organized into **sessions**. Each session is a chronological
+list of either:
+
+- **UI actions** (creating a new signal, removing selected objects, saving the
+ workspace to HDF5, ...), or
+- **computations** (FFT, average, Gaussian fit, ...) dispatched through the
+ Sigima processor.
+
+A recorded session can be:
+
+- **Replayed** in validation mode, without adding new signal/image outputs to
+ the workspace;
+- **Duplicated and applied**, to create an explicit comparison branch with new
+ outputs in the signal/image panels;
+- **Saved to a standalone history file** (``.dlhist``) or **embedded in the
+ workspace** when saving to HDF5, so that the full processing chain travels
+ with the data.
+
+.. figure:: ../../images/shots/history_panel.png
+ :align: center
+ :alt: History Panel
+
+ The History Panel after recording a representative session: create three
+ signals (Voigt, Lorentzian, Lorentzian), remove one of them, create a
+ Gaussian signal, compute the average, add Gaussian noise to the result
+ and run a Gaussian fit.
+
+Toolbar
+-------
+
+The toolbar at the top of the panel exposes the following actions:
+
+.. |record| image:: ../../../datalab/data/icons/record.svg
+ :width: 24px
+ :height: 24px
+ :class: dark-light no-scaled-link
+
+.. |new_session| image:: ../../../datalab/data/icons/libre-gui-add.svg
+ :width: 24px
+ :height: 24px
+ :class: dark-light no-scaled-link
+
+.. |open_history| image:: ../../../datalab/data/icons/io/fileopen_h5.svg
+ :width: 24px
+ :height: 24px
+ :class: dark-light no-scaled-link
+
+.. |save_history| image:: ../../../datalab/data/icons/io/filesave_h5.svg
+ :width: 24px
+ :height: 24px
+ :class: dark-light no-scaled-link
+
+.. |replay| image:: ../../../datalab/data/icons/replay.svg
+ :width: 24px
+ :height: 24px
+ :class: dark-light no-scaled-link
+
+.. |step_by_step| image:: ../../../datalab/data/icons/edit_mode.svg
+ :width: 24px
+ :height: 24px
+ :class: dark-light no-scaled-link
+
+.. |duplicate| image:: ../../../datalab/data/icons/edit/duplicate.svg
+ :width: 24px
+ :height: 24px
+ :class: dark-light no-scaled-link
+
+.. |step_prev| image:: ../../../datalab/data/icons/libre-gui-arrow-left.svg
+ :width: 24px
+ :height: 24px
+ :class: dark-light no-scaled-link
+
+.. |step_next| image:: ../../../datalab/data/icons/libre-gui-arrow-right.svg
+ :width: 24px
+ :height: 24px
+ :class: dark-light no-scaled-link
+
+.. |delete| image:: ../../../datalab/data/icons/edit/delete.svg
+ :width: 24px
+ :height: 24px
+ :class: dark-light no-scaled-link
+
+.. |remove_incompatible| image:: ../../../datalab/data/icons/edit/delete_all.svg
+ :width: 24px
+ :height: 24px
+ :class: dark-light no-scaled-link
+
+- |record| **Record mode**: toggle the recording of new actions. When off, no
+ new entry is added to the history (existing sessions are preserved).
+- |new_session| **New session**: start a new history session.
+- |open_history| **Open history file**: load recorded sessions from a standalone
+ ``.dlhist`` file.
+- |save_history| **Save history file**: save the current recorded sessions to a
+ standalone ``.dlhist`` file.
+- |step_prev| **Previous step**: select the preceding action in the current
+ session (keyboard shortcut: :kbd:`Ctrl+Left`).
+- |step_next| **Next step**: select the following action in the current
+ session (keyboard shortcut: :kbd:`Ctrl+Right`).
+- |replay| **Replay**: validate/replay the selected action (or the whole
+ session if a session row is selected) without changing the current workspace
+ selection beforehand and without adding new outputs to the signal/image
+ panels.
+- |step_by_step| **Step-by-step**: replay the selection one step at a time,
+ opening the parameters dialog at each step so the user can tweak the
+ parameters before re-running.
+- |duplicate| **Duplicate**: copy the selected action or session into a new
+ history session. The copied parameters are independent from the original
+ record.
+- |remove_incompatible| **Remove incompatible**: remove all actions whose
+ workspace state is no longer compatible with the current workspace. A
+ confirmation dialog shows how many actions will be removed.
+- |delete| **Delete**: remove the selected actions or sessions from the
+ history.
+
+.. note::
+
+ Double-clicking on an action row in the tree is equivalent to **Replay**.
+
+Tree view
+---------
+
+The tree view organizes recorded actions into expandable sessions:
+
+- Each top-level row is a **session**, automatically created when recording is
+ enabled and a new application context is started.
+- Each child row is an **action**, with its title, date/time and a description
+ summarising the parameters (for computations) or the call (for UI actions).
+
+The selection of one or several rows drives which actions are targeted by the
+toolbar buttons.
+
+Actions that are not compatible with the current workspace state (for example
+because a referenced object identifier no longer exists, or because its data
+shape changed) are shown with a disabled foreground and an explanatory tooltip.
+They cannot be replayed until the workspace matches the recorded state again.
+
+Workspace state display
+-----------------------
+
+Below the action tree, a split-view widget shows the **workspace state**
+captured at the time of the selected action:
+
+- **Left table**: lists the signals that were selected, with their data shape.
+- **Right table**: lists the images that were selected, with their dimensions.
+
+This information helps the user understand the context in which each action
+was originally executed and diagnose compatibility issues when replaying
+sessions on a different workspace.
+
+Session replay across workspaces
+--------------------------------
+
+A full session can be replayed on a workspace that no longer contains the
+objects originally referenced by the recorded actions -- typically after
+loading a saved session into a fresh workspace. In that case, the panel
+**remaps the recorded object identifiers** to the newly-created ones on the
+fly:
+
+- UI actions creating new objects (e.g. *New signal*) enqueue the freshly
+ created identifiers;
+- subsequent computations claim the identifiers they need from that queue,
+ in the same order as the original recording;
+- UI actions removing objects keep the queue in sync with the live workspace
+ contents, so chained creation/removal sequences replay correctly.
+
+This makes it possible, for instance, to record a full processing chain on
+one dataset, save it, then re-apply the exact same chain on a different but
+structurally identical input.
+
+Persistence
+-----------
+
+The history can be persisted in two complementary ways:
+
+- **Embedded in the workspace**: when the workspace is saved to HDF5
+ (``File > Save to HDF5 file``), the History Panel content is automatically
+ saved alongside the signals and images. Reloading the workspace restores
+ the recorded sessions.
+- **Standalone history file** (``.dlhist``): the file embeds both the
+ recorded sessions **and** all signal/image objects referenced by those
+ sessions. This makes the file fully self-contained:
+
+ - Opening a ``.dlhist`` into an **empty workspace** loads sessions and
+ objects directly, restoring the workspace to its recorded state.
+ - Opening a ``.dlhist`` into a **non-empty workspace** creates new
+ signal/image groups for the imported objects (with remapped identifiers
+ to avoid collisions) and appends new history sessions that reference
+ those fresh identifiers.
+
+.. warning::
+
+ Replaying a session that depends on external files (e.g. opening a
+ dataset from disk) will only succeed if those files are still available at
+ the same locations as when the session was recorded.
+
+Chain reconnection on deletion
+-------------------------------
+
+When a result object is deleted from the **signal or image panel** (not
+from the History Panel tree), and that object was produced by a recorded
+processing step, the History Panel automatically reconnects the processing
+chain:
+
+- All downstream steps that consumed the deleted object are rewired to use
+ the source of the deleted step as their new input.
+- For ``2_to_1`` operations (e.g. *difference*), the first source is used
+ for reconnection.
+- If no valid source can be determined (e.g. the source itself was already
+ deleted), a warning is displayed listing the unreconnectable operations,
+ but the deletion is allowed to proceed.
+
+This behaviour mirrors removing a link from a chain: the adjacent links
+reconnect to preserve the processing flow.
+
+.. note::
+
+ Reconnection is only triggered by deletions initiated from the
+ signal/image panels. Deleting an action directly from the History Panel
+ tree removes it and all subsequent actions in that session.
+
+Auto-recompute
+--------------
+
+.. note::
+
+ When a result object is selected in the signal/image panel and it has
+ processing parameters (i.e. was produced by a 1-to-1 computation), a
+ **Processing** tab appears in the Properties panel. Checking
+ **Auto-recompute on edit** in that tab will re-run the computation
+ automatically 300 ms after any parameter modification.
diff --git a/doc/features/image/menu_edit.rst b/doc/features/image/menu_edit.rst
index 67a9bdbb8..8e88a4008 100644
--- a/doc/features/image/menu_edit.rst
+++ b/doc/features/image/menu_edit.rst
@@ -37,6 +37,12 @@ their original processing parameters. This is useful when you want to re-execute
processing chain that was used to create an image, for example after modifying global
settings or dependencies.
+It also refreshes analysis results (statistics, FWHM, centroid, peak/contour/blob
+detection, etc.) using their stored parameters. Analysis results are not recomputed
+automatically when you modify a region of interest, the data, or object properties:
+existing results are left untouched until you explicitly trigger this action, giving you
+full control over when analyses are refreshed.
+
.. |recompute| image:: ../../../datalab/data/icons/edit/recompute.svg
:width: 24px
:height: 24px
@@ -44,8 +50,9 @@ settings or dependencies.
.. note::
- This action is only available for images that were created through processing
- operations and have stored processing parameters.
+ This action is only available for images that have stored processing parameters
+ (created through processing operations) or stored analysis parameters (analysis
+ operations previously run on them).
Select source objects
---------------------
diff --git a/doc/features/index.rst b/doc/features/index.rst
index b05605bf2..09254926d 100644
--- a/doc/features/index.rst
+++ b/doc/features/index.rst
@@ -35,6 +35,7 @@ Overview & Common features
common/overview
common/settings
common/h5browser
+ common/historypanel
.. raw:: latex
diff --git a/doc/features/signal/menu_edit.rst b/doc/features/signal/menu_edit.rst
index 691195c82..280cfe3c8 100644
--- a/doc/features/signal/menu_edit.rst
+++ b/doc/features/signal/menu_edit.rst
@@ -33,6 +33,12 @@ their original processing parameters. This is useful when you want to re-execute
processing chain that was used to create a signal, for example after modifying global
settings or dependencies.
+It also refreshes **analysis results** (statistics, FWHM, centroid, peak/contour
+detection, etc.) using their stored parameters. Analysis results are **not** recomputed
+automatically when you modify a region of interest, the data, or object properties:
+existing results are left untouched until you explicitly trigger this action, giving you
+full control over when analyses are refreshed.
+
.. |recompute| image:: ../../../datalab/data/icons/edit/recompute.svg
:width: 24px
:height: 24px
@@ -40,8 +46,9 @@ settings or dependencies.
.. note::
- This action is only available for signals that were created through processing
- operations and have stored processing parameters.
+ This action is only available for signals that have stored processing parameters
+ (created through processing operations) or stored analysis parameters (analysis
+ operations previously run on them).
Select source objects
---------------------
diff --git a/doc/images/shots/history_panel.png b/doc/images/shots/history_panel.png
new file mode 100644
index 000000000..0ebd0e327
Binary files /dev/null and b/doc/images/shots/history_panel.png differ
diff --git a/doc/locale/fr/LC_MESSAGES/features/common/historypanel.po b/doc/locale/fr/LC_MESSAGES/features/common/historypanel.po
new file mode 100644
index 000000000..26ed9899d
--- /dev/null
+++ b/doc/locale/fr/LC_MESSAGES/features/common/historypanel.po
@@ -0,0 +1,243 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) 2023, DataLab Platform Developers
+# This file is distributed under the same license as the DataLab package.
+# FIRST AUTHOR , 2026.
+#
+msgid ""
+msgstr ""
+"Language: fr\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+
+msgid "History Panel in DataLab, the open-source scientific data analysis and visualization platform"
+msgstr "Panneau historique de DataLab, la plateforme open-source d'analyse et de visualisation de données scientifiques"
+
+msgid "DataLab, history, record, replay, session, scientific, data, analysis, visualization, platform"
+msgstr "DataLab, historique, enregistrement, rejeu, session, scientifique, données, analyse, visualisation, plateforme"
+
+msgid "History Panel"
+msgstr "Panneau historique"
+
+msgid "Overview"
+msgstr "Vue d'ensemble"
+
+msgid "The \"History Panel\" records the sequence of actions performed by the user on signals and images, organized into **sessions**. Each session is a chronological list of either:"
+msgstr "Le « Panneau historique » enregistre la séquence des actions effectuées par l'utilisateur sur les signaux et les images, organisée en **sessions**. Chaque session est une liste chronologique constituée soit :"
+
+msgid "**UI actions** (creating a new signal, removing selected objects, saving the workspace to HDF5, ...), or"
+msgstr "d'**actions de l'interface** (création d'un nouveau signal, suppression des objets sélectionnés, enregistrement de l'espace de travail au format HDF5, ...), soit"
+
+msgid "**computations** (FFT, average, Gaussian fit, ...) dispatched through the Sigima processor."
+msgstr "de **calculs** (FFT, moyenne, ajustement gaussien, ...) exécutés via le processeur Sigima."
+
+msgid "A recorded session can be:"
+msgstr "Une session enregistrée peut être :"
+
+msgid "**Replayed** in validation mode, without adding new signal/image outputs to the workspace;"
+msgstr "**rejouée** en mode validation, sans ajouter de nouvelles sorties signal/image à l'espace de travail ;"
+
+msgid "**Duplicated and applied**, to create an explicit comparison branch with new outputs in the signal/image panels;"
+msgstr "**dupliquée et appliquée**, afin de créer une branche de comparaison explicite avec de nouvelles sorties dans les panneaux signal/image ;"
+
+msgid "**Saved to a standalone history file** (``.dlhist``) or **embedded in the workspace** when saving to HDF5, so that the full processing chain travels with the data."
+msgstr "**sauvegardée dans un fichier d'historique autonome** (``.dlhist``) ou **intégrée à l'espace de travail** lors de l'enregistrement au format HDF5, de sorte que toute la chaîne de traitement accompagne les données."
+
+msgid "The History Panel after recording a representative session: create three signals (Voigt, Lorentzian, Lorentzian), remove one of them, create a Gaussian signal, compute the average, add Gaussian noise to the result and run a Gaussian fit."
+msgstr "Le Panneau historique après l'enregistrement d'une session représentative : création de trois signaux (Voigt, lorentzien, lorentzien), suppression de l'un d'eux, création d'un signal gaussien, calcul de la moyenne, ajout d'un bruit gaussien au résultat et ajustement par une gaussienne."
+
+msgid "Toolbar"
+msgstr "Barre d'outils"
+
+msgid "The toolbar at the top of the panel exposes the following actions:"
+msgstr "La barre d'outils en haut du panneau expose les actions suivantes :"
+
+msgid "|record| **Record mode**: toggle the recording of new actions. When off, no new entry is added to the history (existing sessions are preserved)."
+msgstr "|record| **Mode enregistrement** : active ou désactive l'enregistrement des nouvelles actions. Lorsqu'il est désactivé, aucune nouvelle entrée n'est ajoutée à l'historique (les sessions existantes sont conservées)."
+
+msgid "record"
+msgstr "record"
+
+msgid "|new_session| **New session**: start a new history session."
+msgstr "|new_session| **Nouvelle session** : démarre une nouvelle session d'historique."
+
+msgid "new_session"
+msgstr "new_session"
+
+msgid "|open_history| **Open history file**: load recorded sessions from a standalone ``.dlhist`` file."
+msgstr "|open_history| **Ouvrir un fichier d'historique** : charge des sessions enregistrées depuis un fichier ``.dlhist`` autonome."
+
+msgid "open_history"
+msgstr "open_history"
+
+msgid "|save_history| **Save history file**: save the current recorded sessions to a standalone ``.dlhist`` file."
+msgstr "|save_history| **Enregistrer le fichier d'historique** : enregistre les sessions actuellement enregistrées dans un fichier ``.dlhist`` autonome."
+
+msgid "save_history"
+msgstr "save_history"
+
+msgid "|step_prev| **Previous step**: select the preceding action in the current session (keyboard shortcut: :kbd:`Ctrl+Left`)."
+msgstr "|step_prev| **Étape précédente** : sélectionne l'action précédente dans la session courante (raccourci clavier : :kbd:`Ctrl+Gauche`)."
+
+msgid "step_prev"
+msgstr "step_prev"
+
+msgid "|step_next| **Next step**: select the following action in the current session (keyboard shortcut: :kbd:`Ctrl+Right`)."
+msgstr "|step_next| **Étape suivante** : sélectionne l'action suivante dans la session courante (raccourci clavier : :kbd:`Ctrl+Droite`)."
+
+msgid "step_next"
+msgstr "step_next"
+
+msgid "|replay| **Replay**: validate/replay the selected action (or the whole session if a session row is selected) without changing the current workspace selection beforehand and without adding new outputs to the signal/image panels."
+msgstr "|replay| **Rejouer** : valide/rejoue l'action sélectionnée (ou la session entière si une ligne de session est sélectionnée) sans modifier au préalable la sélection courante de l'espace de travail et sans ajouter de nouvelles sorties aux panneaux signal/image."
+
+msgid "replay"
+msgstr "replay"
+
+msgid "|step_by_step| **Step-by-step**: replay the selection one step at a time, opening the parameters dialog at each step so the user can tweak the parameters before re-running."
+msgstr "|step_by_step| **Pas à pas** : rejoue la sélection étape par étape, en ouvrant la boîte de dialogue des paramètres à chaque étape afin que l'utilisateur puisse ajuster les paramètres avant de relancer."
+
+msgid "step_by_step"
+msgstr "step_by_step"
+
+msgid "|duplicate| **Duplicate**: copy the selected action or session into a new history session. The copied parameters are independent from the original record."
+msgstr "|duplicate| **Dupliquer** : copie l'action ou la session sélectionnée dans une nouvelle session d'historique. Les paramètres copiés sont indépendants de l'enregistrement d'origine."
+
+msgid "duplicate"
+msgstr "duplicate"
+
+msgid "|remove_incompatible| **Remove incompatible**: remove all actions whose workspace state is no longer compatible with the current workspace. A confirmation dialog shows how many actions will be removed."
+msgstr "|remove_incompatible| **Supprimer les incompatibles** : supprime toutes les actions dont l'état de l'espace de travail n'est plus compatible avec l'espace de travail actuel. Une boîte de dialogue de confirmation indique combien d'actions seront supprimées."
+
+msgid "remove_incompatible"
+msgstr "remove_incompatible"
+
+msgid "|delete| **Delete**: remove the selected actions or sessions from the history."
+msgstr "|delete| **Supprimer** : retire de l'historique les actions ou sessions sélectionnées."
+
+msgid "delete"
+msgstr "delete"
+
+msgid "Double-clicking on an action row in the tree is equivalent to **Replay**."
+msgstr "Un double-clic sur la ligne d'une action dans l'arborescence équivaut à **Rejouer**."
+
+msgid "Tree view"
+msgstr "Arborescence"
+
+msgid "The tree view organizes recorded actions into expandable sessions:"
+msgstr "L'arborescence organise les actions enregistrées dans des sessions dépliables :"
+
+msgid "Each top-level row is a **session**, automatically created when recording is enabled and a new application context is started."
+msgstr "Chaque ligne de premier niveau est une **session**, créée automatiquement lorsque l'enregistrement est activé et qu'un nouveau contexte d'application est démarré."
+
+msgid "Each child row is an **action**, with its title, date/time and a description summarising the parameters (for computations) or the call (for UI actions)."
+msgstr "Chaque ligne enfant est une **action**, accompagnée de son titre, de sa date et de son heure, ainsi que d'une description résumant les paramètres (pour les calculs) ou l'appel (pour les actions de l'interface)."
+
+msgid "The selection of one or several rows drives which actions are targeted by the toolbar buttons."
+msgstr "La sélection d'une ou de plusieurs lignes détermine les actions ciblées par les boutons de la barre d'outils."
+
+msgid "Actions that are not compatible with the current workspace state (for example because a referenced object identifier no longer exists, or because its data shape changed) are shown with a disabled foreground and an explanatory tooltip. They cannot be replayed until the workspace matches the recorded state again."
+msgstr "Les actions qui ne sont pas compatibles avec l'état courant de l'espace de travail (par exemple parce qu'un identifiant d'objet référencé n'existe plus, ou parce que la forme de ses données a changé) sont affichées avec un texte désactivé et une infobulle explicative. Elles ne peuvent pas être rejouées tant que l'espace de travail ne correspond pas de nouveau à l'état enregistré."
+
+msgid "Workspace state display"
+msgstr "Affichage de l'état de l'espace de travail"
+
+msgid "Below the action tree, a split-view widget shows the **workspace state** captured at the time of the selected action:"
+msgstr "Sous l'arborescence des actions, un widget en vue divisée affiche l'**état de l'espace de travail** tel qu'il était au moment de l'action sélectionnée :"
+
+msgid "**Left table**: lists the signals that were selected, with their data shape."
+msgstr "**Tableau de gauche** : liste les signaux qui étaient sélectionnés, avec leur forme de données."
+
+msgid "**Right table**: lists the images that were selected, with their dimensions."
+msgstr "**Tableau de droite** : liste les images qui étaient sélectionnées, avec leurs dimensions."
+
+msgid "This information helps the user understand the context in which each action was originally executed and diagnose compatibility issues when replaying sessions on a different workspace."
+msgstr "Ces informations aident l'utilisateur à comprendre le contexte dans lequel chaque action a été exécutée à l'origine et à diagnostiquer les problèmes de compatibilité lors du rejeu de sessions sur un espace de travail différent."
+
+msgid "Session replay across workspaces"
+msgstr "Rejeu d'une session entre espaces de travail"
+
+msgid "A full session can be replayed on a workspace that no longer contains the objects originally referenced by the recorded actions -- typically after loading a saved session into a fresh workspace. In that case, the panel **remaps the recorded object identifiers** to the newly-created ones on the fly:"
+msgstr "Une session complète peut être rejouée sur un espace de travail qui ne contient plus les objets référencés à l'origine par les actions enregistrées -- typiquement après le chargement d'une session sauvegardée dans un espace de travail vierge. Dans ce cas, le panneau **réassocie à la volée les identifiants d'objets enregistrés** aux nouveaux identifiants créés :"
+
+msgid "UI actions creating new objects (e.g. *New signal*) enqueue the freshly created identifiers;"
+msgstr "les actions de l'interface qui créent de nouveaux objets (par exemple *Nouveau signal*) empilent les identifiants fraîchement créés ;"
+
+msgid "subsequent computations claim the identifiers they need from that queue, in the same order as the original recording;"
+msgstr "les calculs ultérieurs récupèrent dans cette file les identifiants dont ils ont besoin, dans l'ordre de l'enregistrement initial ;"
+
+msgid "UI actions removing objects keep the queue in sync with the live workspace contents, so chained creation/removal sequences replay correctly."
+msgstr "les actions de l'interface qui suppriment des objets maintiennent la file synchronisée avec le contenu réel de l'espace de travail, de sorte que les séquences enchaînées de création et de suppression se rejouent correctement."
+
+msgid "This makes it possible, for instance, to record a full processing chain on one dataset, save it, then re-apply the exact same chain on a different but structurally identical input."
+msgstr "Cela permet par exemple d'enregistrer une chaîne de traitement complète sur un jeu de données, de la sauvegarder, puis de la ré-appliquer telle quelle à une entrée différente mais structurellement identique."
+
+msgid "Persistence"
+msgstr "Persistance"
+
+msgid "The history can be persisted in two complementary ways:"
+msgstr "L'historique peut être persisté de deux manières complémentaires :"
+
+msgid "**Embedded in the workspace**: when the workspace is saved to HDF5 (``File > Save to HDF5 file``), the History Panel content is automatically saved alongside the signals and images. Reloading the workspace restores the recorded sessions."
+msgstr "**Intégré à l'espace de travail** : lorsque l'espace de travail est enregistré au format HDF5 (``Fichier > Enregistrer dans un fichier HDF5``), le contenu du Panneau historique est automatiquement sauvegardé aux côtés des signaux et des images. Le rechargement de l'espace de travail restaure les sessions enregistrées."
+
+msgid "**Standalone history file** (``.dlhist``): the file embeds both the recorded sessions **and** all signal/image objects referenced by those sessions. This makes the file fully self-contained:"
+msgstr "**Fichier d'historique autonome** (``.dlhist``) : le fichier embarque à la fois les sessions enregistrées **et** tous les objets signal/image référencés par ces sessions. Cela rend le fichier entièrement autonome :"
+
+msgid "Opening a ``.dlhist`` into an **empty workspace** loads sessions and objects directly, restoring the workspace to its recorded state."
+msgstr "Ouvrir un fichier ``.dlhist`` dans un **espace de travail vide** charge les sessions et les objets directement, restaurant l'espace de travail dans son état enregistré."
+
+msgid "Opening a ``.dlhist`` into a **non-empty workspace** creates new signal/image groups for the imported objects (with remapped identifiers to avoid collisions) and appends new history sessions that reference those fresh identifiers."
+msgstr "Ouvrir un fichier ``.dlhist`` dans un **espace de travail non vide** crée de nouveaux groupes signal/image pour les objets importés (avec des identifiants remappés pour éviter les collisions) et ajoute de nouvelles sessions d'historique référençant ces nouveaux identifiants."
+
+msgid "Replaying a session that depends on external files (e.g. opening a dataset from disk) will only succeed if those files are still available at the same locations as when the session was recorded."
+msgstr "Le rejeu d'une session qui dépend de fichiers externes (par exemple l'ouverture d'un jeu de données depuis le disque) ne réussira que si ces fichiers sont toujours disponibles aux mêmes emplacements qu'au moment de l'enregistrement de la session."
+
+msgid "Chain reconnection on deletion"
+msgstr "Reconnexion de la chaîne lors d'une suppression"
+
+msgid "When a result object is deleted from the **signal or image panel** (not from the History Panel tree), and that object was produced by a recorded processing step, the History Panel automatically reconnects the processing chain:"
+msgstr "Lorsqu'un objet résultat est supprimé depuis le **panneau signal ou image** (et non depuis l'arborescence du panneau historique), et que cet objet a été produit par une étape de traitement enregistrée, le Panneau historique reconnecte automatiquement la chaîne de traitement :"
+
+msgid "All downstream steps that consumed the deleted object are rewired to use the source of the deleted step as their new input."
+msgstr "Toutes les étapes aval qui consommaient l'objet supprimé sont recâblées pour utiliser la source de l'étape supprimée comme nouvelle entrée."
+
+msgid "For ``2_to_1`` operations (e.g. *difference*), the first source is used for reconnection."
+msgstr "Pour les opérations ``2_to_1`` (par exemple *différence*), la première source est utilisée pour la reconnexion."
+
+msgid "If no valid source can be determined (e.g. the source itself was already deleted), a warning is displayed listing the unreconnectable operations, but the deletion is allowed to proceed."
+msgstr "Si aucune source valide ne peut être déterminée (par exemple la source elle-même a déjà été supprimée), un avertissement est affiché listant les opérations non reconnectables, mais la suppression est néanmoins autorisée."
+
+msgid "This behaviour mirrors removing a link from a chain: the adjacent links reconnect to preserve the processing flow."
+msgstr "Ce comportement reproduit la suppression d'un maillon d'une chaîne : les maillons adjacents se reconnectent pour préserver le flux de traitement."
+
+msgid "Reconnection is only triggered by deletions initiated from the signal/image panels. Deleting an action directly from the History Panel tree removes it and all subsequent actions in that session."
+msgstr "La reconnexion n'est déclenchée que par les suppressions initiées depuis les panneaux signal/image. Supprimer une action directement depuis l'arborescence du Panneau historique la retire ainsi que toutes les actions suivantes de cette session."
+
+msgid "Auto-recompute"
+msgstr "Recalcul automatique"
+
+msgid "When a result object is selected in the signal/image panel and it has processing parameters (i.e. was produced by a 1-to-1 computation), a **Processing** tab appears in the Properties panel. Checking **Auto-recompute on edit** in that tab will re-run the computation automatically 300 ms after any parameter modification."
+msgstr "Lorsqu'un objet résultat est sélectionné dans le panneau signal/image et qu'il possède des paramètres de traitement (c'est-à-dire qu'il a été produit par un calcul 1-à-1), un onglet **Traitement** apparaît dans le panneau Propriétés. Cocher **Recalcul automatique lors de l'édition** dans cet onglet relancera automatiquement le calcul 300 ms après toute modification d'un paramètre."
+
+#~ msgid "**Restored to a given selection state** without re-executing anything, to quickly jump back to a previous working context;"
+#~ msgstr "**restaurée dans un état de sélection donné** sans rien réexécuter, afin de revenir rapidement à un contexte de travail antérieur ;"
+
+#~ msgid "|restore_selection| **Restore selection**: only re-select the objects that were selected when the action was originally executed; no computation is re-run."
+#~ msgstr "|restore_selection| **Restaurer la sélection** : ré-sélectionne uniquement les objets qui étaient sélectionnés lors de l'exécution initiale de l'action ; aucun calcul n'est ré-exécuté."
+
+#~ msgid "restore_selection"
+#~ msgstr "restore_selection"
+
+#~ msgid "|edit_mode| **Edit mode**: when on, replaying a computation opens the parameters dialog so the user can tweak the parameters before re-running. When replaying a *whole session*, the parameter dialogs open in a **read-only** mode — all fields are shown with their recorded values but cannot be edited."
+#~ msgstr "|edit_mode| **Mode édition** : lorsqu'il est activé, le rejeu d'un calcul ouvre la boîte de dialogue des paramètres afin que l'utilisateur puisse ajuster les paramètres avant de relancer. Lors du rejeu d'une *session entière*, les boîtes de dialogue des paramètres s'ouvrent en mode **lecture seule** — tous les champs affichent les valeurs enregistrées mais ne peuvent pas être édités."
+
+#~ msgid "edit_mode"
+#~ msgstr "edit_mode"
+
+#~ msgid "|generate_macro| **Generate macro**: generate a Python macro script from the selected actions (or all actions if nothing is selected). The generated script is copied to the clipboard."
+#~ msgstr "|generate_macro| **Générer une macro** : génère un script macro Python à partir des actions sélectionnées (ou de toutes les actions si rien n'est sélectionné). Le script généré est copié dans le presse-papiers."
+
+#~ msgid "generate_macro"
+#~ msgstr "generate_macro"
diff --git a/doc/locale/fr/LC_MESSAGES/features/image/menu_edit.po b/doc/locale/fr/LC_MESSAGES/features/image/menu_edit.po
index dd72af11c..71f157eb3 100644
--- a/doc/locale/fr/LC_MESSAGES/features/image/menu_edit.po
+++ b/doc/locale/fr/LC_MESSAGES/features/image/menu_edit.po
@@ -48,8 +48,11 @@ msgstr "L'action \"Recalculer\" |recompute| vous permet de recalculer l'image (o
msgid "recompute"
msgstr ""
-msgid "This action is only available for images that were created through processing operations and have stored processing parameters."
-msgstr "Cette action n'est disponible que pour les images qui ont été créées par des opérations de traitement et qui ont des paramètres de traitement stockés."
+msgid "It also refreshes analysis results (statistics, FWHM, centroid, peak/contour/blob detection, etc.) using their stored parameters. Analysis results are not recomputed automatically when you modify a region of interest, the data, or object properties: existing results are left untouched until you explicitly trigger this action, giving you full control over when analyses are refreshed."
+msgstr "Cette action actualise également les résultats d'analyse (statistiques, FWHM, centroïde, détection de pics, de contours ou de blobs, etc.) à partir de leurs paramètres enregistrés. Les résultats d'analyse ne sont pas recalculés automatiquement lorsque vous modifiez une région d'intérêt, les données ou les propriétés de l'objet : les résultats existants restent inchangés jusqu'à ce que vous déclenchiez explicitement cette action, vous laissant ainsi un contrôle total sur le moment où les analyses sont actualisées."
+
+msgid "This action is only available for images that have stored processing parameters (created through processing operations) or stored analysis parameters (analysis operations previously run on them)."
+msgstr "Cette action n'est disponible que pour les images disposant de paramètres de traitement enregistrés (images créées par des opérations de traitement) ou de paramètres d'analyse enregistrés (opérations d'analyse exécutées précédemment sur celles-ci)."
msgid "Select source objects"
msgstr "Sélectionner les objets sources"
diff --git a/doc/locale/fr/LC_MESSAGES/features/signal/menu_edit.po b/doc/locale/fr/LC_MESSAGES/features/signal/menu_edit.po
index c9b5a30fe..8a398543c 100644
--- a/doc/locale/fr/LC_MESSAGES/features/signal/menu_edit.po
+++ b/doc/locale/fr/LC_MESSAGES/features/signal/menu_edit.po
@@ -45,8 +45,11 @@ msgstr "L'action \"Recalculer\" |recompute| vous permet de recalculer le signal
msgid "recompute"
msgstr ""
-msgid "This action is only available for signals that were created through processing operations and have stored processing parameters."
-msgstr "Cette action n'est disponible que pour les signaux qui ont été créés par des opérations de traitement et qui ont des paramètres de traitement stockés."
+msgid "It also refreshes **analysis results** (statistics, FWHM, centroid, peak/contour detection, etc.) using their stored parameters. Analysis results are **not** recomputed automatically when you modify a region of interest, the data, or object properties: existing results are left untouched until you explicitly trigger this action, giving you full control over when analyses are refreshed."
+msgstr "Cette action actualise également les **résultats d'analyse** (statistiques, FWHM, centroïde, détection de pics ou de contours, etc.) à partir de leurs paramètres enregistrés. Les résultats d'analyse ne sont **pas** recalculés automatiquement lorsque vous modifiez une région d'intérêt, les données ou les propriétés de l'objet : les résultats existants restent inchangés jusqu'à ce que vous déclenchiez explicitement cette action, vous laissant ainsi un contrôle total sur le moment où les analyses sont actualisées."
+
+msgid "This action is only available for signals that have stored processing parameters (created through processing operations) or stored analysis parameters (analysis operations previously run on them)."
+msgstr "Cette action n'est disponible que pour les signaux disposant de paramètres de traitement enregistrés (signaux créés par des opérations de traitement) ou de paramètres d'analyse enregistrés (opérations d'analyse exécutées précédemment sur ceux-ci)."
msgid "Select source objects"
msgstr "Sélectionner les objets sources"
diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po
index 4e57afc5e..bce23bbfb 100644
--- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po
+++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po
@@ -23,17 +23,40 @@ msgstr "✨ Nouvelles fonctionnalités"
msgid "**Third-party plugin discovery via environment variable:**"
msgstr "**Découverte des plugins tiers via une variable d'environnement :**"
-msgid "Added support for the `DATALAB_PLUGINS` environment variable, allowing one or more directories to be specified as additional plugin search paths"
-msgstr "Ajout de la prise en charge de la variable d'environnement `DATALAB_PLUGINS`, permettant de spécifier un ou plusieurs répertoires comme chemins de recherche supplémentaires pour les plugins"
+msgid ""
+"Added support for the `DATALAB_PLUGINS` environment variable, allowing "
+"one or more directories to be specified as additional plugin search paths"
+msgstr ""
+"Ajout de la prise en charge de la variable d'environnement "
+"`DATALAB_PLUGINS`, permettant de spécifier un ou plusieurs répertoires "
+"comme chemins de recherche supplémentaires pour les plugins"
-msgid "Multiple directories can be listed using the OS path separator (`;` on Windows, `:` on Linux/macOS), following the same convention as `PYTHONPATH`"
-msgstr "Plusieurs répertoires peuvent être listés en utilisant le séparateur de chemin du système d'exploitation (`;` sous Windows, `:` sous Linux/macOS), selon la même convention que `PYTHONPATH`"
+msgid ""
+"Multiple directories can be listed using the OS path separator (`;` on "
+"Windows, `:` on Linux/macOS), following the same convention as "
+"`PYTHONPATH`"
+msgstr ""
+"Plusieurs répertoires peuvent être listés en utilisant le séparateur de "
+"chemin du système d'exploitation (`;` sous Windows, `:` sous "
+"Linux/macOS), selon la même convention que `PYTHONPATH`"
-msgid "Listed directories are appended to the existing plugin search paths at startup and are picked up automatically by the plugin discovery mechanism"
-msgstr "Les répertoires listés sont ajoutés aux chemins de recherche existants au démarrage et sont automatiquement pris en compte par le mécanisme de découverte des plugins"
+msgid ""
+"Listed directories are appended to the existing plugin search paths at "
+"startup and are picked up automatically by the plugin discovery mechanism"
+msgstr ""
+"Les répertoires listés sont ajoutés aux chemins de recherche existants au"
+" démarrage et sont automatiquement pris en compte par le mécanisme de "
+"découverte des plugins"
-msgid "Non-existent directories are silently skipped (a warning is recorded in the log file), so a stale environment variable on another machine will not prevent DataLab from starting"
-msgstr "Les répertoires inexistants sont ignorés silencieusement (un avertissement est consigné dans le fichier journal), ainsi une variable d'environnement obsolète sur une autre machine n'empêchera pas le démarrage de DataLab"
+msgid ""
+"Non-existent directories are silently skipped (a warning is recorded in "
+"the log file), so a stale environment variable on another machine will "
+"not prevent DataLab from starting"
+msgstr ""
+"Les répertoires inexistants sont ignorés silencieusement (un "
+"avertissement est consigné dans le fichier journal), ainsi une variable "
+"d'environnement obsolète sur une autre machine n'empêchera pas le "
+"démarrage de DataLab"
msgid "**Replace special values processing (signal and image):**"
msgstr "**Traitement de remplacement des valeurs spéciales (signal et image) :**"
@@ -58,3 +81,52 @@ msgstr "Lorsqu'une stratégie de voisinage est sélectionnée, un **aperçu en d
msgid "Integer images are handled explicitly: because `NaN` and infinite values cannot exist in integer data, the dialog explains that the operation is not applicable and prevents accidental processing, while preserving the original image data type without unnecessary float conversion"
msgstr "Les images entières sont traitées explicitement : comme les valeurs `NaN` et infinies ne peuvent pas exister dans les données entières, la boîte de dialogue explique que l'opération n'est pas applicable et empêche un traitement accidentel, tout en préservant le type de données d'image d'origine sans conversion inutile en flottant"
+
+msgid "**History Panel sessions:**"
+msgstr "**Sessions du panneau d'historique :**"
+
+msgid ""
+"Added serialized and replayable history sessions with workspace-state "
+"validation"
+msgstr ""
+"Ajout de sessions d'historique sérialisées et rejouables, avec validation "
+"de l'état de l'espace de travail"
+
+msgid ""
+"Added `.dlhist` import/export support and separated reset sessions from "
+"regular history sessions"
+msgstr ""
+"Ajout de la prise en charge de l'import/export `.dlhist` et séparation des "
+"sessions de réinitialisation des sessions d'historique ordinaires"
+
+msgid "Improved replay compatibility reporting for clearer user feedback"
+msgstr ""
+"Amélioration du rapport de compatibilité de relecture pour fournir un retour "
+"utilisateur plus clair"
+
+msgid "🔄 Changes"
+msgstr "🔄 Modifications"
+
+msgid "**Detection tools now preserve existing regions of interest:**"
+msgstr "**Les outils de détection préservent désormais les régions d'intérêt existantes :**"
+
+msgid "Regions of interest created by peak, blob, Hough circle, and contour detection are now added to existing ROIs instead of replacing them, without displaying a destructive confirmation dialog"
+msgstr "Les régions d'intérêt créées par la détection de pics, de blobs, de cercles de Hough et de contours sont désormais ajoutées aux ROI existantes au lieu de les remplacer, sans afficher de boîte de dialogue de confirmation destructive"
+
+msgid "**Analysis results are now refreshed on demand instead of automatically:**"
+msgstr "**Les résultats d'analyse sont désormais actualisés à la demande plutôt qu'automatiquement :**"
+
+msgid "Analysis results (statistics, FWHM, centroid, peak/contour/blob detection, etc.) are no longer recomputed automatically when you modify a region of interest, transform the data, or edit object properties"
+msgstr "Les résultats d'analyse (statistiques, FWHM, centroïde, détection de pics/contours/blobs, etc.) ne sont plus recalculés automatiquement lorsque vous modifiez une région d'intérêt, transformez les données ou modifiez les propriétés de l'objet"
+
+msgid "Existing analysis results are now left untouched after such edits, avoiding surprising side effects and results that could become misleading once the data no longer matches the stored analysis parameters"
+msgstr "Les résultats d'analyse existants sont désormais laissés intacts après de telles modifications, évitant des effets de bord inattendus et des résultats susceptibles de devenir trompeurs lorsque les données ne correspondent plus aux paramètres d'analyse enregistrés"
+
+msgid "Ordinary replay and ordinary mutations do not recompute analyses; however, in History edit mode, editing upstream parameters recomputes downstream analysis actions through the cascade"
+msgstr "La relecture ordinaire et les mutations ordinaires ne recalculent pas les analyses ; toutefois, en mode d'édition de l'historique, la modification de paramètres en amont recalcule les actions d'analyse en aval par propagation en cascade"
+
+msgid "The familiar **\"Recompute\"** action (Edit menu, `Ctrl+R`) now refreshes both processing *and* analysis results, giving you full control over when analyses are updated"
+msgstr "L'action familière **\"Recalculer\"** (menu Édition, `Ctrl+R`) actualise désormais à la fois les résultats de traitement *et* d'analyse, vous permettant de maîtriser pleinement le moment où les analyses sont mises à jour"
+
+msgid "For developers, the former panel-level `recompute_analysis` entry point has been renamed to `recompute_selected`; `recompute_analysis` now refers to a different processor-level helper dedicated to explicit 1-to-0 analysis refresh"
+msgstr "Pour les développeurs, l'ancien point d'entrée `recompute_analysis` au niveau du panneau a été renommé `recompute_selected` ; `recompute_analysis` désigne désormais une autre fonction auxiliaire au niveau du processeur, dédiée à l'actualisation explicite des analyses 1-vers-0"
diff --git a/doc/release_notes/release_1.03.md b/doc/release_notes/release_1.03.md
index a4350ccc5..4cf47018b 100644
--- a/doc/release_notes/release_1.03.md
+++ b/doc/release_notes/release_1.03.md
@@ -11,6 +11,12 @@
* Listed directories are appended to the existing plugin search paths at startup and are picked up automatically by the plugin discovery mechanism
* Non-existent directories are silently skipped (a warning is recorded in the log file), so a stale environment variable on another machine will not prevent DataLab from starting
+**History Panel sessions:**
+
+* Added serialized and replayable history sessions with workspace-state validation
+* Added `.dlhist` import/export support and separated reset sessions from regular history sessions
+* Improved replay compatibility reporting for clearer user feedback
+
**Replace special values processing (signal and image):**
DataLab now provides a dedicated **"Replace special values"** processing
@@ -35,4 +41,31 @@ and Image panels.
* Integer images are handled explicitly: because `NaN` and infinite values
cannot exist in integer data, the dialog explains that the operation is not
applicable and prevents accidental processing, while preserving the original
- image data type without unnecessary float conversion
\ No newline at end of file
+ image data type without unnecessary float conversion
+
+### 🔄 Changes ###
+
+**Detection tools now preserve existing regions of interest:**
+
+* Regions of interest created by peak, blob, Hough circle, and contour
+ detection are now added to existing ROIs instead of replacing them, without
+ displaying a destructive confirmation dialog
+
+**Analysis results are now refreshed on demand instead of automatically:**
+
+* Analysis results (statistics, FWHM, centroid, peak/contour/blob detection,
+ etc.) are no longer recomputed automatically when you modify a region of
+ interest, transform the data, or edit object properties
+* Existing analysis results are now left untouched after such edits, avoiding
+ surprising side effects and results that could become misleading once the
+ data no longer matches the stored analysis parameters
+* Ordinary replay and ordinary mutations do not recompute analyses; however,
+ in History edit mode, editing upstream parameters recomputes downstream
+ analysis actions through the cascade
+* The familiar **"Recompute"** action (Edit menu, `Ctrl+R`) now refreshes both
+ processing *and* analysis results, giving you full control over when analyses
+ are updated
+* For developers, the former panel-level `recompute_analysis` entry point has
+ been renamed to `recompute_selected`; `recompute_analysis` now refers to a
+ different processor-level helper dedicated to explicit 1-to-0 analysis
+ refresh
diff --git a/doc/update_screenshots.py b/doc/update_screenshots.py
index 2ae380d1e..8858bb1ce 100644
--- a/doc/update_screenshots.py
+++ b/doc/update_screenshots.py
@@ -6,6 +6,7 @@
from datalab import config
from datalab.tests.features.applauncher import launcher1_app_test
+from datalab.tests.features.common import history_panel_app_test
from datalab.tests.features.utilities import settings_unit_test
from datalab.tests.scenarios import beautiful_app
@@ -17,4 +18,5 @@
beautiful_app.run_beautiful_scenario(screenshots=True)
beautiful_app.run_blob_detection_on_flower_image(screenshots=True)
settings_unit_test.capture_settings_screenshots()
+ history_panel_app_test.test_history_panel(screenshots=True)
print("done.")