diff --git a/datalab/adapters_metadata/__init__.py b/datalab/adapters_metadata/__init__.py
index 37bfe4ea7..0bca1806d 100644
--- a/datalab/adapters_metadata/__init__.py
+++ b/datalab/adapters_metadata/__init__.py
@@ -9,9 +9,11 @@
from .base_adapter import BaseResultAdapter
from .common import (
ResultData,
+ create_adapter,
create_resultdata_dict,
have_geometry_results,
have_results,
+ register_result_adapter,
resultadapter_to_html,
show_resultdata,
)
@@ -23,9 +25,11 @@
"GeometryAdapter",
"ResultData",
"TableAdapter",
+ "create_adapter",
"create_resultdata_dict",
"have_geometry_results",
"have_results",
+ "register_result_adapter",
"resultadapter_to_html",
"show_resultdata",
]
diff --git a/datalab/adapters_metadata/common.py b/datalab/adapters_metadata/common.py
index d63052fc5..4e9ee8555 100644
--- a/datalab/adapters_metadata/common.py
+++ b/datalab/adapters_metadata/common.py
@@ -14,7 +14,7 @@
import pandas as pd
from guidata.qthelpers import exec_dialog
from guidata.widgets.dataframeeditor import DataFrameEditor
-from sigima.objects import ImageObj, SignalObj
+from sigima.objects import GeometryResult, ImageObj, SignalObj, TableResult
from datalab.adapters_metadata.base_adapter import BaseResultAdapter
from datalab.adapters_metadata.geometry_adapter import GeometryAdapter
@@ -25,6 +25,55 @@
if TYPE_CHECKING:
from qtpy.QtWidgets import QWidget
+# Registry mapping result typologies to their metadata adapter classes
+_ADAPTER_REGISTRY: dict[type, type[BaseResultAdapter]] = {
+ GeometryResult: GeometryAdapter,
+ TableResult: TableAdapter,
+}
+
+
+def register_result_adapter(
+ result_class: type, adapter_class: type[BaseResultAdapter]
+) -> None:
+ """Register an adapter class for a result typology (extension point for
+ new result typologies, e.g. from plugins).
+
+ Args:
+ result_class: Result typology to associate with the adapter.
+ adapter_class: Adapter class instantiated for results of that typology.
+ """
+ _ADAPTER_REGISTRY[result_class] = adapter_class
+
+
+def create_adapter(result: GeometryResult | TableResult) -> BaseResultAdapter:
+ """Create the metadata adapter matching the result's typology.
+
+ Resolution first looks up the exact type in the registry, then walks the
+ result type's MRO so subclasses of registered typologies are accepted:
+ the most specific registered base class wins, regardless of registration
+ order.
+
+ Args:
+ result: Analysis result to wrap.
+
+ Returns:
+ Adapter instance wrapping ``result``.
+
+ Raises:
+ TypeError: If no adapter is registered for the result's type.
+ """
+ adapter_class = _ADAPTER_REGISTRY.get(type(result))
+ if adapter_class is None:
+ for base in type(result).__mro__[1:]:
+ adapter_class = _ADAPTER_REGISTRY.get(base)
+ if adapter_class is not None:
+ break
+ if adapter_class is None:
+ raise TypeError(
+ f"No result adapter registered for type {type(result).__name__!r}"
+ )
+ return adapter_class(result)
+
def alpha_label(index: int) -> str:
"""Return an alphabetic label for a 0-based index.
@@ -55,6 +104,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..b984fa665 100644
--- a/datalab/config.py
+++ b/datalab/config.py
@@ -297,6 +297,24 @@ 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
+ # - False: user must enable it manually via the History panel toolbar (default)
+ history_auto_record = conf.Option()
+
+ # History session behavior for new inputs:
+ history_new_session_behavior = conf.EnumOption(["ask", "yes", "no"], default="ask")
+
+ # History session behavior for plugin-created inputs:
+ history_plugin_new_session_behavior = conf.EnumOption(
+ ["ask", "yes", "no"], default="no"
+ )
+
+ # History session behavior when plugins load multiple inputs:
+ history_plugin_multiload_behavior = conf.EnumOption(
+ ["ask", "yes", "no"], default="no"
+ )
+
# 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 +661,10 @@ 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.history_new_session_behavior.get("ask")
+ Conf.proc.history_plugin_new_session_behavior.get("no")
+ Conf.proc.history_plugin_multiload_behavior.get("no")
Conf.proc.xarray_compat_behavior.get("ask")
Conf.proc.small_mono_font.get((configtools.MONOSPACE, 8, False))
# View section
diff --git a/datalab/control/proxy.py b/datalab/control/proxy.py
index 8acd63d26..07382b628 100644
--- a/datalab/control/proxy.py
+++ b/datalab/control/proxy.py
@@ -75,15 +75,39 @@
from collections.abc import Generator
from contextlib import contextmanager
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Literal
import guidata.dataset as gds
import numpy as np
from sigima import ImageObj, SignalObj
+from datalab.config import Conf
from datalab.control.baseproxy import BaseProxy
from datalab.control.remote import RemoteClient
from datalab.utils import qthelpers as qth
+if TYPE_CHECKING:
+ from datalab.gui.historysession_ops import SessionBehavior
+ from datalab.gui.main import DLMainWindow
+
+
+@dataclass
+class MultiLoadState:
+ """Track a local proxy multi-object load."""
+
+ panel: Literal["signal", "image"]
+ behavior: SessionBehavior | None
+ decision_applied: bool = False
+
+ def behavior_for(self, panel: Literal["signal", "image"]) -> SessionBehavior | None:
+ """Return the session behavior for an object added to ``panel``."""
+ if panel != self.panel:
+ raise ValueError(
+ f"Cannot add a {panel} object during a {self.panel} multiload session"
+ )
+ return "no" if self.decision_applied else self.behavior
+
class RemoteProxy(RemoteClient):
"""DataLab remote proxy class.
@@ -137,8 +161,20 @@ class LocalProxy(BaseProxy):
Args:
datalab (DLMainWindow): DLMainWindow instance.
+ input_source: Source of objects added through this proxy.
"""
+ def __init__(
+ self,
+ datalab: DLMainWindow | None = None,
+ input_source: Literal["local", "plugin"] = "local",
+ ) -> None:
+ if input_source not in ("local", "plugin"):
+ raise ValueError(f"Invalid local proxy input source: {input_source!r}")
+ super().__init__(datalab)
+ self.input_source = input_source
+ self.multiload_state: MultiLoadState | None = None
+
def add_signal(
self,
title: str,
@@ -150,6 +186,7 @@ def add_signal(
ylabel: str = "",
group_id: str = "",
set_current: bool = True,
+ new_session_behavior: SessionBehavior | None = None,
) -> bool: # pylint: disable=too-many-arguments
"""Add signal data to DataLab.
@@ -163,6 +200,7 @@ def add_signal(
ylabel: Y label. Defaults to ""
group_id: group id in which to add the signal. Defaults to ""
set_current: if True, set the added signal as current
+ new_session_behavior: Optional history session creation policy
Returns:
True if signal was added successfully, False otherwise
@@ -171,9 +209,28 @@ def add_signal(
ValueError: Invalid xdata dtype
ValueError: Invalid ydata dtype
"""
- return self._datalab.add_signal(
- title, xdata, ydata, xunit, yunit, xlabel, ylabel, group_id, set_current
+ multiload_state = self.multiload_state
+ if multiload_state is None:
+ behavior = new_session_behavior
+ if behavior is None and self.input_source == "plugin":
+ behavior = Conf.proc.history_plugin_new_session_behavior.get()
+ else:
+ behavior = multiload_state.behavior_for("signal")
+ added = self._datalab.add_signal(
+ title,
+ xdata,
+ ydata,
+ xunit,
+ yunit,
+ xlabel,
+ ylabel,
+ group_id,
+ set_current,
+ new_session_behavior=behavior,
)
+ if added and multiload_state is not None:
+ multiload_state.decision_applied = True
+ return added
def add_image(
self,
@@ -187,6 +244,7 @@ def add_image(
zlabel: str = "",
group_id: str = "",
set_current: bool = True,
+ new_session_behavior: SessionBehavior | None = None,
) -> bool: # pylint: disable=too-many-arguments
"""Add image data to DataLab.
@@ -201,6 +259,7 @@ def add_image(
zlabel: Z label. Defaults to ""
group_id: group id in which to add the image. Defaults to ""
set_current: if True, set the added image as current
+ new_session_behavior: Optional history session creation policy
Returns:
True if image was added successfully, False otherwise
@@ -208,7 +267,14 @@ def add_image(
Raises:
ValueError: Invalid data dtype
"""
- return self._datalab.add_image(
+ multiload_state = self.multiload_state
+ if multiload_state is None:
+ behavior = new_session_behavior
+ if behavior is None and self.input_source == "plugin":
+ behavior = Conf.proc.history_plugin_new_session_behavior.get()
+ else:
+ behavior = multiload_state.behavior_for("image")
+ added = self._datalab.add_image(
title,
data,
xunit,
@@ -219,19 +285,81 @@ def add_image(
zlabel,
group_id,
set_current,
+ new_session_behavior=behavior,
)
+ if added and multiload_state is not None:
+ multiload_state.decision_applied = True
+ return added
def add_object(
- self, obj: SignalObj | ImageObj, group_id: str = "", set_current: bool = True
- ) -> None:
+ self,
+ obj: SignalObj | ImageObj,
+ group_id: str = "",
+ set_current: bool = True,
+ new_session_behavior: SessionBehavior | None = None,
+ ) -> bool:
"""Add object to DataLab.
Args:
obj: Signal or image object
group_id: group id in which to add the object. Defaults to ""
set_current: if True, set the added object as current
+ new_session_behavior: Optional history session creation policy
+
+ Returns:
+ True if the object was added successfully, False otherwise
"""
- self._datalab.add_object(obj, group_id, set_current)
+ multiload_state = self.multiload_state
+ if multiload_state is None:
+ behavior = new_session_behavior
+ if behavior is None and self.input_source == "plugin":
+ behavior = Conf.proc.history_plugin_new_session_behavior.get()
+ else:
+ if isinstance(obj, SignalObj):
+ panel = "signal"
+ elif isinstance(obj, ImageObj):
+ panel = "image"
+ else:
+ raise TypeError(f"Unsupported object type {type(obj)}")
+ behavior = multiload_state.behavior_for(panel)
+ added = self._datalab.add_object(
+ obj, group_id, set_current, new_session_behavior=behavior
+ )
+ if added and multiload_state is not None:
+ multiload_state.decision_applied = True
+ return added
+
+ @contextmanager
+ def multiload_session(
+ self,
+ panel: Literal["signal", "image"],
+ new_session_behavior: SessionBehavior | None = None,
+ ) -> Generator[None, None, None]:
+ """Apply one lazy session decision to a multi-object load.
+
+ Args:
+ panel: Target data panel ("signal" or "image")
+ new_session_behavior: Optional history session creation policy
+
+ Raises:
+ ValueError: If the panel or session behavior is invalid
+ RuntimeError: If another multiload session is already active
+ """
+ if panel not in ("signal", "image"):
+ raise ValueError(f"Invalid data panel: {panel!r}")
+ behavior = new_session_behavior
+ if behavior is None and self.input_source == "plugin":
+ behavior = Conf.proc.history_plugin_multiload_behavior.get()
+ if behavior is not None and behavior not in ("ask", "yes", "no"):
+ raise ValueError(f"Invalid session behavior: {behavior!r}")
+ if self.multiload_state is not None:
+ raise RuntimeError("Nested multiload sessions are not supported")
+ previous_state = self.multiload_state
+ self.multiload_state = MultiLoadState(panel, behavior)
+ try:
+ yield
+ finally:
+ self.multiload_state = previous_state
def calc(self, name: str, param: gds.DataSet | None = None) -> None:
"""Call computation feature ``name``
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/creation.py b/datalab/gui/creation.py
new file mode 100644
index 000000000..6e8124972
--- /dev/null
+++ b/datalab/gui/creation.py
@@ -0,0 +1,219 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Dependency-neutral object creation services."""
+
+from __future__ import annotations
+
+import json
+from typing import TYPE_CHECKING
+
+import guidata.dataset as gds
+import numpy as np
+from sigima.objects import (
+ PEAK_PARAMETERIZATION,
+ CustomSignalParam,
+ Gauss2DParam,
+ ImageDatatypes,
+ ImageObj,
+ NewImageParam,
+ NewSignalParam,
+ SignalObj,
+ convert_legacy_peak_creation_params,
+ create_signal,
+ validate_peak_creation_params,
+)
+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 sigima.objects.signal import (
+ BaseGaussLorentzVoigtParam,
+)
+
+from datalab.config import _
+
+if TYPE_CHECKING:
+ from qtpy import QtWidgets as QW
+
+CREATION_PARAMETERS_OPTION = "creation_parameters"
+LEGACY_CREATION_PARAMETERS_OPTION = "creation_param_json"
+CREATION_PARAMETERS_FORMAT_VERSION = 1
+
+
+def _decode_dataset_json(dataset_json: str) -> dict[str, object]:
+ """Decode a DataSet JSON payload without instantiating its class."""
+ try:
+ payload = json.loads(dataset_json)
+ except (TypeError, json.JSONDecodeError) as exc:
+ raise ValueError("Invalid creation parameter JSON") from exc
+ if not isinstance(payload, dict):
+ raise ValueError("Creation parameter JSON must contain an object")
+ return payload
+
+
+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.
+ """
+ dataset_json = gds.dataset_to_json(param)
+ raw_params = _decode_dataset_json(dataset_json)
+ envelope: dict[str, object] = {
+ "format_version": CREATION_PARAMETERS_FORMAT_VERSION,
+ "dataset_json": dataset_json,
+ }
+ if isinstance(param, BaseGaussLorentzVoigtParam):
+ validate_peak_creation_params(raw_params)
+ envelope["peak_parameterization"] = PEAK_PARAMETERIZATION
+ obj.set_metadata_option(CREATION_PARAMETERS_OPTION, envelope)
+ obj.metadata.pop(f"__{LEGACY_CREATION_PARAMETERS_OPTION}", None)
+
+
+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.
+ """
+ options = obj.get_metadata_options()
+ has_current = CREATION_PARAMETERS_OPTION in options
+ has_legacy = LEGACY_CREATION_PARAMETERS_OPTION in options
+ if has_current and has_legacy:
+ raise ValueError("Conflicting creation parameter formats")
+ if not has_current and not has_legacy:
+ return None
+
+ if has_current:
+ envelope = options[CREATION_PARAMETERS_OPTION]
+ if not isinstance(envelope, dict):
+ raise ValueError("Creation parameters must use a versioned envelope")
+ version = envelope.get("format_version")
+ if version != CREATION_PARAMETERS_FORMAT_VERSION:
+ raise ValueError(f"Unsupported creation parameter format: {version!r}")
+ dataset_json = envelope.get("dataset_json")
+ if not isinstance(dataset_json, str):
+ raise ValueError("Creation parameter envelope has no dataset_json")
+ raw_params = _decode_dataset_json(dataset_json)
+ is_peak = raw_params.get("class_name") in {
+ "GaussParam",
+ "LorentzParam",
+ "VoigtParam",
+ }
+ parameterization = envelope.get("peak_parameterization")
+ if is_peak:
+ if parameterization != PEAK_PARAMETERIZATION:
+ raise ValueError(
+ f"Unsupported peak parameterization: {parameterization!r}"
+ )
+ validate_peak_creation_params(raw_params)
+ elif parameterization is not None:
+ raise ValueError(
+ "Peak parameterization set on non-peak creation parameters"
+ )
+ return gds.json_to_dataset(dataset_json)
+
+ dataset_json = options[LEGACY_CREATION_PARAMETERS_OPTION]
+ if not isinstance(dataset_json, str):
+ raise ValueError("Legacy creation parameters must contain DataSet JSON")
+ raw_params = _decode_dataset_json(dataset_json)
+ if raw_params.get("class_name") in {"GaussParam", "LorentzParam", "VoigtParam"}:
+ validate_peak_creation_params(raw_params)
+ return gds.json_to_dataset(dataset_json)
+
+
+def convert_legacy_creation_parameters(
+ obj: SignalObj | ImageObj,
+) -> gds.DataSet:
+ """Explicitly convert legacy peak creation metadata to version 2.
+
+ The object data is not regenerated; only its reusable creation parameters
+ are converted and stored under the new metadata option.
+
+ Args:
+ obj: Object carrying historical creation metadata.
+
+ Returns:
+ Converted peak creation parameters.
+ """
+ options = obj.get_metadata_options()
+ if CREATION_PARAMETERS_OPTION in options:
+ raise ValueError("Current creation parameters already exist")
+ dataset_json = options.get(LEGACY_CREATION_PARAMETERS_OPTION)
+ if not isinstance(dataset_json, str):
+ raise ValueError("Object has no legacy creation parameters")
+ raw_params = _decode_dataset_json(dataset_json)
+ converted = convert_legacy_peak_creation_params(raw_params)
+ param = gds.json_to_dataset(json.dumps(converted))
+ insert_creation_parameters(obj, param)
+ return param
+
+
+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..550abaeae
--- /dev/null
+++ b/datalab/gui/historysession_ops.py
@@ -0,0 +1,433 @@
+# 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, Literal
+
+from qtpy import QtWidgets as QW
+
+from datalab.config import Conf, _
+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
+
+
+SessionBehavior = Literal["ask", "yes", "no"]
+SESSION_BEHAVIORS: tuple[SessionBehavior, ...] = ("ask", "yes", "no")
+
+
+def create_new_session(panel: HistoryPanel) -> HistorySession:
+ """Create a new history session and make it the active recording session.
+
+ Returns:
+ The newly created session.
+ """
+ panel.navigation.session_increment += 1
+ session = HistorySession(number=panel.navigation.session_increment)
+ panel.history_sessions.append(session)
+ panel.navigation.set_active_session(session)
+ 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,
+ behavior: SessionBehavior | None = None,
+) -> bool:
+ """Offer to start a new history session before a creation/load is recorded.
+
+ When the active recording 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.
+ behavior: Session creation policy: ask, always create ("yes"), or keep
+ the current session ("no"). Defaults to the live general policy.
+
+ Returns:
+ True if a new session was created.
+
+ Raises:
+ ValueError: If ``behavior`` is unsupported.
+ """
+ if behavior is None:
+ behavior = Conf.proc.history_new_session_behavior.get()
+ if behavior not in SESSION_BEHAVIORS:
+ raise ValueError(f"Invalid session behavior: {behavior!r}")
+ if not panel.record_mode_enabled or panel.is_replaying():
+ return False
+ if panel.runtime.execution.suppress_session_prompt:
+ return False
+ active_session = panel.navigation.get_active_session()
+ if active_session is None or not active_session.actions:
+ return False
+ if behavior == "no":
+ return False
+ if behavior == "yes":
+ panel.create_new_session()
+ return True
+ # 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 False
+ 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 True
+ return False
+ 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()
+ return True
+ return False
+
+
+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 replayable via
+ the generic compute replay, like the other compute patterns).
+ 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
+ action-to-outputs mapping and inverse output lookup are 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 ``action → outputs`` mapping and inverse ``output → action``
+ lookup. One action may produce multiple outputs. 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 UI actions without new objects;
+ output-producing UI actions may provide one or more UUIDs).
+ """
+ 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:
+ no_output_compute = action.kind == HistoryAction.KIND_COMPUTE and (
+ action.pattern
+ in {"1_to_1", "multiple_1_to_1", "1_to_n", "n_to_1", "2_to_1"}
+ )
+ no_output_load = (
+ action.kind == HistoryAction.KIND_UI
+ and action.method_name in HistoryAction.UI_LOAD_METHODS
+ )
+ if no_output_compute or no_output_load:
+ # The action produced no output object: either the compute
+ # failed (or was a full no-op), or the load found nothing
+ # readable for the panel. Do not keep a misleading entry in
+ # the history.
+ discard_empty_output_action(panel, action)
+
+
+def discard_empty_output_action(panel: HistoryPanel, action: HistoryAction) -> None:
+ """Remove a just-recorded action that produced no output object.
+
+ Removes the action from the session chain and refreshes the tree so the
+ panel stays consistent.
+
+ Args:
+ action: The history action to discard.
+ """
+ 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"``, ``"signalprocessor"``, or ``"imageprocessor"``.
+ 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
+ # Derive the action's panel from the UI target so prompting and captured
+ # state concern the panel the action actually operates on.
+ target_panel_str = {
+ "signalpanel": "signal",
+ "imagepanel": "image",
+ "signalprocessor": "signal",
+ "imageprocessor": "image",
+ }.get(target)
+ # 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)
+ 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,
+ )
+ panel.add_object(action)
+ return action
+
+
+def add_mutation_entry(
+ panel: HistoryPanel,
+ action_title: str,
+ panel_str: str,
+ mutation_key: str,
+ target_uuids: list[str],
+ payload: Any = None,
+ save_state: bool = True,
+) -> HistoryAction | None:
+ """Record a *mutation* action in the current history session.
+
+ Mutation actions describe in-place modifications of existing data objects
+ (no new objects created), e.g. ROI assignment or removal. At replay time,
+ the payload is re-applied to each target object still present in the data
+ panel's object model.
+
+ Args:
+ action_title: Title shown in the history tree.
+ panel_str: Data panel the mutation operates on ("signal" or "image").
+ mutation_key: Mutation identifier (currently only "roi" is supported).
+ target_uuids: UUIDs of the data objects modified in place.
+ payload: Mutation payload (e.g. a sigima ROI object). ``None`` means
+ the attribute is removed at replay time (e.g. ROI deletion).
+ save_state: If True, capture the workspace state for replay.
+
+ 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 the payload to ensure independent ownership (same rationale
+ # as in add_compute_entry). A None payload is encoded as a missing kwarg.
+ action = HistoryAction(
+ title=action_title,
+ kind=HistoryAction.KIND_MUTATION,
+ panel_str=panel_str,
+ mutation_key=mutation_key,
+ target_uuids=list(target_uuids),
+ kwargs={"payload": deepcopy(payload)} if payload is not None else {},
+ state=state,
+ )
+ panel.add_object(action)
+ return action
+
+
+def add_object(panel: HistoryPanel, obj: HistoryAction) -> None:
+ """Add an action to the single active recording session.
+
+ Actions from both the signal and image panels are chained into the same
+ active recording session, creating one on first use, so mixed-panel
+ pipelines stay together and recording resumes in the user-selected session.
+ """
+ session = panel.navigation.get_active_session()
+ if session is None:
+ session = panel.create_new_session()
+ 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..6acc39d4b
--- /dev/null
+++ b/datalab/gui/historytools_ops.py
@@ -0,0 +1,658 @@
+# 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 sigima.objects import ImageObj, SignalObj
+
+ 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."""
+ return action.effective_panel_str() or "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`. Because
+ it is prepended, :func:`build_session_chains` treats it as the first
+ chronological action and therefore as the 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 only chain input/output UUIDs per panel.
+
+ Collected UUIDs are the chain inputs (recorded selections), the second
+ operands of 2-to-1 operations (``obj2_uuids``) and the produced outputs.
+ Captured ``object_metadata`` UUIDs are intentionally ignored: workspace
+ states snapshot every object alive at record time (in both panels), so
+ collecting them would clone unrelated objects.
+ """
+ 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)
+ 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: SignalObj | ImageObj) -> 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: SignalObj | ImageObj, 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.effective_panel_str()
+ # 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.effective_panel_str())
+ 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)
+ # Deep-copying the orphan and stripping its source links makes the copy
+ # autonomous: it no longer references the deleted upstream chain.
+ 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.effective_panel_str(), 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.effective_panel_str(), 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):
+ 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..b43b0775f 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 (
@@ -94,6 +94,7 @@
if TYPE_CHECKING:
from typing import Literal
+ from datalab.gui.historysession_ops import SessionBehavior
from datalab.gui.panel.base import AbstractPanel, BaseDataPanel
from datalab.gui.panel.image import ImagePanel
from datalab.gui.panel.macro import MacroPanel
@@ -178,6 +179,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 +199,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 +738,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 +901,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 +1027,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 +1741,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
@@ -2066,8 +2082,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 +2128,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 +2207,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:
@@ -2219,11 +2257,11 @@ def browse_h5_files(self, filenames: list[str], reset_all: bool) -> None:
@remote_controlled
def load_h5_workspace(self, h5files: list[str], reset_all: bool = False) -> None:
- """Load native DataLab HDF5 workspace files without any GUI elements.
+ """Load native DataLab HDF5 workspace files programmatically.
- This method can be safely called from the internal console as it does not
- create any Qt widgets, dialogs, or progress bars. It is designed for
- programmatic use when loading DataLab workspace files.
+ This method does not create file-selection widgets or progress bars. When
+ history recording is active and the new-session policy is ``"ask"``, a
+ history-session question may still be shown before loading.
.. warning::
@@ -2238,17 +2276,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,30 +2320,60 @@ 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)
# @remote_controlled
def add_object(
- self, obj: SignalObj | ImageObj, group_id: str = "", set_current=True
- ) -> None:
+ self,
+ obj: SignalObj | ImageObj,
+ group_id: str = "",
+ set_current=True,
+ new_session_behavior: SessionBehavior | None = None,
+ ) -> bool:
"""Add object - signal or image
Args:
obj: object to add (signal or image)
group_id: group ID (optional)
set_current: True to set the object as current object
+ new_session_behavior: Optional history session creation policy
+
+ Returns:
+ True if the object was added successfully, False otherwise
"""
- if self.confirm_memory_state():
- if isinstance(obj, SignalObj):
- self.signalpanel.add_object(obj, group_id, set_current)
- elif isinstance(obj, ImageObj):
- self.imagepanel.add_object(obj, group_id, set_current)
- else:
- raise TypeError(f"Unsupported object type {type(obj)}")
+ if not self.confirm_memory_state():
+ return False
+ if isinstance(obj, SignalObj):
+ panel = self.signalpanel
+ panel_str = "signal"
+ elif isinstance(obj, ImageObj):
+ panel = self.imagepanel
+ panel_str = "image"
+ else:
+ raise TypeError(f"Unsupported object type {type(obj)}")
+ self.historypanel.maybe_start_session_for_input(behavior=new_session_behavior)
+ panel.add_object(obj, group_id, set_current)
+ # 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).
+ with self.historypanel.session_prompt_suppressed():
+ 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)])
+ return True
@remote_controlled
def set_object(self, obj: SignalObj | ImageObj) -> None:
@@ -2373,6 +2444,7 @@ def add_signal(
ylabel: str = "",
group_id: str = "",
set_current: bool = True,
+ new_session_behavior: SessionBehavior | None = None,
) -> bool: # pylint: disable=too-many-arguments
"""Add signal data to DataLab.
@@ -2386,6 +2458,7 @@ def add_signal(
ylabel: Y label. Defaults to ""
group_id: group id in which to add the signal. Defaults to ""
set_current: if True, set the added signal as current
+ new_session_behavior: Optional history session creation policy
Returns:
True if signal was added successfully, False otherwise
@@ -2401,8 +2474,7 @@ def add_signal(
units=(xunit, yunit),
labels=(xlabel, ylabel),
)
- self.add_object(obj, group_id, set_current)
- return True
+ return self.add_object(obj, group_id, set_current, new_session_behavior)
# This API mirrors the image metadata accepted by create_image, so the
# argument count is part of the stable public interface rather than noise.
@@ -2418,6 +2490,7 @@ def add_image( # pylint: disable=too-many-arguments
zlabel: str = "",
group_id: str = "",
set_current: bool = True,
+ new_session_behavior: SessionBehavior | None = None,
) -> bool:
"""Add image data to DataLab.
@@ -2432,6 +2505,7 @@ def add_image( # pylint: disable=too-many-arguments
zlabel: Z label. Defaults to ""
group_id: group id in which to add the image. Defaults to ""
set_current: if True, set the added image as current
+ new_session_behavior: Optional history session creation policy
Returns:
True if image was added successfully, False otherwise
@@ -2445,8 +2519,7 @@ def add_image( # pylint: disable=too-many-arguments
units=(xunit, yunit, zunit),
labels=(xlabel, ylabel, zlabel),
)
- self.add_object(obj, group_id, set_current)
- return True
+ return self.add_object(obj, group_id, set_current, new_session_behavior)
# ------?
def __about(self) -> None: # pragma: no cover
@@ -2699,11 +2772,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 3a3db3d14..c5314b9ca 100644
--- a/datalab/gui/newobject.py
+++ b/datalab/gui/newobject.py
@@ -10,12 +10,8 @@
"""
-# pylint: disable=invalid-name # Allows short reference names like x, y, ...
-
from __future__ import annotations
-import json
-
import guidata.dataset as gds
import numpy as np
from guidata.qthelpers import exec_dialog
@@ -23,151 +19,47 @@
from plotpy.plot import PlotDialog
from plotpy.tools import EditPointTool
from qtpy import QtWidgets as QW
+from sigima.objects import CustomSignalParam as OrigCustomSignalParam
from sigima.objects import (
- PEAK_PARAMETERIZATION,
- Gauss2DParam,
- ImageDatatypes,
ImageObj,
NewImageParam,
NewSignalParam,
SignalObj,
- convert_legacy_peak_creation_params,
- create_signal,
- validate_peak_creation_params,
-)
-from sigima.objects import CustomSignalParam as OrigCustomSignalParam
-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 sigima.objects.signal import (
- BaseGaussLorentzVoigtParam,
)
from datalab.config import _
+from datalab.gui.creation import (
+ CREATION_PARAMETERS_FORMAT_VERSION,
+ CREATION_PARAMETERS_OPTION,
+ LEGACY_CREATION_PARAMETERS_OPTION,
+ convert_legacy_creation_parameters,
+ create_image_from_param,
+ create_signal_from_param,
+ extract_creation_parameters,
+ initialize_image_parameters,
+ insert_creation_parameters,
+ prepare_signal_parameters,
+)
-CREATION_PARAMETERS_OPTION = "creation_parameters"
-LEGACY_CREATION_PARAMETERS_OPTION = "creation_param_json"
-CREATION_PARAMETERS_FORMAT_VERSION = 1
-
-
-def _decode_dataset_json(dataset_json: str) -> dict[str, object]:
- """Decode a DataSet JSON payload without instantiating its class."""
- try:
- payload = json.loads(dataset_json)
- except (TypeError, json.JSONDecodeError) as exc:
- raise ValueError("Invalid creation parameter JSON") from exc
- if not isinstance(payload, dict):
- raise ValueError("Creation parameter JSON must contain an object")
- return payload
-
-
-def insert_creation_parameters(obj: SignalObj | ImageObj, param: gds.DataSet) -> None:
- """Insert creation parameters into object metadata.
-
- Args:
- param: creation parameters
- """
- dataset_json = gds.dataset_to_json(param)
- raw_params = _decode_dataset_json(dataset_json)
- envelope: dict[str, object] = {
- "format_version": CREATION_PARAMETERS_FORMAT_VERSION,
- "dataset_json": dataset_json,
- }
- if isinstance(param, BaseGaussLorentzVoigtParam):
- validate_peak_creation_params(raw_params)
- envelope["peak_parameterization"] = PEAK_PARAMETERIZATION
- obj.set_metadata_option(CREATION_PARAMETERS_OPTION, envelope)
- obj.metadata.pop(f"__{LEGACY_CREATION_PARAMETERS_OPTION}", None)
-
-
-def extract_creation_parameters(obj: SignalObj | ImageObj) -> gds.DataSet | None:
- """Extract creation parameters from object metadata.
-
- Returns:
- Creation parameters or None if not found
- """
- options = obj.get_metadata_options()
- has_current = CREATION_PARAMETERS_OPTION in options
- has_legacy = LEGACY_CREATION_PARAMETERS_OPTION in options
- if has_current and has_legacy:
- raise ValueError("Conflicting creation parameter formats")
- if not has_current and not has_legacy:
- return None
-
- if has_current:
- envelope = options[CREATION_PARAMETERS_OPTION]
- if not isinstance(envelope, dict):
- raise ValueError("Creation parameters must use a versioned envelope")
- version = envelope.get("format_version")
- if version != CREATION_PARAMETERS_FORMAT_VERSION:
- raise ValueError(f"Unsupported creation parameter format: {version!r}")
- dataset_json = envelope.get("dataset_json")
- if not isinstance(dataset_json, str):
- raise ValueError("Creation parameter envelope has no dataset_json")
- raw_params = _decode_dataset_json(dataset_json)
- is_peak = raw_params.get("class_name") in {
- "GaussParam",
- "LorentzParam",
- "VoigtParam",
- }
- parameterization = envelope.get("peak_parameterization")
- if is_peak:
- if parameterization != PEAK_PARAMETERIZATION:
- raise ValueError(
- f"Unsupported peak parameterization: {parameterization!r}"
- )
- validate_peak_creation_params(raw_params)
- elif parameterization is not None:
- raise ValueError(
- "Peak parameterization set on non-peak creation parameters"
- )
- return gds.json_to_dataset(dataset_json)
-
- dataset_json = options[LEGACY_CREATION_PARAMETERS_OPTION]
- if not isinstance(dataset_json, str):
- raise ValueError("Legacy creation parameters must contain DataSet JSON")
- raw_params = _decode_dataset_json(dataset_json)
- if raw_params.get("class_name") in {"GaussParam", "LorentzParam", "VoigtParam"}:
- validate_peak_creation_params(raw_params)
- return gds.json_to_dataset(dataset_json)
-
-
-def convert_legacy_creation_parameters(
- obj: SignalObj | ImageObj,
-) -> gds.DataSet:
- """Explicitly convert legacy peak creation metadata to version 2.
-
- The object data is not regenerated; only its reusable creation parameters
- are converted and stored under the new metadata option.
-
- Args:
- obj: Object carrying historical creation metadata.
-
- Returns:
- Converted peak creation parameters.
- """
- options = obj.get_metadata_options()
- if CREATION_PARAMETERS_OPTION in options:
- raise ValueError("Current creation parameters already exist")
- dataset_json = options.get(LEGACY_CREATION_PARAMETERS_OPTION)
- if not isinstance(dataset_json, str):
- raise ValueError("Object has no legacy creation parameters")
- raw_params = _decode_dataset_json(dataset_json)
- converted = convert_legacy_peak_creation_params(raw_params)
- param = gds.json_to_dataset(json.dumps(converted))
- insert_creation_parameters(obj, param)
- return param
+__all__ = [
+ "CREATION_PARAMETERS_FORMAT_VERSION",
+ "CREATION_PARAMETERS_OPTION",
+ "LEGACY_CREATION_PARAMETERS_OPTION",
+ "convert_legacy_creation_parameters",
+ "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",
@@ -177,8 +69,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)
@@ -188,14 +80,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"
)
@@ -217,49 +121,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
@@ -285,39 +159,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 4b8dc3c46..6d6fb01b4 100644
--- a/datalab/gui/panel/base.py
+++ b/datalab/gui/panel/base.py
@@ -9,6 +9,7 @@
from __future__ import annotations
import abc
+import copy
import glob
import os
import os.path as osp
@@ -81,10 +82,12 @@
)
from datalab.gui.processor.base import (
PROCESSING_PARAMETERS_OPTION,
+ ProcessingParameters,
ProcessingReport,
clear_analysis_parameters,
extract_analysis_parameters,
extract_processing_parameters,
+ insert_processing_parameters,
)
from datalab.gui.roieditor import TypeROIEditor
from datalab.objectmodel import (
@@ -92,6 +95,7 @@
get_number,
get_short_id,
get_uuid,
+ patch_title_with_ids,
set_number,
set_uuid,
)
@@ -195,6 +199,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)
@@ -429,6 +442,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
@@ -437,13 +454,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
@@ -459,6 +481,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:
@@ -705,16 +729,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)
@@ -747,6 +761,20 @@ def apply_creation_parameters(self) -> None:
# Update metadata with new creation parameters
insert_creation_parameters(self.current_creation_obj, param)
+ # 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)
@@ -759,6 +787,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
@@ -834,6 +868,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
@@ -860,7 +911,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,
@@ -874,14 +939,233 @@ 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)
+
+ # 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:
+ """Get the processor associated to the given object type.
+
+ Args:
+ obj: Signal or Image object
+
+ Returns:
+ Processor associated to the object's type
+ """
+ assert isinstance(obj, (SignalObj, ImageObj))
+ if isinstance(obj, SignalObj):
+ 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.
@@ -896,30 +1180,170 @@ def apply_processing_parameters(
success=False, message=_("No processing object available.")
)
- param = editor.dataset if editor is not None else None
- report = self.panel.processor.recompute_processing(
- obj=obj,
- param=param,
- interactive=interactive,
- )
+ proc_params = extract_processing_parameters(obj)
+ 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 report.success:
- # 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
- ),
+ # 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(
+ self,
+ _("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
+
+ # 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
+
+ hpanel = getattr(self.panel.mainwindow, "historypanel", None)
+ is_edit_mode = hpanel is not None and hpanel.is_edit_mode()
+
+ if is_edit_mode:
+ report = self.panel.processor.recompute_processing(
+ obj=obj,
+ param=param,
+ interactive=interactive,
+ )
+ 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
+
+ # --- 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(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"""
@@ -936,6 +1360,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):
@@ -969,7 +1394,8 @@ def deserialize_object_from_hdf5(
reader: HDF5 reader
name: Object name in HDF5 file
reset_all: If True, preserve original UUIDs (workspace reload).
- If False, regenerate UUIDs (importing objects).
+ If False, regenerate only UUIDs that conflict with existing
+ objects (object import).
"""
with reader.group(name):
obj = self.create_object()
@@ -997,7 +1423,8 @@ def deserialize_from_hdf5(
Args:
reader: HDF5 reader
reset_all: If True, preserve original UUIDs (workspace reload).
- If False, regenerate UUIDs (importing objects).
+ If False, regenerate only UUIDs that conflict with existing
+ objects (object import).
"""
@abc.abstractmethod
@@ -1121,7 +1548,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."""
@@ -1262,7 +1689,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."""
@@ -1473,7 +1900,8 @@ def deserialize_from_hdf5(
Args:
reader: HDF5 reader
reset_all: If True, preserve original UUIDs (workspace reload).
- If False, regenerate UUIDs (importing objects).
+ If False, regenerate only UUIDs that conflict with existing
+ objects (object import).
"""
with reader.group(self.H5_PREFIX):
for name in reader.h5.get(self.H5_PREFIX, []):
@@ -1596,6 +2024,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"""
@@ -1722,20 +2151,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()
@@ -1792,6 +2234,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]
@@ -1844,6 +2293,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)
@@ -1856,19 +2313,54 @@ 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 kept for legacy session replay compatibility.
+ When ``None`` (interactive use), the ROI is read from the
+ currently selected object.
+ """
+ # Copying to the clipboard mutates nothing: no history entry is
+ # recorded (the paste operation records the resulting ROI mutation).
+ if roi_data is None:
+ obj = self.objview.get_sel_objects()[0]
+ if obj.roi is None:
+ return
+ roi_data = obj.roi.copy()
+ self.__roi_clipboard = roi_data.copy()
+
+ def paste_roi(self, roi_data=None) -> None:
+ """Paste regions of interest
- def paste_roi(self) -> None:
- """Paste regions of interest"""
+ Args:
+ roi_data: ROI snapshot kept for legacy session replay compatibility.
+ When ``None`` (interactive use), the clipboard populated by
+ :meth:`copy_roi` is used.
+ """
+ if roi_data is None:
+ roi_data = self.__roi_clipboard
+ if roi_data is None:
+ return
sel_objects = self.objview.get_sel_objects(include_groups=True)
+ title = _("Paste regions of interest into selected %s") % (
+ _("signal") if self.PANEL_STR_ID == "signal" else _("image")
+ )
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)
+ # Pasting combines with any existing ROI, whereas mutation replay
+ # replaces the target's ROI: record one entry per object with the
+ # post-combination ROI so replay is deterministic.
+ self.mainwindow.historypanel.add_mutation_entry(
+ title,
+ panel_str=self.PANEL_STR_ID,
+ mutation_key="roi",
+ target_uuids=[get_uuid(obj)],
+ payload=obj.roi,
+ )
self.selection_changed(update_items=True)
self.refresh_plot(
"selected", update_items=True, only_visible=False, only_existing=True
@@ -1890,6 +2382,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 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] = []
@@ -1961,7 +2464,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:
@@ -2015,6 +2520,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:
@@ -2023,6 +2536,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(
@@ -2099,11 +2619,18 @@ 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
) -> list[SignalObj] | list[ImageObj]:
- """Open objects from file (signal/image), add them to DataLab and return them.
+ """Open and return objects from file, optionally adding them to DataLab.
Args:
filename: file name
@@ -2163,42 +2690,55 @@ 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)
+ action = self.mainwindow.historypanel.add_ui_entry(
+ _('Load from directory "%s"') % osp.basename(osp.normpath(directory)),
+ target=self.PANEL_STR_ID + "panel",
+ method_name="load_from_directory",
+ save_state=False,
+ directory=directory,
+ )
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 self.mainwindow.historypanel.capture_outputs(action):
+ 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(
@@ -2208,7 +2748,7 @@ def load_from_files(
add_objects: bool = True,
ignore_errors: bool = False,
) -> list[TypeObj]:
- """Open objects from file (signals/images), add them to DataLab and return them.
+ """Open and return objects from files, optionally adding them to DataLab.
Args:
filenames: File names
@@ -2228,22 +2768,67 @@ def load_from_files(
filters = self.IO_REGISTRY.get_read_filters()
with save_restore_stds():
filenames, _filt = getopenfilenames(self, _("Open"), basedir, filters)
+ if not filenames: # pragma: no cover
+ return []
# 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])
+ # Only record a history entry when this call actually adds the objects to
+ # the workspace; otherwise the caller is responsible for adding *and*
+ # recording (e.g. ``load_from_directory``).
+ action = None
+ if add_objects:
+ # 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
+ loaded_filenames: list[str] = []
+ 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:
+ new_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
+ else:
+ objs += new_objs
+ if new_objs:
+ loaded_filenames.append(filename)
+ if action is not None and loaded_filenames and len(loaded_filenames) < nbf:
+ # Some files could not be loaded: make the recorded entry reflect
+ # the files actually loaded, so the title is accurate and replay
+ # does not re-attempt the failed files. (If *no* file was loaded,
+ # ``capture_outputs`` already discarded the entry.)
+ if len(loaded_filenames) > 1:
+ action.title = _("Load from %d files") % len(loaded_filenames)
+ else:
+ action.title = _('Load "%s"') % osp.basename(loaded_filenames[0])
+ action.kwargs["filenames"] = loaded_filenames
+ self.mainwindow.historypanel.tree.refresh_action_item(action)
return objs
def save_to_files(self, filenames: list[str] | str | None = None) -> None:
@@ -2253,14 +2838,18 @@ def save_to_files(self, filenames: list[str] | str | None = None) -> None:
filenames: File names
"""
objs = self.objview.get_sel_objects(include_groups=True)
+ if isinstance(filenames, str):
+ filenames = [filenames]
if filenames is None: # pragma: no cover
filenames = [None] * len(objs)
assert len(filenames) == len(objs), (
"Number of filenames must match number of objects"
)
- for index, obj in enumerate(objs):
- filename = filenames[index]
- if filename is None:
+ # Ask for missing file names first, so that the history entry reflects the
+ # actual files written (and is skipped altogether if the user cancels)
+ pairs: list[tuple[TypeObj, str]] = []
+ for obj, filename in zip(objs, filenames):
+ if filename is None: # pragma: no cover
basedir = Conf.main.base_dir.get()
filters = self.IO_REGISTRY.get_write_filters()
with save_restore_stds():
@@ -2268,9 +2857,25 @@ def save_to_files(self, filenames: list[str] | str | None = None) -> None:
self, _("Save as"), basedir, filters
)
if filename:
- with qt_try_loadsave_file(self.parentWidget(), filename, "save"):
- Conf.main.base_dir.set(filename)
- self.__save_to_file(obj, filename)
+ pairs.append((obj, filename))
+ if not pairs: # pragma: no cover
+ return
+ nbf = len(pairs)
+ if nbf > 1:
+ entry_title = _("Save to %d different files") % nbf
+ else:
+ entry_title = _('Save to "%s"') % osp.basename(pairs[0][1])
+ self.mainwindow.historypanel.add_ui_entry(
+ entry_title,
+ target=self.PANEL_STR_ID + "panel",
+ method_name="save_to_files",
+ save_state=False,
+ filenames=[filename for _obj, filename in pairs],
+ )
+ for obj, filename in pairs:
+ with qt_try_loadsave_file(self.parentWidget(), filename, "save"):
+ Conf.main.base_dir.set(filename)
+ self.__save_to_file(obj, filename)
def save_to_directory(self, param: SaveToDirectoryParam | None = None) -> None:
"""Save signals or images to directory using a filename pattern.
@@ -2311,6 +2916,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)
@@ -2544,12 +3157,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))
+ # 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,6 +3176,7 @@ 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_selected(self) -> None:
"""Recompute/rerun selected objects or group with stored parameters.
@@ -2604,27 +3220,22 @@ def recompute_selected(self) -> None:
)
return
- # Recompute 1-to-1 processing operations first (they modify data in-place),
- # so that any subsequent analysis recomputation uses the updated data.
- recomputed_uuids, was_interrupted = self.recompute_1_to_1_objects(
- recomputable_objects
- )
-
- # If user canceled/stopped the 1-to-1 pass, do not continue with analysis
- # recomputation.
- if was_interrupted:
- return
+ # 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
- # Recompute 1-to-0 analysis operations (refresh results on current data).
- # For objects that also had a 1-to-1 step, only recompute analysis if that
- # step actually succeeded.
- 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)
+ 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]
@@ -2680,14 +3291,17 @@ def recompute_1_to_1_objects(
return recomputed_uuids, True
return recomputed_uuids, False
- def recompute_1_to_0_objects(self, objects: list[SignalObj | ImageObj]) -> None:
+ 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
+ return set(), False
+ recomputed_uuids: set[str] = set()
with create_progress_bar(
self, _("Recomputing analyses"), max_=len(objects)
) as progress:
@@ -2695,8 +3309,36 @@ def recompute_1_to_0_objects(self, objects: list[SignalObj | ImageObj]) -> None:
progress.setValue(index + 1)
QW.QApplication.processEvents()
if progress.wasCanceled():
- break
- self.processor.recompute_analysis(obj)
+ 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.
@@ -2807,8 +3449,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)
@@ -2834,18 +3477,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,
@@ -3379,6 +4010,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:
@@ -3426,6 +4063,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..d13e09b05
--- /dev/null
+++ b/datalab/gui/panel/history/chain.py
@@ -0,0 +1,439 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Action↔output chain helpers for the History panel."""
+
+from __future__ import annotations
+
+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
+
+
+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``."""
+ return {"signal": "signalpanel", "image": "imagepanel"}.get(
+ action.effective_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:
+ # Outputs of cross-panel features (e.g. image line profile producing
+ # a signal) live in the other panel: check both object models.
+ for out_uuid in registered:
+ if panel.mainwindow.signalpanel.objmodel.has_uuid(
+ out_uuid
+ ) or panel.mainwindow.imagepanel.objmodel.has_uuid(out_uuid):
+ 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.effective_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. Matching is two-pass:
+
+ 1. Effects manifest: an action whose ``effects`` manifest contains
+ ``obj_uuid`` is a durable, exact record that it wrote to that object.
+ The most recent such action wins.
+ 2. Legacy heuristic: for actions recorded before the manifest existed,
+ fall back to matching ``obj_uuid`` against the action's input UUIDs.
+
+ 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.
+ """
+ candidates = [
+ action
+ for session in reversed(panel.history_sessions)
+ for action in reversed(session.actions)
+ if action.kind == HistoryAction.KIND_COMPUTE and action.func_name == func_name
+ ]
+ for action in candidates:
+ if action.effects is not None and obj_uuid in action.effects:
+ return action
+ for action in candidates:
+ if obj_uuid in action_input_uuids(action):
+ return action
+ 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 recorded_action_output_uuids(
+ panel: HistoryPanel, action: HistoryAction
+) -> list[str]:
+ """Return output UUIDs recorded for ``action``, preferring durable history."""
+ if action.output_uuids:
+ return list(action.output_uuids)
+ runtime_outputs = panel.runtime.objects.action_output_uuids.get(action.uuid, [])
+ if runtime_outputs:
+ return list(runtime_outputs)
+ legacy_output = action_output_uuid(panel, action)
+ return [legacy_output] if legacy_output is not None else []
+
+
+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 action_mutates_any(action: HistoryAction, uuids: set[str]) -> bool:
+ """Return True if ``action``'s mutation targets intersect ``uuids``."""
+ if action.kind != HistoryAction.KIND_MUTATION:
+ return False
+ return bool(set(action.target_uuids or []) & 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 = find_parent_session(panel, action)
+ if current is None:
+ return []
+ if action.kind == HistoryAction.KIND_MUTATION:
+ # Mutations produce no outputs: seed the closure with the mutated
+ # objects so downstream computes consuming them are included.
+ root_outputs = list(action.target_uuids or [])
+ else:
+ root_outputs = recorded_action_output_uuids(panel, action)
+ if not root_outputs:
+ return []
+ closure: set[str] = set(root_outputs)
+ downstream: list[HistoryAction] = []
+ idx = current.actions.index(action)
+ for candidate in current.actions[idx + 1 :]:
+ if candidate.kind == HistoryAction.KIND_MUTATION:
+ # Mutations produce no outputs: they are downstream when they
+ # modify an object already in the closure.
+ if action_mutates_any(candidate, closure):
+ downstream.append(candidate)
+ continue
+ if candidate.kind != HistoryAction.KIND_COMPUTE:
+ continue
+ if not action_consumes_any(candidate, closure):
+ continue
+ downstream.append(candidate)
+ closure.update(recorded_action_output_uuids(panel, candidate))
+ return downstream
+
+
+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 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]
+ if action.target_uuids and action.effective_panel_str() == pstr:
+ action.target_uuids = [
+ new_uuid if u == old_uuid else u for u in action.target_uuids
+ ]
+
+
+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 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..7173b2837
--- /dev/null
+++ b/datalab/gui/panel/history/chainmodel.py
@@ -0,0 +1,175 @@
+# 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 Any
+
+from datalab.gui.processor.base import ProcessingParameters
+from datalab.history import HistoryAction, HistorySession
+
+
+@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.effective_panel_str(), [])
+ )
+ 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 as one chronological processing chain.
+
+ The current History Panel read model treats every non-empty session as one
+ ordered chain rooted at its first action. Recording policy choices can place
+ independent roots in the same session; this function intentionally does not
+ split them.
+
+ 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]
diff --git a/datalab/gui/panel/history/facade.py b/datalab/gui/panel/history/facade.py
new file mode 100644
index 000000000..65a77a929
--- /dev/null
+++ b/datalab/gui/panel/history/facade.py
@@ -0,0 +1,305 @@
+# 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 the replaying guard is active.
+
+ Cascade recomputation uses separate state and is not reported here.
+ """
+ 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 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)
+
+
+class HistoryRecordingFacadeMixin:
+ """Expose history-session recording operations used by application code."""
+
+ def create_new_session(self) -> HistorySession:
+ """Create a new history session and make it active."""
+ return hsess.create_new_session(self)
+
+ 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,
+ behavior: hsess.SessionBehavior | None = None,
+ ) -> bool:
+ """Offer to start a new session before recording an input."""
+ return hsess.maybe_start_session_for_input(self, load=load, behavior=behavior)
+
+ @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
+ )
+
+ def add_mutation_entry(
+ self,
+ action_title: str,
+ panel_str: str,
+ mutation_key: str,
+ target_uuids: list[str],
+ payload: Any = None,
+ save_state: bool = True,
+ ) -> HistoryAction | None:
+ """Add a mutation entry to history."""
+ return hsess.add_mutation_entry(
+ self,
+ action_title,
+ panel_str,
+ mutation_key,
+ target_uuids,
+ payload,
+ save_state,
+ )
+
+
+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..016af9035
--- /dev/null
+++ b/datalab/gui/panel/history/interactive_replay.py
@@ -0,0 +1,445 @@
+# 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
+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
+
+
+@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.
+
+ Entry point of the Replay, Step-by-step and double-click commands. When
+ nothing is selected in the tree, the last session is targeted (no-op if
+ the history is empty). Each selected session or action is first checked
+ against the current workspace state: any incompatibility vetoes the whole
+ command with an error dialog (skipped in unattended mode).
+
+ When ``replay`` is enabled, the selected actions (sessions contribute all
+ of their actions) are forwarded to :func:`replay_actions`, with parameter
+ dialogs when the panel's edit mode is active. When ``replay`` is disabled
+ and ``restore_selection`` is enabled, each selected entry is restored
+ instead: if edit mode is active or any action has pending parameter
+ edits, :func:`restore_action_params` rolls back the edited parameters and
+ recomputes in place; otherwise the recorded workspace selection is simply
+ restored.
+
+ Args:
+ panel: History panel instance
+ replay: Replay the selected actions through the in-place recompute
+ engine
+ restore_selection: When not replaying, restore the recorded workspace
+ selection (or the original parameters when edits are pending)
+ """
+ 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_mode = panel.runtime.execution.edit_mode
+ actions_to_replay: list[HistoryAction] = []
+ for session_or_action in selected:
+ if not session_or_action.is_current_state_compatible(panel.mainwindow):
+ if not execenv.unattended:
+ QW.QMessageBox.critical(
+ panel.mainwindow,
+ _("Error"),
+ _("The current workspace state is not compatible with the action."),
+ )
+ return
+ if replay:
+ if isinstance(session_or_action, HistorySession):
+ actions_to_replay.extend(session_or_action.actions)
+ else:
+ actions_to_replay.append(session_or_action)
+ elif restore_selection:
+ if 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 actions_to_replay:
+ replay_actions(panel, actions_to_replay, prompt=edit_mode)
+
+
+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 _load_outputs_still_exist(panel: HistoryPanel, action: HistoryAction) -> bool:
+ """Return True if all recorded load outputs still exist in a data panel.
+
+ Args:
+ panel: History panel instance
+ action: Load action (``UI_LOAD_METHODS``) to check
+
+ Returns:
+ True if the action recorded at least one output UUID and every one of
+ them still exists in either the signal or the image panel.
+ """
+ output_uuids = hchain.recorded_action_output_uuids(panel, action)
+ if not output_uuids:
+ return False
+ panels = (panel.mainwindow.signalpanel, panel.mainwindow.imagepanel)
+ return all(any(p.objmodel.has_uuid(uid) for p in panels) for uid in output_uuids)
+
+
+def confirm_file_output_replay(panel: HistoryPanel, action: HistoryAction) -> bool:
+ """Ask the user to confirm the replay of a file-save action.
+
+ Replaying a file-output action (``FILE_OUTPUT_METHODS``) overwrites the
+ files recorded in the action kwargs, so one confirmation question is asked
+ per action. In unattended mode no dialog is shown: the action is skipped
+ by default and replayed only when ``execenv.accept_dialogs`` is set.
+
+ Args:
+ panel: History panel instance
+ action: File-output action (``FILE_OUTPUT_METHODS``) to confirm
+
+ Returns:
+ True if the action should be replayed.
+ """
+ if execenv.unattended:
+ return bool(execenv.accept_dialogs)
+ names: list[str] = []
+ filename = action.kwargs.get("filename")
+ if isinstance(filename, str):
+ names.append(filename)
+ filenames = action.kwargs.get("filenames")
+ if isinstance(filenames, (list, tuple)):
+ names.extend(str(fname) for fname in filenames)
+ if not names:
+ # ``save_to_directory``: the destination is carried by the recorded
+ # parameter object (inspected defensively, format may evolve).
+ param = action.kwargs.get("param")
+ directory = getattr(param, "directory", None)
+ if directory:
+ pattern = getattr(param, "basename", None)
+ extension = getattr(param, "extension", None)
+ if pattern:
+ names.append(f"{directory} ({pattern}{extension or ''})")
+ else:
+ names.append(str(directory))
+ if not names:
+ names.append(action.title or action.uuid)
+ answer = QW.QMessageBox.question(
+ panel.mainwindow,
+ _("Replay file save"),
+ _("This action will overwrite the following file(s):\n%s\n\nReplay it?")
+ % "\n".join(names),
+ QW.QMessageBox.Yes | QW.QMessageBox.No,
+ QW.QMessageBox.No,
+ )
+ return answer == QW.QMessageBox.Yes
+
+
+def _recompute_stale_actions(panel: HistoryPanel, ordered: list[HistoryAction]) -> None:
+ """Recompute stale actions in place after a dialog rollback.
+
+ Args:
+ panel: History panel instance
+ ordered: Selected actions in session order
+ """
+ stale_actions = [a for a in ordered if a.is_stale]
+ if not stale_actions:
+ return
+ try:
+ for stale_action in stale_actions:
+ success = hrec.recompute_action_in_place(panel, stale_action)
+ stale_action.is_stale = not success
+ panel.tree.refresh_action_item(stale_action)
+ finally:
+ hrec.flush_cascade_warnings(panel)
+
+
+def replay_actions(
+ panel: HistoryPanel, actions: list[HistoryAction], prompt: bool = True
+) -> None:
+ """Replay selected actions through the in-place recompute engine.
+
+ When ``prompt`` is enabled, each selected action gets exactly one
+ parameter dialog. Recomputable selected actions are always included,
+ while accepted parameter edits also include all downstream dependent
+ actions. When ``prompt`` is disabled, actions are recomputed silently
+ with their current parameters (no dialogs anywhere). The resulting
+ global plan is deduplicated and executed in session order. A re-entrance
+ guard prevents nested prompt loops.
+
+ Args:
+ panel: History panel instance
+ actions: Selected actions to replay
+ prompt: Open parameter dialogs before recomputing
+ """
+ # 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
+ }
+ if prompt
+ else {}
+ )
+ 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 action.kind == HistoryAction.KIND_COMPUTE and not is_compute:
+ name = action.func_name or action.title or action.uuid
+ panel.runtime.execution.cascade_warnings.append(
+ _("Action %s has no recorded pattern and cannot be replayed.")
+ % name
+ )
+ continue
+ if not is_creation and not is_compute:
+ deferred_actions.append(action)
+ continue
+ if prompt:
+ 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)
+ _recompute_stale_actions(panel, ordered)
+ 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:
+ if hchain.action_mutates_any(action, blocked_outputs):
+ # Mutation targeting an object whose recompute failed
+ # upstream: skip it like a blocked compute.
+ continue
+ is_load_action = (
+ action.kind == HistoryAction.KIND_UI
+ and action.method_name in HistoryAction.UI_LOAD_METHODS
+ )
+ if is_load_action:
+ if _load_outputs_still_exist(panel, action):
+ # All loaded objects still exist: replaying would
+ # duplicate them, so skip the load action.
+ continue
+ if action.kwargs.get("add_objects") is False:
+ # Legacy entries recorded by ``load_from_directory``
+ # with ``add_objects=False``: self-heal so replay
+ # actually adds the loaded objects.
+ action.kwargs["add_objects"] = True
+ if (
+ action.kind == HistoryAction.KIND_UI
+ and action.method_name in HistoryAction.FILE_OUTPUT_METHODS
+ and not confirm_file_output_replay(panel, action)
+ ):
+ # User declined (or unattended default): skip the
+ # file-save action cleanly, the plan continues.
+ continue
+ data_panels = (
+ panel.mainwindow.signalpanel,
+ panel.mainwindow.imagepanel,
+ )
+ before_ids = (
+ {
+ p.PANEL_STR_ID: set(p.objmodel.get_object_ids())
+ for p in data_panels
+ }
+ if is_load_action
+ else None
+ )
+ payload_before = action.kwargs.get("payload")
+ with panel.replaying(), panel.output_suppressed():
+ action.replay(
+ panel.mainwindow, restore_selection=True, edit=prompt
+ )
+ if before_ids is not None:
+ new_uuids = [
+ uid
+ for p in data_panels
+ for uid in p.objmodel.get_object_ids()
+ if uid not in before_ids[p.PANEL_STR_ID]
+ ]
+ if new_uuids:
+ # Re-bind the load action to the freshly loaded
+ # objects: replay assigns new UUIDs, and stale
+ # recorded outputs would break duplicate detection
+ # and downstream reconnection.
+ panel.register_action_outputs(action, new_uuids)
+ if (
+ prompt
+ and action.kind == HistoryAction.KIND_MUTATION
+ and action.kwargs.get("payload") is not payload_before
+ ):
+ # The mutation payload was edited in the dialog:
+ # recompute the downstream closure (seeded from the
+ # mutation targets, see ``get_downstream_actions``).
+ panel.tree.refresh_action_item(action)
+ hrec.recompute_cascade(panel, action)
+ continue
+ if hchain.action_consumes_any(action, blocked_outputs):
+ blocked_outputs.update(
+ hchain.recorded_action_output_uuids(panel, action)
+ )
+ 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(
+ hchain.recorded_action_output_uuids(panel, action)
+ )
+ 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.
+
+ Every targeted action is recomputed unconditionally, even when it has no
+ pending parameter edits, so that stale markers are cleared on success.
+ """
+ actions: list[HistoryAction]
+ if isinstance(item, HistorySession):
+ actions = [
+ a
+ for a in item.actions
+ if a.kind in (HistoryAction.KIND_COMPUTE, HistoryAction.KIND_MUTATION)
+ or (
+ a.kind == HistoryAction.KIND_UI
+ and a.method_name in HistoryAction.UI_CREATION_METHODS
+ )
+ ]
+ else:
+ actions = [item]
+ try:
+ for action in actions:
+ 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)
+ finally:
+ hrec.flush_cascade_warnings(panel)
+ 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..3008e1138
--- /dev/null
+++ b/datalab/gui/panel/history/navigation.py
@@ -0,0 +1,207 @@
+# 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: HistorySession | None = None
+ 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 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 get_active_session(self) -> HistorySession | None:
+ """Return the valid active recording session."""
+ session = self.active_session
+ if session is not None and session in self.panel.history_sessions:
+ return session
+ return None
+
+ def set_active_session(self, session: HistorySession) -> None:
+ """Mark a session as the single active recording session."""
+ self.active_session = session
+ self.refresh_active_session_highlight()
+
+ def refresh_active_session_highlight(self) -> None:
+ """Highlight the active recording session in the tree."""
+ session = self.get_active_session()
+ number = session.number if session is not None else None
+ self.panel.tree.set_active_session(number)
+
+ 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 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..1a6b56226
--- /dev/null
+++ b/datalab/gui/panel/history/panel.py
@@ -0,0 +1,106 @@
+# 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"
+
+ 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()
+ self.history_sessions = []
+ self.navigation.active_session = None
+ self.navigation.session_increment = 0
+ self.tree.populate_tree(self.history_sessions)
+ self.ui.update_actions_state()
diff --git a/datalab/gui/panel/history/recompute.py b/datalab/gui/panel/history/recompute.py
new file mode 100644
index 000000000..408b35382
--- /dev/null
+++ b/datalab/gui/panel/history/recompute.py
@@ -0,0 +1,945 @@
+# 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, Any
+
+from qtpy import QtWidgets as QW
+from sigima.objects import ImageObj, SignalObj
+from sigima.objects.base import ROI_KEY
+
+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.history.effects import AnalysisEffects, capture_effects, merge_effects
+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()
+ # Read everything that may raise AttributeError (missing/None metadata)
+ # before wiping the target, so a failure cannot leave metadata half-updated.
+ try:
+ saved_uuid = target_obj.metadata.get("__uuid")
+ saved_number = target_obj.metadata.get("__number")
+ # Align with the 1_to_1 path: keep the target's user ROI when the
+ # freshly computed object does not carry one.
+ saved_roi = target_obj.metadata.get(ROI_KEY)
+ new_metadata = dict(new_obj.metadata)
+ except AttributeError:
+ return
+ target_obj.metadata.clear()
+ target_obj.metadata.update(new_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
+ if saved_roi is not None and ROI_KEY not in target_obj.metadata:
+ target_obj.metadata[ROI_KEY] = saved_roi
+
+
+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 resolve_output_panel(
+ panel: HistoryPanel,
+ out_uuid: str,
+ new_obj: SignalObj | ImageObj | None,
+ fallback: BaseDataPanel,
+) -> BaseDataPanel:
+ """Return the data panel that owns (or must own) an action output.
+
+ Cross-panel features (e.g. an image line profile producing a signal)
+ store their output in the panel matching the output type, not in the
+ panel of the action. Resolution order: the panel whose object model
+ currently owns ``out_uuid``, then the panel matching the type of
+ ``new_obj``, then ``fallback`` (the action's panel).
+
+ Args:
+ panel: History panel instance.
+ out_uuid: Recorded UUID of the output object.
+ new_obj: Freshly recomputed output object, or ``None`` when the
+ output has not been recomputed yet.
+ fallback: Panel to return when neither the UUID nor the object
+ type resolves to a panel.
+
+ Returns:
+ Data panel that owns (or must own) the output object.
+ """
+ # Stub panels in unit tests have no mainwindow: no cross-panel routing
+ mainwindow = getattr(panel, "mainwindow", None)
+ if mainwindow is None:
+ return fallback
+ signalpanel = mainwindow.signalpanel
+ imagepanel = mainwindow.imagepanel
+ if signalpanel.objmodel.has_uuid(out_uuid):
+ return signalpanel
+ if imagepanel.objmodel.has_uuid(out_uuid):
+ return imagepanel
+ if isinstance(new_obj, SignalObj):
+ return signalpanel
+ if isinstance(new_obj, ImageObj):
+ return imagepanel
+ return fallback
+
+
+def apply_output_in_place_or_recreate(
+ panel: HistoryPanel,
+ panel_data: BaseDataPanel,
+ action: HistoryAction,
+ out_uuid: str,
+ new_obj: SignalObj | ImageObj,
+ pparams: ProcessingParameters | None = None,
+ group_id: str | None = None,
+) -> None:
+ """Update the recorded output in place, re-creating it if it was deleted.
+
+ When the recorded output object no longer exists in ``panel_data``, the
+ freshly computed ``new_obj`` is inserted back **under its original
+ recorded UUID** so that downstream references (``source_uuid`` /
+ ``source_uuids`` metadata and ``action.output_uuids``) remain valid
+ without any remapping. The runtime action→outputs mapping (pruned when
+ the object was deleted) is re-registered.
+
+ This only commits the data: callers are responsible for calling
+ :func:`refresh_target` once all outputs have been committed.
+
+ Args:
+ panel: History panel instance.
+ panel_data: Data panel that owns (or owned) the output object.
+ action: History action that produced the output.
+ out_uuid: Recorded UUID of the output object.
+ new_obj: Freshly recomputed object providing title, data and metadata.
+ pparams: Processing parameters to store on the output, or ``None``.
+ group_id: Group for a re-created output (``None`` = default group).
+ """
+ if panel_data.objmodel.has_uuid(out_uuid):
+ target_obj = panel_data.objmodel[out_uuid]
+ update_obj_in_place(target_obj, new_obj)
+ else:
+ new_obj.set_metadata_option("uuid", out_uuid)
+ # The ``replaying()`` guard suppresses history capture and session
+ # prompts while the deleted output is re-inserted in the data panel.
+ with panel.replaying():
+ panel_data.add_object(new_obj, group_id=group_id, set_current=False)
+ target_obj = panel_data.objmodel[out_uuid]
+ panel.runtime.objects.register_action_outputs(
+ action, hchain.recorded_action_output_uuids(panel, action)
+ )
+ if pparams is not None:
+ insert_processing_parameters(target_obj, pparams)
+
+
+def recompute_mutation_in_place(panel: HistoryPanel, action: HistoryAction) -> bool:
+ """Re-apply a mutation action to its target object(s) during a cascade.
+
+ The recorded payload is re-applied through
+ :meth:`HistoryAction.replay_mutation`; targets that were deleted are
+ skipped with a cascade warning.
+
+ Args:
+ panel: History panel instance.
+ action: Mutation-kind history action to re-apply.
+
+ Returns:
+ True when at least one target object was mutated.
+ """
+ name = action.title or action.uuid
+ panel_data = hchain.resolve_panel_for_action(panel, action)
+ if panel_data is None:
+ panel.runtime.execution.cascade_warnings.append(
+ _("Action %s: target panel not found — skipping.") % name
+ )
+ return False
+ targets = action.target_uuids or []
+ if not targets:
+ panel.runtime.execution.cascade_warnings.append(
+ _("Action %s: no recorded mutation target — skipping.") % name
+ )
+ return False
+ missing = [uuid for uuid in targets if not panel_data.objmodel.has_uuid(uuid)]
+ if len(missing) == len(targets):
+ panel.runtime.execution.cascade_warnings.append(
+ _("Action %s: target object(s) no longer exist — skipping.") % name
+ )
+ return False
+ if missing:
+ panel.runtime.execution.cascade_warnings.append(
+ _("Action %s: %d target object(s) were deleted — applying to the rest.")
+ % (name, len(missing))
+ )
+ # ``replaying()`` is reentrant: suppress history capture while the
+ # payload is re-applied to the data panel objects. ``refresh=False``
+ # because each mutated target is refreshed individually below.
+ with panel.replaying():
+ mutated = action.replay_mutation(panel.mainwindow, refresh=False)
+ for uuid in mutated:
+ refresh_target(panel_data, uuid)
+ return bool(mutated)
+
+
+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_MUTATION:
+ return recompute_mutation_in_place(panel, action)
+ if action.kind != HistoryAction.KIND_COMPUTE:
+ return False
+ method = {
+ "1_to_1": recompute_compute_in_place,
+ "multiple_1_to_1": recompute_compute_in_place,
+ "1_to_n": recompute_compute_in_place,
+ "n_to_1": recompute_compute_in_place,
+ "2_to_1": recompute_compute_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 Exception as exc: # pylint: disable=broad-exception-caught
+ action.is_stale = True
+ _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
+ 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.
+ If the output object was deleted, it is re-created under its recorded
+ UUID so the downstream chain remains valid.
+
+ Synthetic session heads (e.g. produced by *Duplicate chain*) carry no
+ creation ``param``: they represent a pre-existing object. As long as
+ their recorded outputs still exist, they are a no-op success; if the
+ object was deleted, it cannot be re-created and a warning is queued.
+ """
+ name = action.title or action.uuid
+ panel_data = hchain.resolve_panel_for_action(panel, action)
+ if panel_data is None:
+ panel.runtime.execution.cascade_warnings.append(
+ _("Action %s: target panel not found — skipping.") % name
+ )
+ return False
+ recorded = hchain.recorded_action_output_uuids(panel, action)
+ if not recorded:
+ panel.runtime.execution.cascade_warnings.append(
+ _("Action %s: no recorded output object — skipping.") % name
+ )
+ return False
+ output_uuid = recorded[0]
+ param = action.kwargs.get("param")
+ if param is None:
+ # Outputs may live in either panel (cross-panel routing): resolve
+ # each one before checking existence.
+ if all(
+ resolve_output_panel(panel, uuid, None, panel_data).objmodel.has_uuid(uuid)
+ for uuid in recorded
+ ):
+ return True
+ panel.runtime.execution.cascade_warnings.append(
+ _(
+ "Action %s: the initial object was deleted and cannot be "
+ "re-created (no creation parameters)."
+ )
+ % name
+ )
+ return False
+ if action.target == "signalpanel":
+ prepared = prepare_signal_parameters(param, edit=False)
+ if prepared is None:
+ panel.runtime.execution.cascade_warnings.append(
+ _("Action %s: creation parameters could not be prepared — skipping.")
+ % name
+ )
+ return False
+ new_obj = create_signal_from_param(prepared)
+ else:
+ new_obj = create_image_from_param(param)
+ # Creation parameters are carried by ``new_obj`` so both the in-place
+ # update (metadata copy) and the recreation path preserve them.
+ insert_creation_parameters(new_obj, param)
+ apply_output_in_place_or_recreate(panel, panel_data, action, output_uuid, new_obj)
+ refresh_target(panel_data, output_uuid)
+ return True
+
+
+def execute_compute_via_ui(
+ panel_data: BaseDataPanel,
+ action: HistoryAction,
+ obj2_uuids: list[str],
+) -> None:
+ """Invoke the public processor entry point matching ``action``'s pattern.
+
+ This is the same code path the menus use (``compute_1_to_1``,
+ ``compute_multiple_1_to_1``, ``compute_1_to_n``, ``compute_n_to_1``,
+ ``compute_2_to_1``), so multi-selection batching, group creation,
+ pairwise mode, X-array compatibility, progress bars and error handling
+ are reused by construction. The recorded parameters are deep-copied so
+ replay never mutates (nor shares) the action's own kwargs, and
+ ``edit=False`` suppresses parameter dialogs (edit-mode prompting is
+ handled upstream by the interactive replay).
+
+ Args:
+ panel_data: Data panel targeted by the action.
+ action: Compute-kind history action to re-execute.
+ obj2_uuids: Recorded second-operand UUIDs (2-to-1 pattern only).
+
+ Raises:
+ FeatureNotFoundError: If the feature is not registered (missing
+ plugin), propagated to :func:`recompute_action_in_place`.
+ """
+ processor = panel_data.processor
+ title = action.title or action.func_name
+ if action.pattern == "multiple_1_to_1":
+ func_names = action.kwargs.get("func_names") or (
+ [action.func_name] if action.func_name else []
+ )
+ funcs = [
+ processor.get_feature(
+ func_name, plugin_origin=action.plugin_origin
+ ).function
+ for func_name in func_names
+ ]
+ params = action.kwargs.get("params")
+ processor.compute_multiple_1_to_1(
+ funcs,
+ params=copy.deepcopy(params) if params is not None else None,
+ title=title,
+ edit=False,
+ )
+ return
+ if action.pattern == "1_to_n":
+ params = [copy.deepcopy(param) for param in action.kwargs.get("params") or []]
+ feature = processor.get_feature(
+ action.func_name,
+ plugin_origin=action.plugin_origin,
+ paramclass_name=type(params[0]).__name__ if params else None,
+ )
+ processor.compute_1_to_n(
+ feature.function, params=params, title=title, edit=False
+ )
+ return
+ param = copy.deepcopy(action.kwargs.get("param"))
+ feature = processor.get_feature(
+ action.func_name,
+ plugin_origin=action.plugin_origin,
+ paramclass_name=type(param).__name__ if param is not None else None,
+ )
+ if action.pattern == "1_to_1":
+ processor.compute_1_to_1(feature.function, param=param, title=title, edit=False)
+ elif action.pattern == "n_to_1":
+ processor.compute_n_to_1(
+ feature.function,
+ param=param,
+ title=title,
+ edit=False,
+ pairwise=bool(action.kwargs.get("pairwise")),
+ )
+ elif action.pattern == "2_to_1":
+ objs2 = [panel_data.objmodel[uuid] for uuid in obj2_uuids]
+ pairwise = bool(action.kwargs.get("pairwise"))
+ processor.compute_2_to_1(
+ objs2 if pairwise else objs2[0],
+ action.kwargs.get("obj2_name") or feature.obj2_name or _("Second operand"),
+ feature.function,
+ param=param,
+ title=title,
+ edit=False,
+ skip_xarray_compat=feature.skip_xarray_compat,
+ pairwise=pairwise,
+ pre_execute_hook=feature.pre_execute_hook,
+ )
+ else:
+ raise ValueError(f"Unsupported compute pattern: {action.pattern!r}")
+
+
+def _detach_object(panel_data: BaseDataPanel, obj_uuid: str) -> SignalObj | ImageObj:
+ """Remove ``obj_uuid`` from ``panel_data`` and return the live instance.
+
+ Low-level counterpart of ``BaseDataPanel.remove_object`` used for the
+ temporary objects created by an execute-via-UI replay: no history entry,
+ no removal signal (the object was never a real workspace output).
+ """
+ obj = panel_data.objmodel[obj_uuid]
+ panel_data.plothandler.remove_item(obj_uuid)
+ panel_data.objview.remove_item(obj_uuid, refresh=False)
+ panel_data.objmodel.remove_object(obj)
+ panel_data.objview.update_tree()
+ return obj
+
+
+def _discard_new_empty_groups(
+ data_panels: tuple[BaseDataPanel, ...],
+ before_groups: dict[str, set[str]],
+) -> None:
+ """Remove empty groups created by an execute-via-UI replay batch."""
+ for panel_data in data_panels:
+ removed = False
+ for group in list(panel_data.objmodel.get_groups()):
+ group_uuid = get_uuid(group)
+ if group_uuid in before_groups[panel_data.PANEL_STR_ID]:
+ continue
+ if group.get_object_ids():
+ continue
+ panel_data.objview.remove_item(group_uuid, refresh=False)
+ panel_data.objmodel.remove_group(group)
+ removed = True
+ if removed:
+ panel_data.objview.update_tree()
+
+
+def _restore_selection(
+ data_panels: tuple[BaseDataPanel, ...],
+ saved_selection: dict[str, list[str]],
+) -> None:
+ """Best-effort restore of the pre-replay object and group selection."""
+ for panel_data in data_panels:
+ group_uuids = {get_uuid(grp) for grp in panel_data.objmodel.get_groups()}
+ uuids = [
+ uuid
+ for uuid in saved_selection[panel_data.PANEL_STR_ID]
+ if panel_data.objmodel.has_uuid(uuid) or uuid in group_uuids
+ ]
+ for idx, uuid in enumerate(uuids):
+ panel_data.objview.set_current_item_id(uuid, extend=idx > 0)
+
+
+def _commit_outputs(
+ panel: HistoryPanel,
+ panel_data: BaseDataPanel,
+ action: HistoryAction,
+ recorded: list[str],
+ detached: list[tuple[BaseDataPanel, SignalObj | ImageObj]],
+) -> None:
+ """Commit fresh outputs onto the recorded outputs (index-aligned).
+
+ Recorded outputs that still exist are updated in place; deleted outputs
+ are re-created under their original recorded UUIDs, preferring the group
+ of a surviving sibling output, then the first source's group. For 1-to-1
+ family patterns, existing outputs keep their own metadata (ROIs,
+ annotations, analysis results...) and only their processing parameters
+ are refreshed, matching the behavior of a manual re-processing. On
+ failure, previously existing outputs are restored from snapshots before
+ the exception is propagated.
+
+ Args:
+ panel: History panel instance.
+ panel_data: Data panel targeted by the action.
+ action: Compute-kind history action being reconciled.
+ recorded: Recorded output UUIDs, in recording order.
+ detached: Freshly computed objects (with their creation panel), in
+ creation order, index-aligned with ``recorded``.
+ """
+ sources = action.state.selection.get(panel_data.PANEL_STR_ID, [])
+ fallback_gid = None
+ if sources and panel_data.objmodel.has_uuid(sources[0]):
+ fallback_gid = panel_data.objmodel.get_object_group_id(
+ panel_data.objmodel[sources[0]]
+ )
+ preserve_metadata = action.pattern in {"1_to_1", "multiple_1_to_1"}
+ plans: list[tuple[str, BaseDataPanel, SignalObj | ImageObj]] = []
+ snapshots: dict[str, tuple[BaseDataPanel, SignalObj | ImageObj]] = {}
+ for out_uuid, (fresh_panel, fresh_obj) in zip(recorded, detached):
+ output_panel = resolve_output_panel(panel, out_uuid, fresh_obj, fresh_panel)
+ plans.append((out_uuid, output_panel, fresh_obj))
+ if output_panel.objmodel.has_uuid(out_uuid):
+ snapshots[out_uuid] = (
+ output_panel,
+ copy.deepcopy(output_panel.objmodel[out_uuid]),
+ )
+ sibling_gid = next(
+ (
+ output_panel.objmodel.get_object_group_id(output_panel.objmodel[out_uuid])
+ for out_uuid, output_panel, _fresh_obj in plans
+ if output_panel is panel_data and out_uuid in snapshots
+ ),
+ None,
+ )
+ try:
+ for out_uuid, output_panel, fresh_obj in plans:
+ pparams = extract_processing_parameters(fresh_obj)
+ existing_pp = (
+ extract_processing_parameters(output_panel.objmodel[out_uuid])
+ if out_uuid in snapshots
+ else None
+ )
+ if pparams is not None:
+ # The freshly registered feature may not carry the plugin
+ # origin (or a param): fall back to the recorded action, then
+ # to the metadata stored on the target, as the legacy
+ # per-pattern engines did.
+ if pparams.plugin_origin is None:
+ pparams.plugin_origin = action.plugin_origin or (
+ existing_pp.plugin_origin if existing_pp else None
+ )
+ if pparams.param is None and existing_pp is not None:
+ pparams.param = existing_pp.param
+ if out_uuid in snapshots and preserve_metadata and pparams is not None:
+ # Preserve the existing output's own metadata (ROIs,
+ # annotations, analysis results...)
+ output_panel.objprop.apply_recomputed_object_in_place(
+ output_panel.objmodel[out_uuid], fresh_obj, pparams
+ )
+ continue
+ group_id = None
+ if output_panel is panel_data and out_uuid not in snapshots:
+ group_id = sibling_gid or fallback_gid
+ apply_output_in_place_or_recreate(
+ panel,
+ output_panel,
+ action,
+ out_uuid,
+ fresh_obj,
+ pparams,
+ group_id=group_id,
+ )
+ for out_uuid, output_panel, _fresh_obj in plans:
+ refresh_target(output_panel, out_uuid)
+ except Exception:
+ # Outputs re-created during this failed batch (absent from
+ # ``snapshots``) must not survive the rollback: detach them.
+ for out_uuid, output_panel, _fresh_obj in plans:
+ if out_uuid not in snapshots and output_panel.objmodel.has_uuid(out_uuid):
+ _detach_object(output_panel, out_uuid)
+ for out_uuid, (output_panel, snapshot) in snapshots.items():
+ update_obj_in_place(output_panel.objmodel[out_uuid], snapshot)
+ for out_uuid, (output_panel, _snapshot) in snapshots.items():
+ try:
+ refresh_target(output_panel, out_uuid)
+ except Exception: # pylint: disable=broad-exception-caught
+ _logger.exception(
+ "Cascade recompute rollback refresh failed for output %s.",
+ out_uuid,
+ )
+ raise
+
+
+def recompute_compute_in_place(panel: HistoryPanel, action: HistoryAction) -> bool:
+ """Replay a compute action through the real UI entry point, then reconcile.
+
+ **Execute**: the recorded selection is restored and the same public
+ processor method the menus use is invoked with the recorded parameters
+ (see :func:`execute_compute_via_ui`), under the ``replaying()`` guard so
+ no new history entry is recorded.
+
+ **Reconcile**: the freshly created objects — diffed from both data
+ panels, in recording order (cross-panel outputs land in the panel
+ matching their type) — are aligned by index with the recorded output
+ UUIDs and committed via :func:`_commit_outputs`. The temporary objects
+ (and any temporary group created by the batch, e.g. pairwise ``dst_gname``
+ groups) are then removed so no duplicates remain.
+
+ If the number of fresh outputs differs from the number of recorded
+ outputs, the temporary objects are discarded, a warning is queued and the
+ action is flagged stale.
+
+ Args:
+ panel: History panel instance.
+ action: Compute-kind history action to recompute.
+
+ Returns:
+ True when every recorded output was reconciled.
+ """
+ panel_data = hchain.resolve_panel_for_action(panel, action)
+ if panel_data is None:
+ return False
+ recorded = hchain.recorded_action_output_uuids(panel, action)
+ if not recorded:
+ return False
+ name = action.func_name or action.title or action.uuid
+ sources = list(action.state.selection.get(panel_data.PANEL_STR_ID, []))
+ if not sources:
+ panel.runtime.execution.cascade_warnings.append(
+ _("Action %s: no recorded source object — skipping.") % name
+ )
+ return False
+ obj2_uuids = action.kwargs.get("obj2_uuids") or []
+ if isinstance(obj2_uuids, str):
+ obj2_uuids = [obj2_uuids]
+ if action.pattern == "2_to_1" and not obj2_uuids:
+ panel.runtime.execution.cascade_warnings.append(
+ _("Action %s: no recorded second operand — skipping.") % name
+ )
+ return False
+ required = sources + (obj2_uuids if action.pattern == "2_to_1" else [])
+ if any(not panel_data.objmodel.has_uuid(uuid) for uuid in required):
+ panel.runtime.execution.cascade_warnings.append(
+ _("Action %s: source object(s) were deleted — skipping.") % name
+ )
+ return False
+ data_panels = (panel.mainwindow.signalpanel, panel.mainwindow.imagepanel)
+ before_objs = {
+ p.PANEL_STR_ID: set(p.objmodel.get_object_ids()) for p in data_panels
+ }
+ before_grps = {
+ p.PANEL_STR_ID: {get_uuid(grp) for grp in p.objmodel.get_groups()}
+ for p in data_panels
+ }
+ saved_selection = {
+ p.PANEL_STR_ID: p.objview.get_sel_object_uuids()
+ + p.objview.get_sel_group_uuids()
+ for p in data_panels
+ }
+ # ``replaying()`` suppresses history capture and session prompts for the
+ # whole execute + reconcile scope (temporary insertions included).
+ with panel.replaying():
+ try:
+ panel_data.objview.select_objects(sources)
+ try:
+ execute_compute_via_ui(panel_data, action, obj2_uuids)
+ except Exception:
+ # A compute failing mid-batch may already have inserted some
+ # fresh temporaries: detach them so no duplicates remain.
+ for p in data_panels:
+ for uid in list(p.objmodel.get_object_ids()):
+ if uid not in before_objs[p.PANEL_STR_ID]:
+ _detach_object(p, uid)
+ _discard_new_empty_groups(data_panels, before_grps)
+ raise
+ fresh = [
+ (p, uid)
+ for p in data_panels
+ for uid in p.objmodel.get_object_ids()
+ if uid not in before_objs[p.PANEL_STR_ID]
+ ]
+ if len(fresh) != len(recorded):
+ for fresh_panel, fresh_uuid in fresh:
+ _detach_object(fresh_panel, fresh_uuid)
+ _discard_new_empty_groups(data_panels, before_grps)
+ action.is_stale = True
+ _logger.warning(
+ "Cascade recompute: cardinality changed for action %s: "
+ "%d output(s), %d recorded.",
+ action.uuid,
+ len(fresh),
+ len(recorded),
+ )
+ panel.runtime.execution.cascade_warnings.append(
+ _("Action %s: recompute returned %d output(s), expected %d.")
+ % (name, len(fresh), len(recorded))
+ )
+ return False
+ # Detach the fresh objects: they are temporary carriers whose
+ # content is committed onto the recorded outputs below.
+ detached = [
+ (fresh_panel, _detach_object(fresh_panel, fresh_uuid))
+ for fresh_panel, fresh_uuid in fresh
+ ]
+ _discard_new_empty_groups(data_panels, before_grps)
+ _commit_outputs(panel, panel_data, action, recorded, detached)
+ finally:
+ _restore_selection(data_panels, saved_selection)
+ return True
+
+
+def _snapshot_analysis_source(
+ obj: SignalObj | ImageObj, effects_dict: dict | None
+) -> tuple[dict[str, Any], list[str] | None]:
+ """Snapshot the metadata of one analysis source before a recompute.
+
+ When an effects manifest is available, only the keys it lists are deep
+ copied (targeted snapshot) and the manifest keys currently absent are
+ recorded so a failed attempt that recreates them can be rolled back by
+ deletion. Without a manifest (legacy action), the whole metadata
+ dictionary is deep copied.
+
+ Args:
+ obj: Source object about to be recomputed.
+ effects_dict: Serialized :class:`AnalysisEffects` manifest, or None.
+
+ Returns:
+ Tuple ``(saved, absent)`` where ``saved`` maps keys to deep-copied
+ values and ``absent`` lists manifest keys missing before the
+ recompute. ``absent`` is None for the legacy full-metadata snapshot.
+ """
+ if effects_dict is None:
+ return copy.deepcopy(obj.metadata), None
+ manifest = AnalysisEffects.from_dict(effects_dict)
+ keys = manifest.metadata_added + manifest.metadata_replaced
+ saved = {
+ key: copy.deepcopy(obj.metadata[key]) for key in keys if key in obj.metadata
+ }
+ absent = [key for key in keys if key not in obj.metadata]
+ return saved, absent
+
+
+def _restore_analysis_source(
+ obj: SignalObj | ImageObj,
+ saved: dict[str, Any],
+ absent: list[str] | None,
+ attempt_effects: AnalysisEffects | None,
+) -> None:
+ """Restore a source's metadata from its snapshot after a failed recompute.
+
+ Args:
+ obj: Source object to restore.
+ saved: Snapshotted metadata values (full metadata for legacy actions).
+ absent: Manifest keys absent before the recompute (delete them if the
+ failed attempt recreated them), or None for a legacy full restore.
+ attempt_effects: Effects captured during the failed attempt, used to
+ delete keys it created outside the manifest (targeted mode only).
+ """
+ if absent is None:
+ obj.metadata.clear()
+ obj.metadata.update(saved)
+ # Drop any ROI created during the failed recompute so the cache stays
+ # consistent with the restored metadata
+ obj.invalidate_roi_cache()
+ return
+ touched = set(saved) | set(absent)
+ if attempt_effects is not None:
+ for key in attempt_effects.metadata_added:
+ obj.metadata.pop(key, None)
+ touched.update(attempt_effects.metadata_added)
+ obj.metadata.update(saved)
+ for key in absent:
+ obj.metadata.pop(key, None)
+ if ROI_KEY in touched and hasattr(obj, "invalidate_roi_cache"):
+ # Align with the legacy full-restore path: a restored/removed ROI
+ # entry must not leave a stale cached ROI object behind.
+ obj.invalidate_roi_cache()
+
+
+def recompute_1_to_0_in_place(panel: HistoryPanel, action: HistoryAction) -> bool:
+ """Recompute a 1-to-0 analysis on each source object in place.
+
+ Sources are snapshotted before the recompute. When the action carries an
+ effects manifest, the snapshot is targeted: only manifest keys are deep
+ copied and a failed attempt rolls back exactly those keys (plus any key
+ the attempt created), leaving unrelated metadata untouched. Legacy
+ actions without a manifest fall back to a full-metadata snapshot.
+ On success, the freshly captured effects are merged into the manifest.
+ """
+ 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"))
+ 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]
+ snapshots = [
+ _snapshot_analysis_source(obj, (action.effects or {}).get(uuid))
+ for uuid, obj in zip(sources, source_objs)
+ ]
+ captured: dict[str, AnalysisEffects] = {}
+
+ def rollback() -> None:
+ for uuid, obj, (saved, absent) in zip(sources, source_objs, snapshots):
+ _restore_analysis_source(obj, saved, absent, captured.get(uuid))
+
+ try:
+ for uuid, src_obj in zip(sources, source_objs):
+ analysis_parameters = extract_analysis_parameters(src_obj)
+ plugin_origin = action.plugin_origin or (
+ analysis_parameters.plugin_origin if analysis_parameters else None
+ )
+ with capture_effects(src_obj) as effects:
+ # Register the (mutable) effects before running so rollback
+ # sees them even when the recompute raises
+ captured[uuid] = effects
+ success = panel_data.processor.recompute_1_to_0(
+ action.func_name,
+ src_obj,
+ param,
+ plugin_origin=plugin_origin,
+ )
+ if not success:
+ rollback()
+ return False
+ except Exception:
+ rollback()
+ raise
+ if action.effects is None:
+ action.effects = {}
+ for uuid in sources:
+ prev_dict = action.effects.get(uuid)
+ previous = AnalysisEffects.from_dict(prev_dict) if prev_dict else None
+ action.effects[uuid] = merge_effects(previous, captured[uuid]).to_dict()
+ 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():
+ 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..de55f27b6
--- /dev/null
+++ b/datalab/gui/panel/history/runtime.py
@@ -0,0 +1,244 @@
+# 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] = []
+
+ @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 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.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..a9ba57983
--- /dev/null
+++ b/datalab/gui/panel/history/ui.py
@@ -0,0 +1,186 @@
+# 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(
+ lambda _item, _column: self.panel.replay_restore_actions(
+ restore_selection=False
+ )
+ )
+ 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 7df4beeb9..238056391 100644
--- a/datalab/gui/processor/base.py
+++ b/datalab/gui/processor/base.py
@@ -9,13 +9,16 @@
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
+from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, Optional, cast
import guidata.dataset as gds
import numpy as np
@@ -28,6 +31,7 @@
ImageObj,
SignalObj,
TableResult,
+ TypeObj,
TypeROI,
TypeROIParam,
concat_geometries,
@@ -40,11 +44,13 @@
GeometryAdapter,
ResultData,
TableAdapter,
+ create_adapter,
show_resultdata,
)
from datalab.adapters_plotpy import coordutils
from datalab.config import Conf, _
from datalab.gui.processor.catcher import CompOut, wng_err_func
+from datalab.history.effects import capture_effects
from datalab.objectmodel import get_short_id, get_uuid, patch_title_with_ids
from datalab.utils.qthelpers import create_progress_bar, qt_try_except
from datalab.widgets.warningerror import show_warning_error
@@ -68,6 +74,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 +82,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.
@@ -212,6 +220,46 @@ 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.
@@ -227,6 +275,29 @@ def clear_analysis_parameters(obj: SignalObj | ImageObj) -> None:
del obj.metadata[key]
+# Param fields triggering side effects that must only run on first execution
+FIRST_RUN_ONLY_PARAM_FIELDS = ("create_rois",)
+
+
+def disable_first_run_side_effects(param: Any) -> None:
+ """Disable parameter fields whose side effects must only run once.
+
+ Recomputing an analysis must refresh its results, not replay first-run
+ side effects: for instance, detection functions store ``create_rois=True``
+ in their parameters, but re-running should not recreate ROIs (which would
+ overwrite ROIs deleted or edited by the user). Each field listed in
+ :data:`FIRST_RUN_ONLY_PARAM_FIELDS` is set to False when present on
+ ``param``. This is a generic extension point for future side-effect
+ parameters.
+
+ Args:
+ param: Parameter dataset to sanitize in place (None is a no-op).
+ """
+ for field_name in FIRST_RUN_ONLY_PARAM_FIELDS:
+ if hasattr(param, field_name):
+ setattr(param, field_name, False)
+
+
def run_with_env(func: Callable, args: tuple, env_json: str) -> CompOut:
"""Wrapper to apply environment config before calling func
@@ -507,18 +578,175 @@ 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 SourcePreparationTransaction:
+class SourcePreparationTransaction(Generic[TypeObj]):
"""Prepare effective sources and commit changes after successful results."""
- source_for_execution: Callable[
- [SignalObj | ImageObj, SignalObj | ImageObj], SignalObj | ImageObj
- ]
- commit: Callable[[SignalObj | ImageObj], None]
+ source_for_execution: Callable[[TypeObj, TypeObj], TypeObj]
+ commit: Callable[[TypeObj], None]
+
+
+SourcePreparationHook = Callable[
+ [list[TypeObj]], Optional[SourcePreparationTransaction[TypeObj]]
+]
@dataclass
-class ComputingFeature:
+class ComputingFeature(Generic[TypeObj]):
"""Computing feature dataclass.
Args:
@@ -531,6 +759,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.
pre_execute_hook: optional transactional source preparation hook
"""
@@ -543,9 +774,8 @@ class ComputingFeature:
edit: Optional[bool] = None
obj2_name: Optional[str] = None
skip_xarray_compat: Optional[bool] = None
- pre_execute_hook: Optional[
- Callable[[list[SignalObj | ImageObj]], SourcePreparationTransaction | None]
- ] = None
+ plugin_origin: Optional[dict[str, Any]] = field(default=None)
+ pre_execute_hook: Optional[SourcePreparationHook[TypeObj]] = None
def __post_init__(self):
"""Validate the function after initialization."""
@@ -683,7 +913,11 @@ def _check_signal_xarray_compatibility(
behavior = Conf.proc.xarray_compat_behavior.get("ask")
yes_to_all_selected = False
- if behavior == "ask" and not env.execenv.unattended:
+ # History replay must be non-interactive and deterministic: treat
+ # "ask" as automatic interpolation while replaying.
+ hpanel = getattr(self.mainwindow, "historypanel", None)
+ replaying = hpanel is not None and hpanel.is_replaying()
+ if behavior == "ask" and not env.execenv.unattended and not replaying:
# Create custom message box with "Yes to All" option
msg_box = QW.QMessageBox(self.mainwindow)
msg_box.setWindowTitle(_("X-array incompatibility"))
@@ -770,18 +1004,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,
@@ -792,8 +1036,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))
@@ -1017,7 +1264,7 @@ def _handle_keep_results(self, result_obj: SignalObj | ImageObj) -> None:
def recompute_analysis(
self, obj: SignalObj | ImageObj, refresh_plot: bool = True
- ) -> None:
+ ) -> bool:
"""Recompute analysis (1-to-0) operations on demand.
This method checks if the object has 1-to-0 analysis parameters (analysis
@@ -1037,31 +1284,26 @@ def recompute_analysis(
# 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
-
- # Get the parameter from processing parameters
- param = proc_params.param
-
- # 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)
+ return False
# 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).
+ # No deepcopy needed here: recompute_1_to_0 deepcopies its param internally.
+ success = self.recompute_1_to_0(
+ proc_params.func_name,
+ obj,
+ param=proc_params.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,
@@ -1138,7 +1380,10 @@ def recompute_processing(
# Recompute using the dedicated method (with multiprocessing support)
try:
compout = source_processor.recompute_1_to_1(
- proc_params.func_name, source_obj, param
+ 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)
@@ -1173,6 +1418,7 @@ def recompute_processing(
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)
@@ -1228,6 +1474,7 @@ def recompute_1_to_1(
func_name: str,
obj: SignalObj | ImageObj,
param: gds.DataSet | None = None,
+ plugin_origin: dict[str, Any] | None = None,
) -> CompOut:
"""Recompute a 1-to-1 processing operation without adding result to panel.
@@ -1240,19 +1487,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:
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
@@ -1282,6 +1533,114 @@ def recompute_1_to_1(
comp_out.result = new_obj
return comp_out
+ def prepare_2_to_1_pairs(
+ self,
+ object_pairs: list[tuple[TypeObj, TypeObj]],
+ skip_xarray_compat: bool | None,
+ pre_execute_hook: SourcePreparationHook[TypeObj] | None,
+ ) -> (
+ tuple[
+ list[tuple[TypeObj, TypeObj]],
+ SourcePreparationTransaction[TypeObj] | None,
+ ]
+ | None
+ ):
+ """Prepare 2-to-1 source pairs without mutating the original objects.
+
+ Args:
+ object_pairs: Original source pairs.
+ skip_xarray_compat: Whether to skip signal X-array compatibility.
+ pre_execute_hook: Optional transactional source preparation hook.
+
+ Returns:
+ Prepared pairs and their transaction, or ``None`` when cancelled.
+
+ Raises:
+ TypeError: If a pair does not match the processor object type.
+ """
+ expected_type = SignalObj if self._is_signal_panel() else ImageObj
+ prepared_pairs: list[tuple[TypeObj, TypeObj]] = []
+ auto_interpolate_for_operation = False
+ for obj1, obj2 in object_pairs:
+ if not isinstance(obj1, expected_type) or not isinstance(
+ obj2, expected_type
+ ):
+ raise TypeError(
+ "2-to-1 source objects must match the processor object type"
+ )
+ actual_obj1, actual_obj2 = obj1, obj2
+ if isinstance(obj1, SignalObj) and not skip_xarray_compat:
+ if auto_interpolate_for_operation:
+ with Conf.proc.xarray_compat_behavior.temp("interpolate"):
+ result = self._check_signal_xarray_compatibility([obj1, obj2])
+ else:
+ result = self._check_signal_xarray_compatibility([obj1, obj2])
+ if result is None:
+ return None
+ checked_pair, yes_to_all_selected = result
+ if yes_to_all_selected:
+ auto_interpolate_for_operation = True
+ actual_obj1 = cast(TypeObj, checked_pair[0])
+ actual_obj2 = cast(TypeObj, checked_pair[1])
+ prepared_pairs.append((actual_obj1, actual_obj2))
+
+ source_transaction = None
+ if pre_execute_hook is not None:
+ source_transaction = pre_execute_hook(
+ [obj1 for obj1, _obj2 in object_pairs]
+ )
+ if source_transaction is None:
+ return None
+ prepared_pairs = [
+ (
+ source_transaction.source_for_execution(obj1, actual_obj1),
+ actual_obj2,
+ )
+ for (obj1, _obj2), (actual_obj1, actual_obj2) in zip(
+ object_pairs, prepared_pairs
+ )
+ ]
+ return prepared_pairs, source_transaction
+
+ 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.
+ """
+ # Work on a local copy so callers' kwargs are never mutated, and
+ # disable side effects that must only run on first execution
+ param = copy.deepcopy(param)
+ disable_first_run_side_effects(param)
+ 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:
@@ -1328,6 +1687,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)
@@ -1426,7 +1786,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.
@@ -1456,7 +1816,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,
@@ -1465,7 +1836,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.
@@ -1481,7 +1852,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.
@@ -1494,7 +1865,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,
@@ -1503,7 +1886,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
@@ -1520,7 +1903,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.
@@ -1530,7 +1913,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,
@@ -1541,14 +1933,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
@@ -1563,7 +1956,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
@@ -1586,6 +1980,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()
@@ -1598,45 +2003,56 @@ 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 analysis parameters to enable on-demand recomputation
- # via the manual "Recompute" action.
- # 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)
-
- # Apply processor-specific post-processing on the result
- refresh_needed |= self.postprocess_1_to_0_result(obj, result)
+ metadata_snapshot = copy.deepcopy(obj.metadata)
+ result_count = len(rdata.results)
+ ylabel_count = len(rdata.ylabels)
+ short_id_count = len(rdata.short_ids)
- # Append result to result data for later display
- rdata.append(adapter, obj)
+ def persist_result(result=result, obj=obj) -> bool:
+ adapter = create_adapter(result)
+ 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
+
+ with capture_effects(obj) as effects:
+ persistence_output = wng_err_func(persist_result, ())
+ result_modified = self.handle_output(
+ persistence_output, _("Computing: %s") % title, progress
+ )
+ if result_modified is None:
+ # Rollback: discard the captured effects for this object
+ obj.metadata = metadata_snapshot
+ # Drop any ROI created during the failed computation so the
+ # cache stays consistent with the restored metadata
+ obj.invalidate_roi_cache()
+ 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 action is not None:
+ if action.effects is None:
+ action.effects = {}
+ action.effects[get_uuid(obj)] = effects.to_dict()
if obj is current_obj:
# Mark object as having fresh analysis results to show Analysis tab
@@ -1661,8 +2077,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
@@ -1693,202 +2110,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(
+ title or 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( # pylint: disable=too-many-return-statements
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,
@@ -1897,12 +2338,10 @@ def compute_2_to_1( # pylint: disable=too-many-return-statements
comment: str | None = None,
edit: bool | None = None,
skip_xarray_compat: bool | None = None,
- pre_execute_hook: Callable[
- [list[SignalObj | ImageObj]], SourcePreparationTransaction | None
- ]
- | None = None,
+ pairwise: bool | None = None,
+ pre_execute_hook: SourcePreparationHook[TypeObj] | 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)
@@ -1941,7 +2380,7 @@ def compute_2_to_1( # pylint: disable=too-many-return-statements
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:
@@ -1951,6 +2390,9 @@ def compute_2_to_1( # pylint: disable=too-many-return-statements
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
@@ -1975,98 +2417,200 @@ def compute_2_to_1( # pylint: disable=too-many-return-statements
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
+ 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)
- # 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]
+ pair_keys = [
+ (src_gid, i_pair)
+ for src_gid in src_gids
+ for i_pair in range(max_i_pair)
+ ]
+ original_pairs = [
+ (src_objs[src_gid][i_pair], objs2[i_pair])
+ for src_gid, i_pair in pair_keys
+ ]
+ preparation = self.prepare_2_to_1_pairs(
+ original_pairs, skip_xarray_compat, pre_execute_hook
+ )
+ if preparation is None:
+ return
+ prepared_pairs, source_transaction = preparation
+ pair_map = dict(zip(pair_keys, prepared_pairs))
+
+ 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]
+ actual_obj1, actual_obj2 = pair_map[(src_gid, i_pair)]
+
+ 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),
)
- else:
- # Normal compatibility check with dialog
+ 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
+ )
+ if source_transaction is not None:
+ source_transaction.commit(orig_obj1)
+
+ 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
- source_transaction = None
- if pre_execute_hook is not None:
- original_sources = [
- src_obj
- for src_gid in src_gids
- for src_obj in src_objs[src_gid][:max_i_pair]
- ]
- source_transaction = pre_execute_hook(original_sources)
- if source_transaction is None:
- return
+ source_transaction = None
+ if pre_execute_hook is not None:
+ source_transaction = pre_execute_hook(objs)
+ if source_transaction is None:
+ return
- 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]
+ 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]
if source_transaction is not None:
- actual_obj1 = source_transaction.source_for_execution(
- orig_obj1, actual_obj1
+ actual_obj = source_transaction.source_for_execution(
+ obj, actual_obj
)
- 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(
@@ -2076,10 +2620,9 @@ def compute_2_to_1( # pylint: disable=too-many-return-statements
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)
@@ -2090,127 +2633,20 @@ def compute_2_to_1( # pylint: disable=too-many-return-statements
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)
- if source_transaction is not None:
- source_transaction.commit(orig_obj1)
-
- 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
-
- source_transaction = None
- if pre_execute_hook is not None:
- source_transaction = pre_execute_hook(objs)
- if source_transaction is None:
- return
-
- 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]
- if source_transaction is not None:
- actual_obj = source_transaction.source_for_execution(
- obj, actual_obj
+ # 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
)
-
- 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
- )
- if source_transaction is not None:
- source_transaction.commit(obj)
+ if source_transaction is not None:
+ source_transaction.commit(obj)
def register_1_to_1(
self,
@@ -2363,10 +2799,7 @@ def register_2_to_1(
edit: bool | None = None,
obj2_name: str | None = None,
skip_xarray_compat: bool | None = None,
- pre_execute_hook: Callable[
- [list[SignalObj | ImageObj]], SourcePreparationTransaction | None
- ]
- | None = None,
+ pre_execute_hook: SourcePreparationHook[TypeObj] | None = None,
) -> ComputingFeature:
"""Register a 2-to-1 processing function.
@@ -2408,27 +2841,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(
@@ -2495,6 +2967,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,
@@ -2502,6 +2977,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)
@@ -2513,6 +2989,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"),
@@ -2523,6 +3000,7 @@ def run_feature(
comment=comment,
edit=edit,
skip_xarray_compat=feature.skip_xarray_compat,
+ pairwise=pairwise,
pre_execute_hook=feature.pre_execute_hook,
)
if pattern == "1_to_n":
@@ -2582,6 +3060,22 @@ def _extract_multiple_roi_in_single_object(
# ------Analysis-------------------------------------------------------------------
+ def _record_roi_mutation(
+ self, title: str, objs: list[TypeObj], roi: TypeROI | None
+ ) -> None:
+ """Record a ROI mutation history entry for ``objs`` (payload may be None)."""
+ # Some tests build processors without a history panel: stay defensive.
+ hpanel = getattr(self.mainwindow, "historypanel", None)
+ if hpanel is None:
+ return
+ hpanel.add_mutation_entry(
+ title,
+ panel_str=self.panel.PANEL_STR_ID,
+ mutation_key="roi",
+ target_uuids=[get_uuid(obj) for obj in objs],
+ payload=roi,
+ )
+
def edit_roi_graphically(
self, mode: Literal["apply", "extract", "define"] = "apply"
) -> TypeROI | None:
@@ -2625,12 +3119,22 @@ def edit_roi_graphically(
# object yet)
for obj_i in objs:
obj_i.roi = None
+ # Objects are actually mutated in both "apply" and "extract"
+ # modes here (mode != "define" is guaranteed above).
+ self._record_roi_mutation(
+ _("Edit regions of interest graphically"), objs, None
+ )
else:
edited_roi = edited_roi.__class__.from_params(obj, params)
if mode == "apply":
# Apply ROI to all selected objects
for obj_i in objs:
obj_i.roi = edited_roi
+ self._record_roi_mutation(
+ _("Edit regions of interest graphically"),
+ objs,
+ edited_roi,
+ )
self.SIG_ADD_SHAPE.emit(get_uuid(obj))
self.panel.selection_changed(update_items=True)
self.panel.refresh_plot(
@@ -2663,6 +3167,9 @@ def edit_roi_numerically(self) -> TypeROI:
if group.edit(parent=self.mainwindow):
edited_roi = obj.roi.__class__.from_params(obj, params)
obj.roi = edited_roi
+ self._record_roi_mutation(
+ _("Edit regions of interest numerically"), [obj], edited_roi
+ )
self.SIG_ADD_SHAPE.emit(get_uuid(obj))
self.panel.refresh_plot(
"selected",
@@ -2684,10 +3191,14 @@ def delete_regions_of_interest(self) -> None:
)
== QW.QMessageBox.Yes
):
+ removed_objs: list[TypeObj] = []
for obj in self.panel.objview.get_sel_objects():
if obj.roi is not None:
obj.roi = None
+ removed_objs.append(obj)
self.panel.selection_changed(update_items=True)
+ if removed_objs:
+ self._record_roi_mutation(_("Remove all ROIs"), removed_objs, None)
def delete_single_roi(self, roi_index: int) -> None:
"""Delete a single ROI by index
@@ -2712,4 +3223,7 @@ def delete_single_roi(self, roi_index: int) -> None:
if len(obj.roi.single_rois) == 0:
obj.roi = None
obj.mark_roi_as_changed()
+ self._record_roi_mutation(
+ _("Remove ROI '%s'") % roi_title, [obj], obj.roi
+ )
self.panel.selection_changed(update_items=True)
diff --git a/datalab/gui/processor/signal.py b/datalab/gui/processor/signal.py
index 5b04018a8..d83b60a38 100644
--- a/datalab/gui/processor/signal.py
+++ b/datalab/gui/processor/signal.py
@@ -89,7 +89,7 @@ def __has_legacy_fit_metadata(obj: SignalObj) -> bool:
def prepare_fit_evaluation(
self, objects: list[SignalObj]
- ) -> SourcePreparationTransaction | None:
+ ) -> SourcePreparationTransaction[SignalObj] | None:
"""Prepare historical peak-fit metadata for transactional conversion."""
converted: dict[str, signal_fitting.FitParams] = {}
invalid = []
@@ -815,16 +815,21 @@ 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)
if output is not None:
if len(output) == 3:
- y, _params, fit_params = output
+ y, params, fit_params = output
metadata = {"fit_params": fit_params}
else:
# Fallback for third-party fitting dialogs that do not return
@@ -844,19 +849,79 @@ def __row_compute_fit(
metadata = {fitdlgfunc.__name__: pvalues}
# Creating new signal
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:
@@ -873,7 +938,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:
@@ -891,7 +962,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/gui/settings.py b/datalab/gui/settings.py
index 19f4cb58b..47a610586 100644
--- a/datalab/gui/settings.py
+++ b/datalab/gui/settings.py
@@ -205,7 +205,51 @@ class ProcSettings(gds.DataSet):
),
)
_g0 = gds.EndGroup("")
- g1 = gds.BeginGroup(_("Settings for results management"))
+ g1 = gds.BeginGroup(_("History sessions"))
+ history_new_session_behavior = gds.ChoiceItem(
+ _("New object or file"),
+ zip(
+ Conf.proc.history_new_session_behavior.values,
+ [
+ _("Ask"),
+ _("Always start a new session"),
+ _("Continue in the current session"),
+ ],
+ ),
+ help=_(
+ "Behavior when a new object or file is added to a populated history "
+ "session."
+ ),
+ )
+ history_plugin_new_session_behavior = gds.ChoiceItem(
+ _("Plugin-created object"),
+ zip(
+ Conf.proc.history_plugin_new_session_behavior.values,
+ [
+ _("Ask"),
+ _("Always start a new session"),
+ _("Continue in the current session"),
+ ],
+ ),
+ help=_("Behavior when a plugin adds an object to a populated history session."),
+ )
+ history_plugin_multiload_behavior = gds.ChoiceItem(
+ _("Plugin multi-load"),
+ zip(
+ Conf.proc.history_plugin_multiload_behavior.values,
+ [
+ _("Ask once"),
+ _("Start a new session"),
+ _("Continue in the current session"),
+ ],
+ ),
+ help=_(
+ "Behavior when a plugin loads multiple objects into a populated history "
+ "session."
+ ),
+ )
+ _g1 = gds.EndGroup("")
+ g2 = gds.BeginGroup(_("Settings for results management"))
keep_results = gds.BoolItem(
_("Keep results in metadata after computation"),
_("Keep results"),
@@ -228,7 +272,7 @@ class ProcSettings(gds.DataSet):
"If disabled, the results dialog will not be shown automatically."
),
)
- _g1 = gds.EndGroup("")
+ _g2 = gds.EndGroup("")
class ImageDefaultSettings(BaseImageParam):
diff --git a/datalab/h5/history.py b/datalab/h5/history.py
new file mode 100644
index 000000000..590c8b61a
--- /dev/null
+++ b/datalab/h5/history.py
@@ -0,0 +1,330 @@
+# 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.
+
+ A pristine workspace directly restores the saved signal/image objects and
+ history sessions. In a workspace already in use, objects are restored into
+ new groups and one imported session is appended for each source session.
+
+ 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 already in use: 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:
+ # Pristine workspace: 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 into new groups and one new session per source history session.
+
+ 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 action-to-outputs mapping and inverse output lookup. Legacy
+ # actions/files without ``output_uuids`` 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..ba7c1035f
--- /dev/null
+++ b/datalab/history/action.py
@@ -0,0 +1,874 @@
+# 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,
+ List,
+ Optional,
+ TypeVar,
+ overload,
+)
+from uuid import uuid4
+
+import sigima.proc.image
+import sigima.proc.signal
+from guidata.dataset.datatypes import DataSet, DataSetGroup
+
+from datalab.config import _
+from datalab.env import execenv
+from datalab.gui import ObjItf
+from datalab.history.core import (
+ HISTORY_ACTION_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``,
+ ``historypanel``, ``signalprocessor``, or ``imageprocessor``).
+
+ 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"
+ # Mutation actions describe in-place modifications of existing data objects
+ # (no new objects created), e.g. ROI assignment/removal.
+ KIND_MUTATION = "mutation"
+ # Mutation keys currently supported by ``replay_mutation``.
+ SUPPORTED_MUTATION_KEYS: frozenset[str] = frozenset({"roi"})
+
+ FUNC_EDIT_MODE = "edit" # Name of the function parameter to enable edit mode
+ # Object-creation actions skipped during non-persistent (output-suppressed)
+ # replay so the panel object count stays stable.
+ UI_CREATION_METHODS: frozenset[str] = frozenset({"new_object"})
+ # UI methods that (re)load objects from disk into the workspace.
+ UI_LOAD_METHODS: frozenset[str] = frozenset(
+ {"load_from_files", "load_from_directory"}
+ )
+ # 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"}
+ )
+ # UI methods that write files on disk. Replaying them silently would
+ # overwrite user files, so the replay engine asks for confirmation.
+ FILE_OUTPUT_METHODS: frozenset[str] = frozenset(
+ {"save_to_h5_file", "save_to_files", "save_to_directory"}
+ )
+
+ @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
+ # Mutation-only descriptors (``kind == KIND_MUTATION``):
+ mutation_key: str | None = None
+ target_uuids: list[str] | 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")
+ mutation_key = DescriptorField[Optional[str]]("mutation_key")
+ target_uuids = DescriptorField[Optional[List[str]]]("target_uuids")
+
+ 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,
+ # --- mutation-only ---------------------------------------------------
+ mutation_key: str | None = None,
+ target_uuids: list[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,
+ mutation_key=mutation_key,
+ target_uuids=target_uuids,
+ )
+ # 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. One action may have
+ # multiple outputs; the history runtime also maintains an inverse output
+ # lookup. Populated after output-producing compute or UI actions via
+ # :meth:`HistoryPanel.register_action_outputs`. Empty for ``1_to_0``
+ # patterns, UI actions without new objects, and legacy actions/files
+ # lacking output information (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.
+
+ # Analysis effects manifest, keyed by source object UUID, values are
+ # ``AnalysisEffects.to_dict()`` payloads. Persisted as a JSON string.
+ # Only populated for 1_to_0 compute actions, None otherwise.
+ self.effects: dict[str, dict] | None = None
+ # 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 the pre-edit values remain available after a
+ # save/reload cycle while Edit mode is active. Cleared by
+ # ``discard_snapshot`` (definitive commit when toggling Edit mode off)
+ # or ``restore_kwargs`` during parameter rollback.
+ 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,
+ mutation_key=self.mutation_key,
+ target_uuids=list(self.target_uuids) if self.target_uuids else None,
+ 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)
+ new_action.effects = copy_history_value(self.effects)
+ # 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. This covers
+ creation actions targeting a data panel and legacy actions targeting a
+ panel processor.
+ """
+ if self.panel_str:
+ return self.panel_str
+ return {
+ "signalpanel": "signal",
+ "signalprocessor": "signal",
+ "imagepanel": "image",
+ "imageprocessor": "image",
+ }.get(self.target, "")
+
+ def copy_with_uuid_remap(
+ self, uuid_remap: dict[str, dict[str, str]]
+ ) -> HistoryAction:
+ """Return a copy with supported object UUID references rewritten.
+
+ State selections and metadata, ``obj2_uuids``, and ``output_uuids`` are
+ remapped. Other UI keyword arguments are preserved.
+
+ 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 supported UUID
+ references remapped.
+ """
+ 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]
+ # Rewrite target_uuids — mutated objects live in the target panel.
+ if new_action.target_uuids:
+ pstr = new_action.effective_panel_str()
+ pmap = uuid_remap.get(pstr, {})
+ new_action.target_uuids = [pmap.get(u, u) for u in new_action.target_uuids]
+ # Rewrite effects keys — they reference source objects in the target panel.
+ if new_action.effects:
+ pstr = new_action.effective_panel_str()
+ pmap = uuid_remap.get(pstr, {})
+ new_action.effects = {
+ pmap.get(u, u): payload for u, payload in new_action.effects.items()
+ }
+ return new_action
+
+ @property
+ def title(self) -> str:
+ """Return object title"""
+ return self.__title
+
+ @title.setter
+ def title(self, value: str) -> None:
+ """Set object title"""
+ self.__title = value or ""
+
+ # ------------------------------------------------------------------
+ # 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 = ""
+ for param in self.__iter_param_kwargs():
+ if desc:
+ desc += os.linesep
+ desc += str(param)
+ 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 ""
+ if func is None:
+ 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 __roi_exclusions(self) -> set[str] | None:
+ """Return the target UUIDs to exclude from ROI comparison, or None.
+
+ Mutation states are captured after the mutation was applied, so the
+ targets' ROI signatures cannot be expected to match at replay time.
+ """
+ if self.kind == self.KIND_MUTATION:
+ return set(self.target_uuids or [])
+ return None
+
+ def is_current_state_compatible(self, mainwindow: DLMainWindow) -> bool:
+ """Check if the current workspace state is compatible with the saved state.
+
+ Mutation actions exclude their own targets from the ROI signature
+ comparison (the recorded state contains the post-mutation ROI).
+ """
+ return self.state.is_current_state_compatible(
+ mainwindow, ignore_roi_uuids=self.__roi_exclusions()
+ )
+
+ def restore(self, mainwindow: DLMainWindow) -> None:
+ """Restore the associated workspace state."""
+ self.state.restore(mainwindow, ignore_roi_uuids=self.__roi_exclusions())
+
+ # ------------------------------------------------------------------
+ # 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_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 replay(
+ self,
+ mainwindow: DLMainWindow,
+ restore_selection: bool,
+ edit: bool,
+ ) -> None:
+ """Replay a UI-kind or mutation-kind action.
+
+ Compute-kind actions are recomputed in place by the History panel
+ engine (see :mod:`datalab.gui.panel.history.recompute`) and must never
+ reach this method.
+
+ Args:
+ mainwindow: DataLab's main window
+ restore_selection: True to restore the captured workspace selection
+ before replaying the action.
+ edit: If True, request a parameter dialog for supported actions with
+ editable parameters. If False, use the captured parameters.
+
+ Raises:
+ NotImplementedError: If called on a compute-kind action.
+ """
+ if self.kind == self.KIND_COMPUTE:
+ raise NotImplementedError(
+ "Compute actions are recomputed in place and cannot be replayed."
+ )
+ # Suppress history capture during replay to avoid recording
+ # synthetic entries when the target 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:
+ if restore_selection:
+ self.restore(mainwindow)
+ if self.kind == self.KIND_MUTATION:
+ self.replay_mutation(mainwindow, edit=edit)
+ else:
+ self.replay_ui(mainwindow, edit)
+
+ def replay_mutation(
+ self, mainwindow: DLMainWindow, edit: bool = False, refresh: bool = True
+ ) -> list[str]:
+ """Replay a mutation-kind action: re-apply the in-place modification.
+
+ Only ``mutation_key == "roi"`` is supported: the ROI payload (or None
+ for a deletion) is re-applied to each target object still present in
+ the data panel's object model.
+
+ Args:
+ mainwindow: DataLab's main window
+ edit: If True (and not in unattended mode), open the ROI parameter
+ dialog before applying so the recorded payload can be modified.
+ Deletion payloads (None) have nothing to edit and are applied
+ directly. If the dialog is cancelled, the recorded payload is
+ applied as-is.
+ refresh: If True (default), refresh the panel selection and plot
+ after applying the mutation. The cascade engine passes False as
+ it refreshes each target itself.
+
+ Returns:
+ UUIDs of the target objects that were mutated (empty list when
+ the mutation could not be applied to any object).
+ """
+ if self.mutation_key not in self.SUPPORTED_MUTATION_KEYS:
+ _logger.warning(
+ "Skipping mutation replay: unsupported mutation key %r",
+ self.mutation_key,
+ )
+ return []
+ panel_str = self.effective_panel_str()
+ if panel_str == "signal":
+ panel_data = mainwindow.signalpanel
+ elif panel_str == "image":
+ panel_data = mainwindow.imagepanel
+ else:
+ _logger.warning("Skipping mutation replay: unknown panel %r", panel_str)
+ return []
+ # A missing "payload" kwarg means None (ROI deletion): encode_kwargs
+ # skips None values, so deletion payloads are simply not persisted.
+ payload = self.kwargs.get("payload")
+ targets = [
+ uuid
+ for uuid in self.target_uuids or []
+ if panel_data.objmodel.has_uuid(uuid)
+ ]
+ if not targets:
+ return []
+ if edit and payload is not None and not execenv.unattended:
+ # Edit mode: let the user adjust the ROI payload before applying.
+ obj = panel_data.objmodel[targets[0]]
+ params = payload.to_params(obj)
+ group = DataSetGroup(params, title=_("Regions of Interest"))
+ if group.edit(parent=mainwindow):
+ payload = payload.__class__.from_params(obj, params)
+ self.snapshot_kwargs()
+ self.kwargs["payload"] = payload
+ for uuid in targets:
+ obj = panel_data.objmodel[uuid]
+ obj.roi = payload.copy() if payload is not None else None
+ if hasattr(obj, "mark_roi_as_changed"):
+ obj.mark_roi_as_changed()
+ if refresh:
+ panel_data.selection_changed(update_items=True)
+ panel_data.refresh_plot(
+ "selected", update_items=True, only_visible=False, only_existing=True
+ )
+ return targets
+
+ def replay_ui(
+ self,
+ mainwindow: DLMainWindow,
+ edit: bool,
+ ) -> 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)
+ # 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)
+ if self.mutation_key is not None:
+ with writer.group("mutation_key"):
+ writer.write(self.mutation_key)
+ # Like ``output_uuids``: only emit when non-empty.
+ if self.target_uuids:
+ with writer.group("target_uuids"):
+ writer.write(list(self.target_uuids))
+ encoded = encode_kwargs(self.kwargs)
+ if encoded:
+ with writer.group("kwargs"):
+ writer.write_dict(encoded)
+ # Persist the Edit mode baseline across save/reload while edits are pending.
+ # The group is 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))
+ # ``effects``: analysis effects manifest (1_to_0 compute actions only),
+ # stored as a JSON string. Skipped when None.
+ if self.effects is not None:
+ with writer.group("effects"):
+ writer.write(json.dumps(self.effects))
+ 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."""
+ # Legacy files predate per-action schema versions: default to 1
+ self.schema_version = reader.read("schema_version", default=1)
+ 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",
+ "mutation_key",
+ ):
+ if attr in current.attrs or attr in current:
+ with reader.group(attr):
+ setattr(action, attr, reader.read_any())
+ else:
+ setattr(action, attr, None)
+ # ``target_uuids`` is serialized only when non-empty (mutation actions);
+ # legacy files and non-mutation actions leave it as ``None``.
+ if "target_uuids" in current.attrs or "target_uuids" in current:
+ with reader.group("target_uuids"):
+ raw_targets = reader.read_any()
+ action.target_uuids = (
+ [str(u) for u in raw_targets] if raw_targets is not None else None
+ )
+ else:
+ action.target_uuids = 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, plugin provenance and effects manifest."""
+ # ``output_uuids`` is serialized only when non-empty. Outputless actions and
+ # legacy files without this field leave it empty, so 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
+ # ``effects`` is present only for 1_to_0 compute actions written with
+ # action schema v2+; legacy files leave it as ``None``.
+ if "effects" in current.attrs or "effects" in current:
+ with reader.group("effects"):
+ raw_effects = reader.read_any()
+ if raw_effects in (None, ""):
+ action.effects = None
+ else:
+ try:
+ action.effects = json.loads(raw_effects)
+ except (TypeError, ValueError):
+ _logger.warning(
+ "Failed to decode effects for action %s; falling back to None.",
+ action.uuid,
+ )
+ action.effects = None
+ else:
+ action.effects = None
diff --git a/datalab/history/core.py b/datalab/history/core.py
new file mode 100644
index 000000000..c1d0fc437
--- /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 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 _
+
+_TRUSTED_ROI_MODULE_PREFIX = "sigima."
+
+# Schema versions for persisted history sessions/actions.
+# Bump the relevant constant (and add the corresponding optional field
+# handling in serialize/deserialize) when the on-disk layout evolves.
+# Action schema v2: optional ``effects`` manifest for 1_to_0 compute actions,
+# and optional mutation descriptors (``mutation_key``/``target_uuids``).
+HISTORY_SCHEMA_VERSION = 1
+HISTORY_ACTION_SCHEMA_VERSION = 2
+# 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/effects.py b/datalab/history/effects.py
new file mode 100644
index 000000000..3cdec6890
--- /dev/null
+++ b/datalab/history/effects.py
@@ -0,0 +1,155 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Analysis effects manifest: capture metadata/ROI mutations of 1-to-0 analyses."""
+
+from __future__ import annotations
+
+from contextlib import contextmanager
+from dataclasses import dataclass, field
+from typing import Any, Generator
+
+import numpy as np
+from sigima.objects.base import ROI_KEY
+
+# Private bookkeeping keys excluded from the metadata diff (ROI changes are
+# tracked separately through ``roi_modified``, not the metadata diff)
+EXCLUDED_METADATA_KEYS = frozenset({"__uuid", "__number", ROI_KEY})
+
+
+@dataclass
+class AnalysisEffects:
+ """Manifest of the side effects produced by a 1-to-0 analysis on one object.
+
+ Attributes:
+ metadata_added: Metadata keys created by the analysis.
+ metadata_replaced: Pre-existing metadata keys whose value changed.
+ roi_modified: True when the analysis created or changed the object's ROI.
+ """
+
+ metadata_added: list[str] = field(default_factory=list)
+ metadata_replaced: list[str] = field(default_factory=list)
+ roi_modified: bool = False
+
+ def to_dict(self) -> dict[str, Any]:
+ """Return a JSON-safe dictionary representation.
+
+ Returns:
+ Dictionary suitable for ``json.dumps`` round-trip.
+ """
+ return {
+ "metadata_added": list(self.metadata_added),
+ "metadata_replaced": list(self.metadata_replaced),
+ "roi_modified": bool(self.roi_modified),
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> AnalysisEffects:
+ """Build an instance from a dictionary produced by :meth:`to_dict`.
+
+ Args:
+ data: Dictionary payload (missing fields fall back to defaults).
+
+ Returns:
+ New :class:`AnalysisEffects` instance.
+ """
+ return cls(
+ metadata_added=list(data.get("metadata_added", [])),
+ metadata_replaced=list(data.get("metadata_replaced", [])),
+ roi_modified=bool(data.get("roi_modified", False)),
+ )
+
+
+def safe_equal(value1: Any, value2: Any) -> bool:
+ """Return True if both values compare equal, tolerating numpy arrays.
+
+ Numpy arrays are compared with :func:`numpy.array_equal`. Any comparison
+ failure is treated as "changed" (returns False).
+
+ Args:
+ value1: First value.
+ value2: Second value.
+
+ Returns:
+ True if values are considered equal.
+ """
+ if isinstance(value1, np.ndarray) or isinstance(value2, np.ndarray):
+ try:
+ return bool(np.array_equal(value1, value2))
+ except (TypeError, ValueError):
+ return False
+ try:
+ return bool(value1 == value2)
+ except Exception: # pylint: disable=broad-except
+ return False
+
+
+def merge_effects(
+ previous: AnalysisEffects | None, new: AnalysisEffects
+) -> AnalysisEffects:
+ """Merge a freshly captured manifest into the previous one.
+
+ Keys produced on the first run stay in ``metadata_added`` even though a
+ recompute observes them as replaced (the analysis owns them for their
+ whole lifetime). ``roi_modified`` is sticky: once an execution touched
+ the ROI, the merged manifest keeps the flag. Output lists are sorted for
+ deterministic ordering.
+
+ Args:
+ previous: Manifest from earlier executions, or None on first merge.
+ new: Manifest captured during the latest execution.
+
+ Returns:
+ Merged :class:`AnalysisEffects` instance.
+ """
+ if previous is None:
+ return AnalysisEffects(
+ metadata_added=sorted(new.metadata_added),
+ metadata_replaced=sorted(new.metadata_replaced),
+ roi_modified=new.roi_modified,
+ )
+ added = set(previous.metadata_added) | set(new.metadata_added)
+ replaced = (set(previous.metadata_replaced) | set(new.metadata_replaced)) - added
+ return AnalysisEffects(
+ metadata_added=sorted(added),
+ metadata_replaced=sorted(replaced),
+ roi_modified=previous.roi_modified or new.roi_modified,
+ )
+
+
+@contextmanager
+def capture_effects(obj: Any) -> Generator[AnalysisEffects, None, None]:
+ """Capture metadata and ROI mutations applied to an object.
+
+ Snapshots the object's metadata keys/values and ROI before yielding, then
+ fills the yielded :class:`AnalysisEffects` instance on exit. Private
+ bookkeeping keys (``__uuid``, ``__number``) and the ROI metadata key are
+ excluded from the diff.
+
+ Args:
+ obj: Signal or image object whose ``metadata`` and ``roi`` are watched.
+
+ Yields:
+ Mutable :class:`AnalysisEffects` instance, filled on exit.
+ """
+ before = {
+ key: value
+ for key, value in obj.metadata.items()
+ if key not in EXCLUDED_METADATA_KEYS
+ }
+ roi_before = obj.roi.copy() if obj.roi is not None else None
+ effects = AnalysisEffects()
+ try:
+ yield effects
+ finally:
+ after = {
+ key: value
+ for key, value in obj.metadata.items()
+ if key not in EXCLUDED_METADATA_KEYS
+ }
+ effects.metadata_added = sorted(set(after) - set(before))
+ effects.metadata_replaced = sorted(
+ key
+ for key in set(before) & set(after)
+ if not safe_equal(before[key], after[key])
+ )
+ effects.roi_modified = not safe_equal(roi_before, obj.roi)
diff --git a/datalab/history/session.py b/datalab/history/session.py
new file mode 100644
index 000000000..1a7e624dc
--- /dev/null
+++ b/datalab/history/session.py
@@ -0,0 +1,134 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""HistorySession: ordered list of HistoryAction."""
+
+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
+
+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 groups, in chronological order, the actions forming a
+ processing chain. Compute actions are recomputed in place by the History
+ panel recompute engine (:meth:`HistoryAction.replay` raises
+ ``NotImplementedError`` for the compute kind); UI and mutation actions
+ are replayed through :meth:`HistoryAction.replay`. The 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 | None = None, number: int = 0) -> None:
+ """Create a new history session"""
+ self.title = _("Processing") if title is None else title
+ 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 with supported UUID references 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 supported object references
+ 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) -> bool:
+ """Check if the current workspace state is compatible with the saved state
+
+ Args:
+ mainwindow: DataLab's main window
+
+ 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)
+ 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 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)
diff --git a/datalab/history/workspace_state.py b/datalab/history/workspace_state.py
new file mode 100644
index 000000000..277d9a318
--- /dev/null
+++ b/datalab/history/workspace_state.py
@@ -0,0 +1,318 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Workspace state snapshot captured at history action time."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from copy import deepcopy
+from typing import TYPE_CHECKING, Any
+
+from datalab.history.core import numpy_to_json_safe
+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). Selections
+ remain separate from structured metadata captured for every object in each
+ data panel. Informative shape and title lists cover the selected objects.
+ """
+
+ 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 all objects, keyed by panel name and UUID.
+ # Compatibility checks use entries for selected objects. 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_roi_signature(obj: Any) -> str | None:
+ """Return a short stable hash of the object's ROI, or None.
+
+ Args:
+ obj: Signal or image object (any object exposing a ``roi``
+ attribute with a ``to_dict()`` method).
+
+ Returns:
+ 16-character hex digest of the JSON-encoded ROI, or ``None``
+ when the object has no ROI or its encoding fails.
+ """
+ roi = getattr(obj, "roi", None)
+ if roi is None:
+ return None
+ try:
+ payload = numpy_to_json_safe(roi.to_dict())
+ serialized = json.dumps(payload, sort_keys=True)
+ except (AttributeError, TypeError, ValueError):
+ # Defensive: ROI classes evolve; omit signature on failure.
+ return None
+ return hashlib.sha1(serialized.encode()).hexdigest()[:16]
+
+ @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))
+ metadata: dict[str, Any] = {"shape": shape, "ndim": int(ndim)}
+ roi_signature = WorkspaceState.get_roi_signature(obj)
+ if roi_signature is not None:
+ metadata["roi"] = roi_signature
+ return metadata
+
+ @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))
+ normalized: dict[str, Any] = {"shape": shape, "ndim": int(ndim)}
+ roi = metadata.get("roi")
+ if roi is not None:
+ normalized["roi"] = str(roi)
+ return normalized
+
+ # 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(
+ self,
+ mainwindow: DLMainWindow,
+ ignore_roi_uuids: set[str] | None = None,
+ ) -> 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,
+ dimensions and ROI signature (when recorded) must also match the saved
+ signature. Histories without this metadata fall back to legacy
+ UUID-existence validation; metadata recorded before ROI signatures
+ existed skips the ROI comparison.
+
+ Args:
+ mainwindow: DataLab's main window
+ ignore_roi_uuids: UUIDs whose ROI signature must be excluded from
+ the comparison (both saved and current sides). Used by mutation
+ actions whose state was captured after the mutation was applied:
+ the target's ROI legitimately differs at replay time.
+
+ 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 ignore_roi_uuids and uuid in ignore_roi_uuids:
+ # Mutation target: the ROI is precisely what the
+ # action changes, exclude it from both sides.
+ current.pop("roi", None)
+ saved.pop("roi", None)
+ elif "roi" not in saved:
+ # Legacy tolerance: states recorded before the ROI
+ # signature existed must not flag incompatibility.
+ current.pop("roi", None)
+ if saved and current != saved:
+ return False
+ return True
+
+ def restore(
+ self, mainwindow: DLMainWindow, ignore_roi_uuids: set[str] | None = None
+ ) -> None:
+ """Restore the workspace state by selecting the recorded UUIDs.
+
+ Args:
+ mainwindow: DataLab's main window
+ ignore_roi_uuids: UUIDs whose ROI signature is excluded from the
+ compatibility check (see :meth:`is_current_state_compatible`).
+
+ Raises:
+ ValueError: If a saved UUID no longer exists in its panel or its
+ saved metadata (shape/dimensions) is incompatible.
+ """
+ if not self.selection:
+ return
+ if not self.is_current_state_compatible(
+ mainwindow, ignore_roi_uuids=ignore_roi_uuids
+ ):
+ 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 8e485f3f0..8c978ed77 100644
--- a/datalab/locale/fr/LC_MESSAGES/datalab.po
+++ b/datalab/locale/fr/LC_MESSAGES/datalab.po
@@ -360,12 +360,8 @@ msgid "Recompute"
msgstr "Retraiter"
#, python-format
-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 "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"
@@ -378,6 +374,12 @@ msgstr "Sélectionner le ou les objets source utilisés pour créer l'objet %s s
msgid "Edit title of selected %s or group"
msgstr "Modifier le titre de l'objet %s ou du groupe sélectionné"
+msgid "Reset to computed title"
+msgstr "Réinitialiser au titre calculé"
+
+msgid "Restore the title generated by the processing operation"
+msgstr "Restaurer le titre généré par l'opération de traitement"
+
msgid "New group..."
msgstr "Nouveau groupe..."
@@ -944,6 +946,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"
@@ -957,38 +968,6 @@ 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 "No compute actions to export."
-msgstr "Pas d'actions de calcul à exporter."
-
-#, python-format
-msgid "Macro script copied to clipboard (%d actions)."
-msgstr "Script de macro copié dans le presse-papier (%d actions)."
-
-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."
-msgstr ""
-"Êtes-vous sûr de vouloir supprimer le(s) groupe(s) 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."
-
-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 "Remove incompatible"
-msgstr "Supprimer les actions incompatibles"
-
-msgid "All actions are compatible with the current workspace."
-msgstr "Toutes les actions sont compatibles avec l'espace de travail actuel."
-
-#, python-format
-msgid "%d incompatible action(s) will be removed. Continue?"
-msgstr "%d action(s) incompatible(s) seront supprimées. Continuer ?"
-
msgid "Macro simple example"
msgstr "Exemple simple de macro"
@@ -1048,7 +1027,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
@@ -1079,18 +1058,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"
@@ -1353,13 +1323,18 @@ 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"
+msgid "deleted"
+msgstr "supprimé"
+
+msgid "Deleted source objects"
+msgstr "Objets source supprimés"
+
msgid "Drag files here to open"
msgstr "Déposer des fichiers ici pour les ouvrir"
@@ -1390,6 +1365,12 @@ msgstr "Propriétés"
msgid "Parameters for function `%s`"
msgstr "Paramètres pour la fonction `%s`"
+msgid "Created: historical peak parameters"
+msgstr "Créé : paramètres de pic historiques"
+
+msgid "Created: invalid creation parameters"
+msgstr "Créé : paramètres de création invalides"
+
msgid "Created"
msgstr "Créé"
@@ -1408,57 +1389,45 @@ 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 "This object uses historical area-based peak parameters. Convert them to signed peak height before editing."
+msgstr "Cet objet utilise des paramètres de pic historiques fondés sur l'aire. Convertissez-les en hauteur de pic signée avant de les modifier."
-msgid "Image was modified in-place."
-msgstr "L'image a été modifiée sur place."
+msgid "Convert historical parameters"
+msgstr "Convertir les paramètres historiques"
-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."
+msgid "Creation parameters cannot be edited because their metadata is invalid or was written by a newer DataLab version."
+msgstr "Les paramètres de création ne peuvent pas être modifiés, car leurs métadonnées sont invalides ou ont été écrites par une version plus récente de DataLab."
#, python-format
msgid ""
-"Failed to recreate object with new parameters:\n"
+"Failed to convert historical creation parameters:\n"
"%s"
msgstr ""
-"Échec de la recréation de l'objet avec les nouveaux paramètres :\n"
+"Échec de la conversion des paramètres de création historiques :\n"
"%s"
-msgid "Processing Parameters"
-msgstr "Paramètres de traitement"
-
-msgid "No processing object available."
-msgstr "Aucun objet de traitement disponible."
-
-msgid "Processing metadata is incomplete."
-msgstr "Les métadonnées de traitement sont incomplètes."
-
-msgid "Processing metadata is incomplete (missing source UUID)."
-msgstr "Les métadonnées de traitement sont incomplètes (UUID source manquant)."
+msgid "Signal was modified in-place."
+msgstr "Le signal a été modifié en place."
-msgid "Source object no longer exists."
-msgstr "L'objet source n'existe plus."
+msgid "Image was modified in-place."
+msgstr "L'image a été modifiée en place."
-msgid "The object that was used to create this processed object has been deleted and cannot be used for reprocessing."
-msgstr "L'objet qui a été utilisé pour créer cet objet traité a été supprimé et ne peut pas être utilisé pour le retraitement."
+msgid "If computation were performed based on this object, they may need to be redone."
+msgstr "Si des calculs ont été effectués à partir de cet objet, il peut être nécessaire de les refaire."
#, python-format
msgid ""
-"Failed to reprocess object:\n"
+"Failed to recreate object with new parameters:\n"
"%s"
msgstr ""
-"Échec du retraitement de l'objet :\n"
+"Échec de la recréation de l'objet avec les nouveaux paramètres :\n"
"%s"
-msgid "Processing was cancelled."
-msgstr "Le traitement a été annulé."
-
-msgid "Signal was reprocessed."
-msgstr "Le signal a été retraité."
+msgid "Processing Parameters"
+msgstr "Paramètres de traitement"
-msgid "Image was reprocessed."
-msgstr "L'image a été retraitée."
+msgid "No processing object available."
+msgstr "Aucun objet de traitement disponible."
msgid "Geometry results"
msgstr "Résultats géométriques"
@@ -1578,31 +1547,30 @@ msgstr "Importer une ROI"
msgid "Export ROI"
msgstr "Exporter une ROI"
-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 "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 "Recomputing analyses"
+msgstr "Recalcul des analyses"
+
msgid "Selected object does not have processing metadata."
msgstr "L'objet sélectionné n'a pas de métadonnées de traitement."
msgid "Selected object does not have source object references."
msgstr "L'objet sélectionné n'a pas de références d'objet source."
+msgid "Source object no longer exists."
+msgstr "L'objet source n'existe plus."
+
msgid "Source objects no longer exist."
msgstr "Les objets source n'existent plus."
@@ -1660,145 +1628,6 @@ msgstr "Annotation ajoutée"
msgid "The label has been added as an annotation. You can edit or remove it using the annotation editing window.
Choosing to ignore this message will prevent it from being displayed again."
msgstr "L'étiquette a été ajoutée comme annotation. Vous pouvez la modifier ou la supprimer en utilisant la fenêtre d'édition des annotations.
Ignorer ce message empêchera son affichage ultérieur."
-#, 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."
-
-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."
-
-msgid "Parameters"
-msgstr "Paramètres"
-
-msgid "History panel"
-msgstr "Panneau d'historique"
-
-msgid "History files"
-msgstr "Fichiers d'historique"
-
-msgid "Edit mode"
-msgstr "Mode édition"
-
-msgid "Record mode"
-msgstr "Mode enregistrement"
-
-msgid "New session"
-msgstr "Nouvelle session"
-
-msgid "Start a new history session"
-msgstr "Démarrer une nouvelle session d'historique"
-
-msgid "Open history file..."
-msgstr "Ouvrir des fichiers HDF5..."
-
-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..."
-
-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é"
-
-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"
-
-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"
-
-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)"
-
-msgid "Replay"
-msgstr "Rejouer"
-
-msgid "Commit edit mode changes?"
-msgstr "Valider les modifications du mode édition ?"
-
-msgid ""
-"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?"
-msgstr ""
-"Vous êtes sur le point de quitter le mode É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"
-"\n"
-"Voulez-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."
-
-#, 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."
-
-#, python-format
-msgid "Recompute failed for action %s: %s"
-msgstr "Le recalcul a échoué pour l'action %s : %s"
-
-#, python-format
-msgid ""
-"Action %(name)s skipped: plugin '%(loc)s' is missing.\n"
-"Required parameter class: %(param)s\n"
-"Reinstall the plugin to re-enable this action."
-msgstr ""
-"Action %(name)s ignorée : le plugin '%(loc)s' est manquant.\n"
-"Classe de paramètre requise : %(param)s\n"
-"Réinstallez le plugin pour réactiver cette action."
-
-#, 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."
-
-#, 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."
-
-#, python-format
-msgid "Action %s: source object(s) were deleted — skipping."
-msgstr "L'action %s : objet(s) source supprimé(s) — saut de l'action."
-
-#, 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."
-
-msgid "Cascade recompute"
-msgstr "Retraiter"
-
-msgid "Some downstream actions could not be recomputed:"
-msgstr "Certaines actions en aval n'ont pas pu être retraitées :"
-
msgid "Recent macros"
msgstr "Macros récentes"
@@ -1806,7 +1635,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 ?"
@@ -1932,18 +1761,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."
@@ -1969,6 +1804,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"
@@ -1990,32 +1834,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."
@@ -2042,8 +1871,37 @@ msgstr ""
msgid "Yes to All"
msgstr "Oui à tout"
-msgid "Recomputing..."
-msgstr "Retraitement en cours..."
+msgid "Processing metadata is incomplete."
+msgstr "Les métadonnées de traitement sont incomplètes."
+
+msgid "Processing metadata is incomplete (missing source UUID)."
+msgstr "Les métadonnées de traitement sont incomplètes (UUID source manquant)."
+
+msgid "The object that was used to create this processed object has been deleted and cannot be used for reprocessing."
+msgstr "L'objet qui a été utilisé pour créer cet objet traité a été supprimé et ne peut pas être utilisé pour le retraitement."
+
+#, python-format
+msgid ""
+"Failed to reprocess object:\n"
+"%s"
+msgstr ""
+"Échec du retraitement de l'objet :\n"
+"%s"
+
+msgid "Processing was cancelled."
+msgstr "Le traitement a été annulé."
+
+msgid "Failed to reprocess object."
+msgstr "Échec du retraitement de l'objet."
+
+msgid "Signal was reprocessed."
+msgstr "Le signal a été retraité."
+
+msgid "Image was reprocessed."
+msgstr "L'image a été retraitée."
+
+msgid "Recomputing..."
+msgstr "Retraitement en cours..."
msgid "Processing object with updated parameters..."
msgstr "Traitement de l'objet avec les paramètres mis à jour..."
@@ -2552,6 +2410,31 @@ msgstr "Ajustement exponentiel par morceaux"
msgid "Sigmoid fit"
msgstr "Ajustement sigmoïde"
+#, python-format
+msgid "%s: fitting curve was computed by an earlier version and cannot be evaluated (please recompute the fit)"
+msgstr "%s : la courbe d'ajustement a été calculée par une version antérieure et ne peut pas être évaluée (veuillez recalculer l'ajustement)"
+
+#, python-format
+msgid "%s: signal does not contain valid fit parameters"
+msgstr "%s : le signal ne contient pas de paramètres d'ajustement valides"
+
+msgid "Cannot evaluate fit"
+msgstr "Impossible d'évaluer l'ajustement"
+
+#, python-format
+msgid ""
+"One or more selected signals have invalid or unsupported fit parameters:\n"
+"%s"
+msgstr ""
+"Un ou plusieurs signaux sélectionnés ont des paramètres d'ajustement invalides ou non pris en charge :\n"
+"%s"
+
+msgid "Convert historical fit parameters"
+msgstr "Convertir les paramètres d'ajustement historiques"
+
+msgid "One or more selected fitting curves use historical area-based peak parameters. Convert them to signed peak height before evaluating?"
+msgstr "Une ou plusieurs courbes d'ajustement sélectionnées utilisent des paramètres de pic historiques fondés sur l'aire. Les convertir en hauteur de pic signée avant l'évaluation ?"
+
msgid "signal to subtract"
msgstr "signal à soustraire"
@@ -2874,21 +2757,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"
@@ -3026,6 +2894,18 @@ msgstr "Afficher automatiquement la boîte de dialogue des résultats après le
msgid "If enabled, the results dialog will be shown automatically after each processing operation producing results.
If disabled, the results dialog will not be shown automatically."
msgstr "Si activé, la boîte de dialogue des résultats sera affichée automatiquement après chaque opération de traitement produisant des résultats.
Si désactivé, la boîte de dialogue des résultats ne sera pas affichée automatiquement."
+msgid "Result titles"
+msgstr "Titres des résultats"
+
+msgid "Source object short identifier"
+msgstr "Identifiant court de l'objet source"
+
+msgid "Source object title"
+msgstr "Titre de l'objet source"
+
+msgid "How source objects are referenced in result titles after a computation (display only):