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):Result titles update automatically when a source object is renamed." +msgstr "Manière dont les objets source sont référencés dans les titres des résultats après un calcul (affichage uniquement) :Les titres des résultats sont mis à jour automatiquement lorsqu'un objet source est renommé." + msgid "Default image visualization settings" msgstr "Réglages d'affichage d'images par défaut" @@ -3364,32 +3244,6 @@ msgstr "C'est la fin de la visite guidée !" msgid "You can show the tour again, or close this dialog box." msgstr "Vous pouvez afficher la visite guidée à nouveau, ou fermer cette boîte de dialogue." -msgid "Save history file" -msgstr "Enregistrer le fichier d'historique" - -msgid "Open history file" -msgstr "Ouvrir le fichier d'historique" - -msgid "Imported" -msgstr "Importer" - -msgid "Replaying compound 'multiple_1_to_1' actions is not supported yet." -msgstr "La relecture des actions composées 'multiple_1_to_1' n'est pas encore prise en charge." - -msgid "Cannot replay 2-to-1 action: source object(s) missing." -msgstr "Impossible de relire l'action 2-à-1 : objet(s) source manquant(s)." - -#, python-format -msgid "Failed to deserialize history DataSet kwarg %r." -msgstr "Echec de la désérialisation de l'argument DataSet de l'historique %r." - -#, python-format -msgid "Failed to deserialize history DataSet-list kwarg %r." -msgstr "Echec de la désérialisation de l'argument DataSet-list de l'historique %r." - -msgid "Session" -msgstr "Session" - msgid "Registered plugins:" msgstr "Plugins enregistrés :" @@ -3456,9 +3310,6 @@ msgstr "Créer une image avec un anneau" msgid "Create image with a grid of gaussian spots" msgstr "Créer une image avec une grille de spots gaussiens" -msgid "New signal" -msgstr "Nouveau signal" - msgid "Host application" msgstr "Application hôte" @@ -3804,24 +3655,6 @@ msgstr "Afficher uniquement les données prises en charge" msgid "Show values" msgstr "Afficher les valeurs" -msgid "Show details" -msgstr "Afficher les détails" - -msgid "Hide details" -msgstr "Masquer les détails" - -msgid "Date and time" -msgstr "Date et heure" - -msgid "Title" -msgstr "Titre" - -msgid "Action is compatible with the current workspace state." -msgstr "L'action est compatible avec l'état actuel de l'espace de travail." - -msgid "Action is not compatible with the current workspace state." -msgstr "L'action n'est pas compatible avec l'état actuel de l'espace de travail." - msgid "Image background selection" msgstr "Sélection de l'arrière-plan de l'image" @@ -3864,6 +3697,9 @@ msgstr "±{n} points" msgid "±{n} rows × ±{n} columns" msgstr "±{n} lignes × ±{n} colonnes" +msgid "This image uses an integer data type, so it cannot contain NaN or infinite values. Replace special values is therefore not applicable." +msgstr "Cette image utilise un type de données entier, elle ne peut donc pas contenir de valeurs NaN ou infinies. Le remplacement des valeurs spéciales n'est donc pas applicable." + msgid "Signal baseline selection" msgstr "Sélection de la ligne de base du signal" @@ -4196,21 +4032,6 @@ msgstr "Merci de sélectionner le fichier à importer." msgid "Example Wizard" msgstr "Assistant exemple" -msgid "Signal" -msgstr "Signal" - -msgid "Shape" -msgstr "Forme" - -msgid "Image" -msgstr "Image" - -msgid "Dimensions" -msgstr "Dimensions" - -msgid "This image uses an integer data type, so it cannot contain NaN or infinite values. Replace special values is therefore not applicable." -msgstr "Cette image utilise un type de données entier, elle ne peut donc pas contenir de valeurs NaN ou infinies. Le remplacement des valeurs spéciales n'est donc pas applicable." - msgid "Minimum value" msgstr "Valeur minimum" @@ -4259,43 +4080,413 @@ msgstr "Aucun contour n'a été trouvé pour la plage de niveaux sélectionnée. msgid "Show contour plot..." msgstr "Afficher le tracé de contours..." -msgid "Created: historical peak parameters" -msgstr "Créé : paramètres de pic historiques" +msgid "A new object was loaded. Start a new history session?" +msgstr "Un nouvel objet a été chargé. Démarrer une nouvelle session d'historique ?" -msgid "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 "A new object was created. Start a new history session?" +msgstr "Un nouvel objet a été créé. Démarrer une nouvelle session d'historique ?" -msgid "Convert historical parameters" -msgstr "Convertir les paramètres historiques" +msgid "New history session" +msgstr "Nouvelle session d'historique" + +msgid "Initial state" +msgstr "État initial" + +msgid "Chain copy" +msgstr "Copie de chaîne" + +msgid "" +"Do you really want to delete the selected items?\n" +"\n" +"Note: deleting an intermediate action splits its processing chain; downstream steps become an independent chain." +msgstr "" +"Êtes-vous sûr de vouloir supprimer les éléments sélectionnés ?\n" +"\n" +"Remarque : la suppression d'une action intermédiaire divise sa chaîne de traitement ; les étapes en aval deviennent une chaîne indépendante." + +msgid "Do you really want to delete the selected items?" +msgstr "Êtes-vous sûr de vouloir supprimer le(s) groupe(s) sélectionné(s) ?" + +msgid "The deleted action(s) produced object(s) still present in the workspace. Do you want to remove the associated object(s) as well?" +msgstr "La ou les actions supprimées ont produit des objets encore présents dans l'espace de travail. Voulez-vous également supprimer le ou les objets associés ?" + +msgid "Remove incompatible" +msgstr "Supprimer les actions incompatibles" + +msgid "All actions are compatible with the current workspace." +msgstr "Toutes les actions sont compatibles avec l'espace de travail actuel." #, python-format -msgid "Failed to convert historical creation parameters:\n%s" -msgstr "Échec de la conversion des paramètres de création historiques :\n%s" +msgid "%d incompatible action(s) will be removed. Continue?" +msgstr "%d action(s) incompatible(s) seront supprimées. Continuer ?" -msgid "Convert historical fit parameters" -msgstr "Convertir les paramètres d'ajustement historiques" +msgid "History Panel" +msgstr "Historique" -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 ?" +#, python-format +msgid "Open %d HDF5 files" +msgstr "Ouvrir %d fichiers HDF5" -msgid "Created: invalid creation parameters" -msgstr "Créé : paramètres de création invalides" +msgid "Open HDF5 file" +msgstr "Ouvrir un fichier HDF5" -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 "New %s" +msgstr "Créer un nouvel objet %s" + +msgid "Signal was recreated." +msgstr "Le signal a été recréé." + +msgid "Image was recreated." +msgstr "L'image a été recréée." + +msgid "Auto-recompute on edit" +msgstr "Recalcul automatique lors de l'édition" + +msgid "Automatically re-run processing when parameters are modified" +msgstr "Relancer automatiquement le traitement à chaque modification de paramètre" + +msgid "Analysis Parameters" +msgstr "Paramètres d'analyse" + +msgid "Analysis metadata is incomplete." +msgstr "Les métadonnées d'analyse sont incomplètes." #, python-format -msgid "%s: signal does not contain valid fit parameters" -msgstr "%s : le signal ne contient pas de paramètres d'ajustement valides" +msgid "" +"Failed to recompute analysis:\n" +"%s" +msgstr "" +"Échec du recalcul de l'analyse :\n" +"%s" -msgid "Cannot evaluate fit" -msgstr "Impossible d'évaluer l'ajustement" +msgid "Failed to recompute analysis." +msgstr "Échec du recalcul de l'analyse." + +msgid "Analysis was recomputed." +msgstr "L'analyse a été recalculée." + +msgid "Pattern help" +msgstr "Aide sur le motif" + +msgid "Duplicate object or group" +msgstr "Dupliquer l'objet ou le groupe" + +msgid "Remove selected objects" +msgstr "Supprimer les objets sélectionnés" #, 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 "New group \"%s\"" +msgstr "Nouveau groupe \"%s\"" + +msgid "Rename selected object or group" +msgstr "Renommer l'objet ou le groupe sélectionné" #, 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)" +msgid "Set current object title to \"%s\"" +msgstr "Définir le titre de l'objet courant à \"%s\"" + +#, python-format +msgid "Load from directory \"%s\"" +msgstr "Charger depuis le répertoire \"%s\"" + +#, python-format +msgid "Load from %d files" +msgstr "Charger depuis %d fichiers" + +#, python-format +msgid "Load \"%s\"" +msgstr "Charger \"%s\"" + +#, python-format +msgid "Save to %d different files" +msgstr "Enregistrer dans %d fichiers différents" +#, python-format +msgid "Save to \"%s\"" +msgstr "Enregistrer dans \"%s\"" + +msgid "Analysis computation failed." +msgstr "Le calcul de l'analyse a échoué." + +msgid "Failed to recompute analysis" +msgstr "Échec du recalcul de l'analyse" + +msgid "Add object title to plot" +msgstr "Ajouter le titre de l'objet au graphique" + +msgid "Add label with title" +msgstr "Ajouter une étiquette avec le titre" + +#, python-format +msgid "“%s” has dependent operations but no valid source to reconnect to — downstream results are left unchanged." +msgstr "« %s » possède des opérations dépendantes mais aucune source valide à laquelle se reconnecter — les résultats en aval restent inchangés." + +msgid "Some operations could not be reconnected after deletion:" +msgstr "Certaines opérations n'ont pas pu être reconnectées après la suppression :" + +msgid "Commit edit mode changes?" +msgstr "Valider les modifications du mode d'é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 d'édition.\n" +"\n" +"Toutes les modifications de paramètres effectuées lors de cette session seront conservées de façon permanente.\n" +"Cette action est irréversible — la fonction Restaurer ne sera plus disponible.\n" +"\n" +"Souhaitez-vous continuer ?" + +msgid "The current workspace state is not compatible with the action." +msgstr "L'état actuel de l'espace de travail n'est pas compatible avec l'action." + +msgid "Replay file save" +msgstr "Rejouer l'enregistrement de fichier" + +#, python-format +msgid "" +"This action will overwrite the following file(s):\n" +"%s\n" +"\n" +"Replay it?" +msgstr "" +"Cette action écrasera le(s) fichier(s) suivant(s) :\n" +"%s\n" +"\n" +"La rejouer ?" + +#, python-format +msgid "Action %s has no recorded pattern and cannot be replayed." +msgstr "L'action %s n'a pas de motif enregistré et ne peut pas être rejouée." + +msgid "History panel" +msgstr "Panneau d'historique" + +msgid "History files" +msgstr "Fichiers d'historique" + +#, python-format +msgid "Action %s: target panel not found — skipping." +msgstr "Action %s : panneau cible introuvable — ignorée." + +#, python-format +msgid "Action %s: no recorded mutation target — skipping." +msgstr "Action %s : aucune cible de mutation enregistrée — ignorée." + +#, python-format +msgid "Action %s: target object(s) no longer exist — skipping." +msgstr "Action %s : le ou les objets cibles n'existent plus — ignorée." + +#, python-format +msgid "Action %s: %d target object(s) were deleted — applying to the rest." +msgstr "Action %s : %d objet(s) cible(s) ont été supprimés — appliquée aux autres." + +#, python-format +msgid "Action %s uses pattern %r which is not recomputable yet." +msgstr "L'action %s utilise le motif %r qui n'est pas encore recalculable." + +#, python-format +msgid "Action %s could not be fully recomputed." +msgstr "L'action %s n'a pas pu être entièrement recalculée." + +#, python-format +msgid "Recompute failed for action %s: %s" +msgstr "Échec du recalcul 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: no recorded output object — skipping." +msgstr "Action %s : aucun objet de sortie enregistré — ignorée." + +#, python-format +msgid "Action %s: the initial object was deleted and cannot be re-created (no creation parameters)." +msgstr "Action %s : l'objet initial a été supprimé et ne peut pas être recréé (aucun paramètre de création)." + +#, python-format +msgid "Action %s: creation parameters could not be prepared — skipping." +msgstr "Action %s : les paramètres de création n'ont pas pu être préparés — ignorée." + +#, python-format +msgid "Action %s: no recorded source object — skipping." +msgstr "Action %s : aucun objet source enregistré — ignorée." + +#, python-format +msgid "Action %s: no recorded second operand — skipping." +msgstr "Action %s : aucun second opérande enregistré — ignorée." + +#, python-format +msgid "Action %s: source object(s) were deleted — skipping." +msgstr "Action %s : le ou les objets source ont été supprimés — ignorée." + +#, python-format +msgid "Action %s: recompute returned %d output(s), expected %d." +msgstr "Action %s : le recalcul a renvoyé %d sortie(s), %d attendue(s)." + +#, python-format +msgid "Action %s: %d analysed object(s) were deleted — skipping." +msgstr "Action %s : %d objet(s) analysé(s) ont été supprimés — ignorée." + +msgid "Cascade recompute" +msgstr "Recalcul en cascade" + +msgid "Some downstream actions could not be recomputed:" +msgstr "Certaines actions en aval n'ont pas pu être recalculées :" + +msgid "Record mode" +msgstr "Mode d'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 un fichier d'historique..." + +msgid "Open history from a standalone .dlhist file" +msgstr "Ouvrir l'historique depuis un fichier .dlhist autonome" + +msgid "Save history file..." +msgstr "Enregistrer un fichier d'historique..." + +msgid "Save history to a standalone .dlhist file" +msgstr "Enregistrer l'historique dans un fichier .dlhist autonome" + +msgid "Duplicate selected history action/session" +msgstr "Dupliquer l'action ou la session d'historique sélectionnée" + +msgid "Previous step" +msgstr "Étape précédente" + +msgid "Select the previous action in the current session" +msgstr "Sélectionner l'action précédente dans la session courante" + +msgid "Next step" +msgstr "Étape suivante" + +msgid "Select the next action in the current session" +msgstr "Sélectionner l'action suivante dans la session courante" + +msgid "Remove actions incompatible with the current workspace" +msgstr "Supprimer les actions incompatibles avec l'espace de travail courant" + +msgid "Replay" +msgstr "Rejouer" + +msgid "Replay the selection silently (no parameter dialogs)" +msgstr "Rejouer la sélection silencieusement (sans boîtes de dialogue de paramètres)" + +msgid "Step-by-step" +msgstr "Pas à pas" + +msgid "Replay the selection step by step, editing parameters at each step" +msgstr "Rejouer la sélection pas à pas, en modifiant les paramètres à chaque étape" + +msgid "New image" +msgstr "Nouvelle image" + +msgid "New signal" +msgstr "Nouveau signal" + +#, python-format +msgid "Remove ROI '%s'" +msgstr "Supprimer la ROI '%s'" + +msgid "History sessions" +msgstr "Sessions d'historique" + +msgid "New object or file" +msgstr "Nouvel objet ou fichier" + +msgid "Ask" +msgstr "Demander" + +msgid "Always start a new session" +msgstr "Toujours démarrer une nouvelle session" + +msgid "Continue in the current session" +msgstr "Continuer dans la session courante" + +msgid "Behavior when a new object or file is added to a populated history session." +msgstr "Comportement lorsqu'un nouvel objet ou fichier est ajouté à une session d'historique non vide." + +msgid "Plugin-created object" +msgstr "Objet créé par un plugin" + +msgid "Behavior when a plugin adds an object to a populated history session." +msgstr "Comportement lorsqu'un plugin ajoute un objet à une session d'historique non vide." + +msgid "Plugin multi-load" +msgstr "Chargement multiple par un plugin" + +msgid "Ask once" +msgstr "Demander une seule fois" + +msgid "Start a new session" +msgstr "Démarrer une nouvelle session" + +msgid "Behavior when a plugin loads multiple objects into a populated history session." +msgstr "Comportement lorsqu'un plugin charge plusieurs objets dans une session d'historique non vide." + +msgid "Save history file" +msgstr "Enregistrer un fichier d'historique" + +msgid "Open history file" +msgstr "Ouvrir un fichier d'historique" + +msgid "Imported" +msgstr "Importé" + +#, python-format +msgid "Failed to deserialize history DataSet kwarg %r." +msgstr "Échec de la désérialisation de l'argument DataSet de l'historique %r." + +#, python-format +msgid "Failed to deserialize history DataSet-list kwarg %r." +msgstr "Échec de la désérialisation de l'argument DataSet-list de l'historique %r." + +msgid "Show details" +msgstr "Afficher les détails" + +msgid "Hide details" +msgstr "Masquer les détails" + +msgid "Date and time" +msgstr "Date et heure" + +msgid "Action is compatible with the current workspace state." +msgstr "L'action est compatible avec l'état actuel de l'espace de travail." + +msgid "Action is not compatible with the current workspace state." +msgstr "L'action n'est pas compatible avec l'état actuel de l'espace de travail." + +msgid "Active recording session." +msgstr "Session d'enregistrement active." + +msgid "Signal" +msgstr "Signal" + +msgid "Shape" +msgstr "Forme" + +msgid "Image" +msgstr "Image" + +msgid "Dimensions" +msgstr "Dimensions" diff --git a/datalab/objectmodel.py b/datalab/objectmodel.py index 5976c3ae2..335af0d22 100644 --- a/datalab/objectmodel.py +++ b/datalab/objectmodel.py @@ -62,10 +62,20 @@ def set_number(obj: SignalObj | ImageObj | ObjectGroup, number: int) -> None: def get_uuid(obj: SignalObj | ImageObj | ObjectGroup) -> str: - """Get object UUID""" + """Get object UUID. + + For data objects (signals/images), the UUID is stored in metadata under + the ``__uuid`` option. It is materialized on first access (via + :func:`set_uuid`) so that the returned value is stable across calls and + survives serialization. + """ if isinstance(obj, ObjectGroup): return obj.uuid - return obj.get_metadata_option("uuid", str(uuid4())) + uuid = obj.metadata.get("__uuid") + if not uuid: + set_uuid(obj) + uuid = obj.metadata["__uuid"] + return uuid def set_uuid(obj: SignalObj | ImageObj | ObjectGroup) -> None: diff --git a/datalab/plugins.py b/datalab/plugins.py index a1750bdfb..40ab20e32 100644 --- a/datalab/plugins.py +++ b/datalab/plugins.py @@ -346,7 +346,7 @@ def register(self, main: main.DLMainWindow) -> None: PluginRegistry.register_plugin(self) self._is_registered = True self.main = main - self.proxy = LocalProxy(main) + self.proxy = LocalProxy(main, input_source="plugin") self.register_hooks() def unregister(self): diff --git a/datalab/plugins/datalab_testdata.py b/datalab/plugins/datalab_testdata.py index a8ae11c19..951d2416a 100644 --- a/datalab/plugins/datalab_testdata.py +++ b/datalab/plugins/datalab_testdata.py @@ -42,18 +42,24 @@ def load_test_objs( Args: registry_class: Registry class (SignalIORegistry or ImageIORegistry) title: Progress bar title - - Returns: - List of (filename, object) tuples """ + if issubclass(registry_class, SignalIORegistry): + panel_str = "signal" + panel = self.signalpanel + elif issubclass(registry_class, ImageIORegistry): + panel_str = "image" + panel = self.imagepanel + else: + raise TypeError(f"Unsupported I/O registry class: {registry_class!r}") test_objs = list(helpers.read_test_objects(registry_class)) - with create_progress_bar(self.signalpanel, title, max_=len(test_objs)) as prog: - for i_obj, (_fname, obj) in enumerate(test_objs): - prog.setValue(i_obj + 1) - if prog.wasCanceled(): - break - if obj is not None: - self.proxy.add_object(obj) + with self.proxy.multiload_session(panel_str): + with create_progress_bar(panel, title, max_=len(test_objs)) as prog: + for i_obj, (_fname, obj) in enumerate(test_objs): + prog.setValue(i_obj + 1) + if prog.wasCanceled(): + break + if obj is not None: + self.proxy.add_object(obj) # Signal processing features ------------------------------------------------ def create_paracetamol_signal(self) -> None: diff --git a/datalab/tests/__init__.py b/datalab/tests/__init__.py index 70625aac9..12dbdbe6c 100644 --- a/datalab/tests/__init__.py +++ b/datalab/tests/__init__.py @@ -58,6 +58,7 @@ def datalab_test_app_context( save: bool = False, console: bool | None = None, exec_loop: bool = True, + history: bool = False, ) -> Generator[DLMainWindow, None, None]: """Context manager handling DataLab mainwindow creation and Qt event loop with optional HDF5 file save and other options for testing purposes @@ -68,6 +69,7 @@ def datalab_test_app_context( save: whether to save HDF5 file (default: False) console: whether to show console (default: None) exec_loop: whether to execute Qt event loop (default: True) + history: whether to enable and show history tracking (default: False) """ if size is None: size = 1200, 700 @@ -75,6 +77,10 @@ def datalab_test_app_context( win: DLMainWindow | None = None try: win = DLMainWindow(console=console) + if not history: + win.historypanel.set_tracking_enabled(False) + win.historypanel.setEnabled(False) + win.docks[win.historypanel].hide() if maximized: win.showMaximized() else: diff --git a/datalab/tests/features/common/adapters_registry_unit_test.py b/datalab/tests/features/common/adapters_registry_unit_test.py new file mode 100644 index 000000000..8fb90a446 --- /dev/null +++ b/datalab/tests/features/common/adapters_registry_unit_test.py @@ -0,0 +1,135 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +Adapters registry unit test +--------------------------- + +Test the generic result-adapter resolver of :mod:`datalab.adapters_metadata`: +resolution of registered result typologies, subclass tolerance, error on +unsupported types and the public registration hook +(:func:`datalab.adapters_metadata.register_result_adapter`). +""" + +# pylint: disable=invalid-name # Allows short reference names like x, y, ... + +from __future__ import annotations + +import numpy as np +import pytest +from sigima.objects.scalar import GeometryResult, KindShape, TableResult + +from datalab.adapters_metadata import ( + GeometryAdapter, + TableAdapter, + create_adapter, + register_result_adapter, +) +from datalab.adapters_metadata.common import _ADAPTER_REGISTRY +from datalab.env import execenv + + +def make_geometry_result(cls: type[GeometryResult] = GeometryResult) -> GeometryResult: + """Build a minimal segment geometry result. + + Args: + cls: Result class to instantiate (allows building subclasses). + + Returns: + Geometry result instance. + """ + return cls( + title="fwhm", + func_name="fwhm", + kind=KindShape.SEGMENT, + coords=np.array([[3.5, 0.6, 6.5, 0.6]]), + roi_indices=None, + attrs={}, + ) + + +def make_table_result() -> TableResult: + """Build a minimal statistics table result.""" + return TableResult( + title="stats", + func_name="stats", + headers=["Mean", "Std"], + data=[[5.0, 1.5]], + roi_indices=None, + attrs={}, + ) + + +def test_create_adapter_resolves_registered_typologies() -> None: + """Resolve GeometryResult and TableResult to their respective adapters.""" + geometry_adapter = create_adapter(make_geometry_result()) + assert type(geometry_adapter) is GeometryAdapter + table_adapter = create_adapter(make_table_result()) + assert type(table_adapter) is TableAdapter + execenv.print("test_create_adapter_resolves_registered_typologies: ✓") + + +def test_create_adapter_rejects_unsupported_type() -> None: + """Raise TypeError naming the unsupported result type.""" + with pytest.raises(TypeError, match="dict"): + create_adapter({"not": "a result"}) + execenv.print("test_create_adapter_rejects_unsupported_type: ✓") + + +def test_create_adapter_falls_back_to_isinstance() -> None: + """Resolve an unregistered subclass through the isinstance fallback.""" + + class CustomGeometryResult(GeometryResult): + """Subclass without a dedicated adapter registration.""" + + adapter = create_adapter(make_geometry_result(CustomGeometryResult)) + assert type(adapter) is GeometryAdapter + execenv.print("test_create_adapter_falls_back_to_isinstance: ✓") + + +def test_register_result_adapter_custom_typology() -> None: + """Register a custom adapter and resolve the custom typology exactly.""" + + class CustomGeometryResult(GeometryResult): + """Custom result typology (e.g. contributed by a plugin).""" + + class CustomAdapter(GeometryAdapter): + """Adapter dedicated to the custom typology.""" + + register_result_adapter(CustomGeometryResult, CustomAdapter) + try: + adapter = create_adapter(make_geometry_result(CustomGeometryResult)) + assert type(adapter) is CustomAdapter + # Base typology resolution is unaffected by the extra registration + assert type(create_adapter(make_geometry_result())) is GeometryAdapter + finally: + _ADAPTER_REGISTRY.pop(CustomGeometryResult, None) + execenv.print("test_register_result_adapter_custom_typology: ✓") + + +def test_create_adapter_fallback_prefers_most_specific_base() -> None: + """Resolve an unregistered sub-subclass to the most specific adapter.""" + + class CustomGeometryResult(GeometryResult): + """Custom result typology with its own registered adapter.""" + + class CustomAdapter(GeometryAdapter): + """Adapter dedicated to the custom typology.""" + + class UnregisteredSubResult(CustomGeometryResult): + """Sub-subclass without a dedicated adapter registration.""" + + register_result_adapter(CustomGeometryResult, CustomAdapter) + try: + adapter = create_adapter(make_geometry_result(UnregisteredSubResult)) + assert type(adapter) is CustomAdapter + finally: + _ADAPTER_REGISTRY.pop(CustomGeometryResult, None) + execenv.print("test_create_adapter_fallback_prefers_most_specific_base: ✓") + + +if __name__ == "__main__": + test_create_adapter_resolves_registered_typologies() + test_create_adapter_rejects_unsupported_type() + test_create_adapter_falls_back_to_isinstance() + test_register_result_adapter_custom_typology() + test_create_adapter_fallback_prefers_most_specific_base() diff --git a/datalab/tests/features/common/analysis_parameters_edit_unit_test.py b/datalab/tests/features/common/analysis_parameters_edit_unit_test.py new file mode 100644 index 000000000..bf93547de --- /dev/null +++ b/datalab/tests/features/common/analysis_parameters_edit_unit_test.py @@ -0,0 +1,84 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +Analysis parameters edit unit test +---------------------------------- + +Test the editable "Analysis" tab of the Object Properties widget. + +This verifies that a 1-to-0 analysis operation (e.g. 2D peak detection) can be +re-run in place with modified parameters through +:meth:`ObjectProp.setup_analysis_tab` / :meth:`ObjectProp.apply_analysis_parameters`. +""" + +# pylint: disable=invalid-name # Allows short reference names like x, y, ... +# guitest: show + +from __future__ import annotations + +import sigima.params as sigima_param +from sigima.tests.data import create_peak_image + +from datalab.config import Conf +from datalab.env import execenv +from datalab.gui.processor.base import extract_analysis_parameters +from datalab.tests import datalab_test_app_context + + +def test_analysis_parameters_edit_image(): + """Test editing and re-running a 1-to-0 analysis via the Analysis tab.""" + with datalab_test_app_context(console=False) as win: + execenv.print("Analysis parameters edit test (image peak detection):") + panel = win.imagepanel + + # Create a multi-peak image guaranteed to yield detections + img = create_peak_image() + panel.add_object(img) + + # Run 2D peak detection: a 1-to-0 analysis with an editable parameter + det_param = sigima_param.Peak2DDetectionParam.create( + create_rois=False, threshold=0.5 + ) + with Conf.proc.show_result_dialog.temp(False): + panel.processor.run_feature("peak_detection", det_param) + + # The analysis parameters must be stored as a single 1-to-0 dataset + proc_params = extract_analysis_parameters(img) + assert proc_params is not None, "Analysis parameters should be stored" + assert proc_params.pattern == "1-to-0" + assert proc_params.param is not None + assert not isinstance(proc_params.param, list) + assert proc_params.param.threshold == 0.5 + execenv.print(" ✓ Analysis parameters stored (threshold=0.5)") + + # Set up the editable Analysis tab + objprop = panel.objprop + assert objprop.setup_analysis_tab(img) is True + assert objprop.analysis_param_editor is not None + execenv.print(" ✓ Analysis tab set up") + + # Modify the threshold and (deliberately) enable ROI creation to verify + # the create_rois guard forces it back to False on apply + objprop.analysis_param_editor.dataset.threshold = 0.8 + objprop.analysis_param_editor.dataset.create_rois = True + + # Apply: re-run the analysis in place with the modified parameters + objprop.apply_analysis_parameters(img) + + # The stored analysis parameters must reflect the new threshold + proc_params2 = extract_analysis_parameters(img) + assert proc_params2 is not None + assert proc_params2.param.threshold == 0.8, "Threshold change must be applied" + execenv.print(" ✓ Analysis re-ran with new threshold (0.8)") + + # ROI guard: create_rois must have been forced to False (no ROI created) + assert proc_params2.param.create_rois is False, ( + "create_rois must be forced to False on re-analysis" + ) + assert not img.roi, "No ROI should be created on re-analysis" + execenv.print(" ✓ ROI creation guard held (create_rois=False)") + + +if __name__ == "__main__": + execenv.unattended = True # Auto-close dialogs and event loops (standalone run) + test_analysis_parameters_edit_image() diff --git a/datalab/tests/features/common/auto_analysis_recompute_unit_test.py b/datalab/tests/features/common/auto_analysis_recompute_unit_test.py index 7b6620e8f..fcb8fdc3d 100644 --- a/datalab/tests/features/common/auto_analysis_recompute_unit_test.py +++ b/datalab/tests/features/common/auto_analysis_recompute_unit_test.py @@ -195,7 +195,7 @@ def counting_compute_1_to_0(*args, **kwargs): def test_analysis_recompute_after_recompute_1_to_1(): """Test on-demand recomputation of analysis after processing parameter changes.""" - with datalab_test_app_context(console=False) as win: + with datalab_test_app_context(console=False, history=True) as win: panel = win.imagepanel # Create a Gaussian image offset from center @@ -245,6 +245,10 @@ def test_analysis_recompute_after_recompute_1_to_1(): editor = panel.objprop.processing_param_editor editor.dataset.angle = 90.0 # Change from 45° to 90° + # In-place recompute happens when the History panel is in edit mode + # (otherwise a new object is created). + win.historypanel.toggle_edit_mode(True) + call_count = [0] original_compute_1_to_0 = panel.processor.compute_1_to_0 diff --git a/datalab/tests/features/common/history_model_unit_test.py b/datalab/tests/features/common/history_model_unit_test.py new file mode 100644 index 000000000..6cb3de4f8 --- /dev/null +++ b/datalab/tests/features/common/history_model_unit_test.py @@ -0,0 +1,1203 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Pure unit contracts for history persistence, copying and recompute.""" + +from __future__ import annotations + +import os +import tempfile +from contextlib import contextmanager, nullcontext +from types import SimpleNamespace +from typing import cast +from unittest.mock import Mock, patch + +import numpy as np +import pytest +from sigima.objects import ( + Gauss2DParam, + ImageObj, + SignalROI, + create_image_from_param, + create_image_roi, + create_signal_roi, +) +from sigima.tests.data import create_paracetamol_signal + +from datalab.gui import historysession_ops as hsess +from datalab.gui import historytools_ops as hops +from datalab.gui.main import DLMainWindow +from datalab.gui.panel.history import chain as hchain +from datalab.gui.panel.history import interactive_replay as hireplay +from datalab.gui.panel.history import recompute as hrec +from datalab.gui.panel.history import runtime as hruntime +from datalab.gui.panel.history.chainmodel import ProcessingChain, UuidCloneRegistry +from datalab.gui.processor.base import ( + BaseProcessor, + FeatureNotFoundError, + ProcessingParameters, + extract_processing_parameters, + insert_processing_parameters, +) +from datalab.h5.native import NativeH5Reader, NativeH5Writer +from datalab.history.action import HistoryAction +from datalab.history.core import ( + HISTORY_ACTION_SCHEMA_VERSION, + HISTORY_SCHEMA_VERSION, + numpy_to_json_safe, +) +from datalab.history.effects import AnalysisEffects, capture_effects, merge_effects +from datalab.history.session import HistorySession +from datalab.history.workspace_state import WorkspaceState +from datalab.objectmodel import get_uuid +from datalab.tests.features.common.history_test_helpers import ( + CascadeObjectModel, + build_history_action, + build_workspace_state, + delete_hdf5_items_by_name, + read_history_sessions, +) + + +class PromptExecution: + """Minimal execution state for input-session prompt tests.""" + + def __init__(self, prompt_allowed: bool = True) -> None: + self.suppress_session_prompt = False + self.prompt_allowed = prompt_allowed + self.prompt_count = 0 + + def start_session_input_prompt(self) -> bool: + """Record that the debounce guard was reached.""" + self.prompt_count += 1 + return self.prompt_allowed + + @contextmanager + def session_prompt_suppressed(self): + """Suppress session prompts while preserving the previous state.""" + previous = self.suppress_session_prompt + self.suppress_session_prompt = True + try: + yield + finally: + self.suppress_session_prompt = previous + + +class PromptNavigation: + """Minimal single active-session registry for prompt tests.""" + + def __init__(self, active_session: HistorySession | None) -> None: + self.active_session = active_session + + def get_active_session(self) -> HistorySession | None: + """Return the single active recording session.""" + return self.active_session + + +class PromptTree: + """Minimal tree recorder for production history routing.""" + + def __init__(self) -> None: + self.rebuilt_session_indices: list[int] = [] + self.rearrange_count = 0 + + def rebuild_session(self, session_index: int) -> None: + """Record the rebuilt session index.""" + self.rebuilt_session_indices.append(session_index) + + def rearrange_tree(self) -> None: + """Record that tree layout was refreshed.""" + self.rearrange_count += 1 + + +class PromptUI: + """Minimal UI state recorder for production history routing.""" + + def __init__(self) -> None: + self.update_count = 0 + + def update_actions_state(self) -> None: + """Record that action states were refreshed.""" + self.update_count += 1 + + +class PromptPanel: + """Minimal history panel for pure input-session prompt tests.""" + + def __init__( + self, + sessions: list[HistorySession] | None = None, + prompt_allowed: bool = True, + ) -> None: + self.record_mode_enabled = True + self.history_sessions = list(sessions or []) + active = self.history_sessions[-1] if self.history_sessions else None + self.navigation = PromptNavigation(active) + self.runtime = SimpleNamespace(execution=PromptExecution(prompt_allowed)) + self.tree = PromptTree() + self.ui = PromptUI() + self.created_sessions: list[HistorySession] = [] + self.prompt_behaviors: list[hsess.SessionBehavior | None] = [] + self.prompt_suppressed_states: list[bool] = [] + self.added_actions: list[HistoryAction] = [] + self.registered_outputs: list[tuple[HistoryAction, list[str]]] = [] + self.compatibility_refresh_count = 0 + self.events: list[str] = [] + + def is_replaying(self) -> bool: + """Return whether history replay is active.""" + return False + + def create_new_session(self) -> HistorySession: + """Create and activate a session without constructing GUI objects.""" + session = HistorySession(number=len(self.history_sessions) + 1) + self.history_sessions.append(session) + self.navigation.active_session = session + self.created_sessions.append(session) + return session + + def maybe_start_session_for_input( + self, + *, + load: bool = False, + behavior: hsess.SessionBehavior | None = None, + ) -> bool: + """Forward to the session operation while recording the evaluation.""" + self.events.append("session_decision") + self.prompt_behaviors.append(behavior) + self.prompt_suppressed_states.append( + self.runtime.execution.suppress_session_prompt + ) + return hsess.maybe_start_session_for_input(self, load=load, behavior=behavior) + + @contextmanager + def session_prompt_suppressed(self): + """Suppress input-session prompts for the context scope.""" + with self.runtime.execution.session_prompt_suppressed(): + yield + + def add_ui_entry( + self, + action_title: str, + target: str, + method_name: str, + save_state: bool = True, + **kwargs, + ) -> HistoryAction | None: + """Forward a UI entry through the production history operation.""" + return hsess.add_ui_entry( + self, action_title, target, method_name, save_state, **kwargs + ) + + def register_action_outputs( + self, action: HistoryAction, output_uuids: list[str] + ) -> None: + """Record output registration performed by the main window.""" + self.registered_outputs.append((action, output_uuids)) + + def add_object(self, action: HistoryAction) -> None: + """Route an action through the production session operation.""" + self.added_actions.append(action) + hsess.add_object(self, action) + + def refresh_compatibility_items(self) -> None: + """Record that compatibility state was refreshed.""" + self.compatibility_refresh_count += 1 + + +def make_prompt_session(panel_str: str, *, populated: bool) -> HistorySession: + """Return a session optionally populated with one panel action.""" + session = HistorySession(number=1) + if populated: + session.add_action(HistoryAction(panel_str=panel_str)) + return session + + +def test_history_session_default_and_explicit_titles() -> None: + """Translate only the default title and preserve explicit titles.""" + with patch("datalab.history.session._", return_value="Traitement") as translate: + default_session = HistorySession(number=7) + explicit_session = HistorySession(title="Acquisition", number=8) + assert default_session.title == "Traitement" + assert default_session.number == 7 + assert explicit_session.title == "Acquisition" + assert explicit_session.number == 8 + translate.assert_called_once_with("Processing") + + +def test_ui_action_description_is_empty_when_callable_is_unresolved() -> None: + """Return no fallback description when UI callable resolution returns None.""" + action = HistoryAction(kind=HistoryAction.KIND_UI) + + with patch.object(action, "resolve_callable", return_value=None): + assert action.description == "" + + +def test_compute_n_to_1_uses_provided_history_title() -> None: + """Pass the localized operation title to the history panel.""" + history_panel = SimpleNamespace( + add_compute_entry_from_pp=Mock(return_value=object()), + capture_outputs=lambda _action: nullcontext(), + ) + processor = SimpleNamespace( + panel=SimpleNamespace( + PANEL_STR_ID="signal", + objview=SimpleNamespace( + get_sel_objects=Mock(return_value=[]), + get_sel_groups=Mock(return_value=[]), + ), + objmodel=object(), + ), + mainwindow=SimpleNamespace(historypanel=history_panel), + _get_plugin_origin_for=Mock(return_value=None), + ) + + def average(_objects: list[object]) -> None: + return None + + with patch( + "datalab.gui.processor.base.create_progress_bar", + return_value=nullcontext(Mock()), + ): + BaseProcessor.compute_n_to_1( + processor, average, title="Moyenne", pairwise=False + ) + + history_panel.add_compute_entry_from_pp.assert_called_once() + assert history_panel.add_compute_entry_from_pp.call_args.args[0] == "Moyenne" + + +def test_image_creation_extends_active_signal_session_when_rejected() -> None: + """Chain an image creation into the single active recording session.""" + signal_session = make_prompt_session("signal", populated=True) + signal_actions = list(signal_session.actions) + panel = PromptPanel([signal_session]) + unattended = SimpleNamespace(unattended=True, accept_dialogs=False) + with ( + patch.object(hsess, "execenv", unattended), + patch.object( + hsess.Conf.proc.history_new_session_behavior, "get", return_value="ask" + ), + ): + action = hsess.add_ui_entry( + panel, + "New image", + target="imagepanel", + method_name="new_object", + save_state=False, + ) + assert action is panel.added_actions[0] + assert action.panel_str == "image" + assert panel.prompt_behaviors == [None] + assert panel.runtime.execution.prompt_count == 1 + assert panel.created_sessions == [] + assert panel.navigation.get_active_session() is signal_session + assert signal_session.actions == signal_actions + [action] + + +def test_empty_active_session_skips_prompt() -> None: + """Reuse an empty active session without reaching the debounce.""" + image_session = make_prompt_session("image", populated=False) + panel = PromptPanel([image_session]) + created = hsess.maybe_start_session_for_input(panel) + assert created is False + assert panel.runtime.execution.prompt_count == 0 + assert panel.created_sessions == [] + + +@pytest.mark.parametrize( + ("behavior", "policy_values", "expected_created"), + ( + (None, ("no", "yes"), (False, True)), + ("yes", ("ask",), (True,)), + ("no", ("ask",), (False,)), + ("invalid", (), None), + ), +) +def test_session_behavior_policy_matrix( + behavior: str | None, + policy_values: tuple[str, ...], + expected_created: tuple[bool, ...] | None, +) -> None: + """Resolve omitted, explicit and invalid session policies without dialogs. + + Omitted behaviors re-read the live general policy on every call, explicit + yes/no policies bypass both debounce and dialog, and invalid policies are + rejected before any side effect. + """ + image_session = make_prompt_session("image", populated=True) + panel = PromptPanel([image_session]) + attended = SimpleNamespace(unattended=False, accept_dialogs=False) + option = hsess.Conf.proc.history_new_session_behavior + with ( + patch.object(hsess, "execenv", attended), + patch.object(hsess.QW, "QMessageBox") as message_box, + patch.object(option, "get", side_effect=list(policy_values)) as get_policy, + ): + if expected_created is None: + with pytest.raises(ValueError, match="Invalid session behavior"): + panel.maybe_start_session_for_input( + behavior=cast(hsess.SessionBehavior, behavior) + ) + created = [] + else: + created = [ + panel.maybe_start_session_for_input( + behavior=cast(hsess.SessionBehavior, behavior) + ) + for _call in policy_values + ] + assert created == list(expected_created or ()) + assert len(panel.created_sessions) == sum(created) + assert panel.runtime.execution.prompt_count == 0 + message_box.question.assert_not_called() + assert get_policy.call_count == (len(policy_values) if behavior is None else 0) + if expected_created is None: + assert panel.history_sessions == [image_session] + + +def test_accepted_prompt_routes_action_to_new_session() -> None: + """Record an image creation in the newly accepted session.""" + image_session = make_prompt_session("image", populated=True) + previous_actions = list(image_session.actions) + panel = PromptPanel([image_session]) + unattended = SimpleNamespace(unattended=True, accept_dialogs=True) + with ( + patch.object(hsess, "execenv", unattended), + patch.object( + hsess.Conf.proc.history_new_session_behavior, "get", return_value="ask" + ), + ): + action = hsess.add_ui_entry( + panel, + "New image", + target="imagepanel", + method_name="new_object", + save_state=False, + ) + new_session = panel.navigation.get_active_session() + assert panel.runtime.execution.prompt_count == 1 + assert panel.created_sessions == [new_session] + assert new_session is panel.history_sessions[-1] + assert new_session.actions == [action] + assert image_session.actions == previous_actions + + +def test_accepted_prompt_replaces_active_session() -> None: + """Make the freshly created session the single active recording session.""" + signal_session = make_prompt_session("signal", populated=True) + panel = PromptPanel([signal_session]) + unattended = SimpleNamespace(unattended=True, accept_dialogs=True) + with patch.object(hsess, "execenv", unattended): + created = hsess.maybe_start_session_for_input(panel, behavior="ask") + assert created is True + assert panel.runtime.execution.prompt_count == 1 + assert panel.created_sessions == [panel.navigation.get_active_session()] + assert panel.navigation.get_active_session() is not signal_session + + +def test_input_prompt_debounce_is_global() -> None: + """Debounce a synchronous prompt burst and keep routing in place. + + Only the first prompt of a synchronous burst opens a dialog; while the + debounce window is pending, further creations are routed to the active + session without starting a new one or prompting again. + """ + signal_session = make_prompt_session("signal", populated=True) + panel = PromptPanel([signal_session]) + panel.mainwindow = None # dialog parent for the patched QMessageBox + execution = hruntime.HistoryExecutionState() + panel.runtime = SimpleNamespace(execution=execution) + callbacks = [] + attended = SimpleNamespace(unattended=False, accept_dialogs=False) + + with ( + patch.object( + hruntime.QC.QTimer, + "singleShot", + side_effect=lambda _delay, callback: callbacks.append(callback), + ), + patch.object(hsess, "execenv", attended), + patch.object(hsess.QW, "QMessageBox") as message_box, + patch.object( + hsess.Conf.proc.history_new_session_behavior, "get", return_value="ask" + ), + ): + # First call passes the debounce and opens the (rejected) dialog + assert not hsess.maybe_start_session_for_input(panel, behavior="ask") + # Second call is debounced: no second dialog + assert not hsess.maybe_start_session_for_input(panel, behavior="ask") + # Creation entries routed during the window stay in the active session + action = hsess.add_ui_entry( + panel, + "New image", + target="imagepanel", + method_name="new_object", + save_state=False, + ) + + assert message_box.question.call_count == 1 + assert execution.session_input_pending is True + assert len(callbacks) == 1 + assert panel.created_sessions == [] + assert panel.navigation.get_active_session() is signal_session + assert signal_session.actions[-1] is action + callbacks[0]() + assert execution.session_input_pending is False + # Re-entrance guards yield False when already active + with execution.recomputing_cascade() as started: + assert started is True + with execution.recomputing_cascade() as nested: + assert nested is False + with execution.replaying_edits() as started: + assert started is True + with execution.replaying_edits() as nested: + assert nested is False + + +def test_ui_entries_serialize_data_target_ownership() -> None: + """Round-trip data-target ownership and preserve legacy target fallback.""" + expected_ownership = { + "signalpanel": "signal", + "imagepanel": "image", + "signalprocessor": "signal", + "imageprocessor": "image", + "mainwindow": None, + "historypanel": None, + } + panel = PromptPanel([]) + actions = [] + for target, panel_str in expected_ownership.items(): + action = hsess.add_ui_entry( + panel, + target, + target=target, + method_name="refresh", + save_state=False, + ) + assert action is not None + assert action.panel_str == panel_str + actions.append(action) + + legacy_ownership = { + "signalprocessor": "signal", + "imageprocessor": "image", + } + legacy_actions = [ + HistoryAction( + title=target, + kind=HistoryAction.KIND_UI, + panel_str="", + target=target, + method_name="refresh", + ) + for target in legacy_ownership + ] + session = HistorySession(number=1) + for action in actions + legacy_actions: + session.add_action(action) + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "history.dlhist") + with NativeH5Writer(path) as writer: + writer.write_object_list([session], "history_session") + with NativeH5Reader(path) as reader: + loaded_actions = reader.read_object_list("history_session", HistorySession)[ + 0 + ].actions + + for action, (target, panel_str) in zip( + loaded_actions[: len(actions)], expected_ownership.items() + ): + assert action.target == target + assert action.panel_str == panel_str + assert action.effective_panel_str() == (panel_str or "") + for action, (target, panel_str) in zip( + loaded_actions[len(actions) :], legacy_ownership.items() + ): + assert action.target == target + assert action.panel_str == "" + assert action.effective_panel_str() == panel_str + + +def test_resolve_panel_for_data_owned_ui_actions() -> None: + """Resolve legacy processor and data-panel load actions to image data.""" + signal_panel = SimpleNamespace(name="signal") + image_panel = SimpleNamespace(name="image") + history_panel = SimpleNamespace( + mainwindow=SimpleNamespace( + signalpanel=signal_panel, + imagepanel=image_panel, + ) + ) + image_actions = ( + HistoryAction( + kind=HistoryAction.KIND_UI, + panel_str="", + target="imageprocessor", + method_name="run_feature", + ), + HistoryAction( + kind=HistoryAction.KIND_UI, + panel_str="", + target="imagepanel", + method_name="load_from_files", + ), + ) + + for action in image_actions: + assert hchain.resolve_panel_for_action(history_panel, action) is image_panel + for target in ("mainwindow", "historypanel"): + action = HistoryAction( + kind=HistoryAction.KIND_UI, + target=target, + method_name="refresh", + ) + assert hchain.resolve_panel_for_action(history_panel, action) is None + + +def test_history_tools_action_panel_str_uses_effective_ownership() -> None: + """Recognize a legacy image processor while preserving ownerless fallback.""" + action = HistoryAction( + kind=HistoryAction.KIND_UI, + panel_str="", + target="imageprocessor", + ) + assert hops.action_panel_str(action) == "image" + assert hops.action_panel_str(HistoryAction(target="mainwindow")) == "signal" + + +def test_image_histogram_action_keeps_image_ownership_in_active_session() -> None: + """Keep a signal-valued histogram owned by its image source panel.""" + signal_session = make_prompt_session("signal", populated=True) + image_session = make_prompt_session("image", populated=True) + signal_actions = list(signal_session.actions) + image_actions = list(image_session.actions) + signal_output = SimpleNamespace(uuid="histogram-signal", panel_str="signal") + image_source_panel = SimpleNamespace(name="image") + panel = PromptPanel([signal_session, image_session]) + panel.mainwindow = SimpleNamespace( + signalpanel=SimpleNamespace(name="signal", objects=[signal_output]), + imagepanel=image_source_panel, + ) + action = HistoryAction( + title="Histogram", + kind=HistoryAction.KIND_COMPUTE, + panel_str="image", + func_name="histogram", + pattern="1_to_1", + ) + action.output_uuids = [signal_output.uuid] + + panel.add_object(action) + + assert signal_output.panel_str == "signal" + assert action.effective_panel_str() == "image" + assert hops.action_panel_str(action) == "image" + assert hchain.resolve_panel_for_action(panel, action) is image_source_panel + assert signal_session.actions == signal_actions + assert image_session.actions == image_actions + [action] + + +def test_unattended_reject_keeps_populated_active_session() -> None: + """Leave the active session untouched after unattended rejection.""" + image_session = make_prompt_session("image", populated=True) + previous_actions = list(image_session.actions) + panel = PromptPanel([image_session]) + unattended = SimpleNamespace(unattended=True, accept_dialogs=False) + with patch.object(hsess, "execenv", unattended): + created = hsess.maybe_start_session_for_input(panel, behavior="ask") + assert created is False + assert panel.runtime.execution.prompt_count == 1 + assert panel.created_sessions == [] + assert panel.navigation.get_active_session() is image_session + assert image_session.actions == previous_actions + + +def test_mainwindow_add_object_decides_before_mutation_and_suppresses_entry() -> None: + """Decide before adding and suppress the creation entry's second prompt.""" + image_session = make_prompt_session("image", populated=True) + historypanel = PromptPanel([image_session]) + added_objects = [] + + def add_to_image_panel(obj, group_id, set_current): + historypanel.events.append("panel_mutation") + added_objects.append((obj, group_id, set_current)) + + mainwindow = SimpleNamespace( + confirm_memory_state=lambda: True, + signalpanel=SimpleNamespace(), + imagepanel=SimpleNamespace(add_object=add_to_image_panel), + historypanel=historypanel, + ) + unattended = SimpleNamespace(unattended=True, accept_dialogs=False) + image = ImageObj() + with ( + patch.object(hsess, "execenv", unattended), + patch.object( + hsess.Conf.proc.history_new_session_behavior, "get", return_value="ask" + ), + ): + added = DLMainWindow.add_object(mainwindow, image, new_session_behavior="ask") + + assert added is True + assert added_objects == [(image, "", True)] + assert historypanel.events[:3] == [ + "session_decision", + "panel_mutation", + "session_decision", + ] + assert historypanel.prompt_behaviors == ["ask", None] + assert historypanel.prompt_suppressed_states == [False, True] + assert historypanel.runtime.execution.prompt_count == 1 + assert len(historypanel.registered_outputs) == 1 + + +def test_mainwindow_add_object_preserves_no_record_and_memory_rejection() -> None: + """Keep data addition without recording and stop entirely on memory refusal.""" + image_session = make_prompt_session("image", populated=True) + historypanel = PromptPanel([image_session]) + historypanel.record_mode_enabled = False + added_objects = [] + + def add_to_image_panel(obj, group_id, set_current): + added_objects.append((obj, group_id, set_current)) + + mainwindow = SimpleNamespace( + confirm_memory_state=lambda: True, + signalpanel=SimpleNamespace(), + imagepanel=SimpleNamespace(add_object=add_to_image_panel), + historypanel=historypanel, + ) + image = ImageObj() + assert DLMainWindow.add_object(mainwindow, image, new_session_behavior="no") is True + assert added_objects == [(image, "", True)] + assert historypanel.added_actions == [] + assert historypanel.registered_outputs == [] + + mainwindow.confirm_memory_state = lambda: False + assert ( + DLMainWindow.add_object(mainwindow, ImageObj(), new_session_behavior="yes") + is False + ) + assert added_objects == [(image, "", True)] + assert historypanel.prompt_behaviors == ["no"] + + +def test_action_hdf5_current_and_legacy_contract() -> None: + """Round-trip current fields and apply all legacy defaults.""" + action = build_history_action() + action.plugin_origin = { + "module": "example.plugin", + "metadata": {"entry_points": ["difference"]}, + } + action.snapshot_kwargs() + action.kwargs["pairwise"] = True + session = HistorySession(number=1) + session.add_action(action) + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "history.dlhist") + with NativeH5Writer(path) as writer: + writer.write_object_list([session], "history_session") + current = read_history_sessions(path)[0].actions[0] + for attribute in ("selection", "states", "titles"): + values = getattr(action.state, attribute) + setattr(action.state, attribute, {"Signal Panel": values["signal"]}) + with NativeH5Writer(path) as writer: + writer.write_object_list([session], "history_session") + for field in ("schema_version", "uuid", "saved_kwargs", "output_uuids"): + delete_hdf5_items_by_name(writer.h5, field) + delete_hdf5_items_by_name(writer.h5, "object_metadata") + legacy = read_history_sessions(path)[0].actions[0] + assert current.uuid == action.uuid + assert current.schema_version == HISTORY_ACTION_SCHEMA_VERSION + assert current.output_uuids == ["output-uuid"] + assert current.plugin_origin == action.plugin_origin + assert current.has_pending_edits and bool(current.kwargs["pairwise"]) + assert legacy.schema_version == HISTORY_SCHEMA_VERSION + assert legacy.uuid != action.uuid and legacy.output_uuids == [] + assert not legacy.has_pending_edits and legacy.state.object_metadata == {} + assert legacy.state.selection == {"signal": ["source-uuid"]} + assert legacy.state.states == {"signal": ["(10,)"]} + assert legacy.state.titles == {"signal": ["Source"]} + + +def test_action_copy_remaps_all_uuid_references() -> None: + """Copy an action independently and rewrite every captured UUID field.""" + action = build_history_action() + action.plugin_origin = { + "module": "example.plugin", + "metadata": {"entry_points": ["difference"]}, + } + copied = action.copy_with_uuid_remap( + { + "signal": { + "source-uuid": "new-source", + "second-uuid": "new-second", + "output-uuid": "new-output", + } + } + ) + assert copied is not action and copied.uuid != action.uuid + assert copied.state.selection == {"signal": ["new-source"]} + assert copied.state.object_metadata == { + "signal": {"new-source": {"shape": [10], "ndim": 1, "title": "Source"}} + } + assert copied.kwargs["obj2_uuids"] == "new-second" + assert copied.output_uuids == ["new-output"] + assert copied.plugin_origin == action.plugin_origin + copied.state.object_metadata["signal"]["new-source"]["shape"] = [20] + copied.plugin_origin["metadata"]["entry_points"].append("average") + assert action.state.object_metadata["signal"]["source-uuid"]["shape"] == [10] + assert action.plugin_origin["metadata"]["entry_points"] == ["difference"] + + +def _make_analysis_action(obj_uuid: str) -> HistoryAction: + """Build a 1-to-0 compute action analysing ``obj_uuid``.""" + return HistoryAction( + title="FWHM", + kind=HistoryAction.KIND_COMPUTE, + panel_str="signal", + func_name="fwhm", + pattern="1_to_0", + state=build_workspace_state([obj_uuid]), + ) + + +def test_find_analysis_action_two_pass_matching() -> None: + """Prefer the effects manifest, then fall back to the input-uuid heuristic.""" + obj_uuid = "analysed-uuid" + older = _make_analysis_action(obj_uuid) + newer = _make_analysis_action(obj_uuid) + session = HistorySession(number=1) + session.add_action(older) + session.add_action(newer) + panel = SimpleNamespace(history_sessions=[session]) + manifest = {obj_uuid: AnalysisEffects(metadata_added=["fwhm"]).to_dict()} + # Manifest pass: only the older action recorded effects for the object, + # so it wins over the more recent heuristic-only match + older.effects = manifest + assert hchain.find_analysis_action(panel, obj_uuid, "fwhm") is older + # Both actions carry a manifest: the most recent one wins + newer.effects = dict(manifest) + assert hchain.find_analysis_action(panel, obj_uuid, "fwhm") is newer + # Legacy pass: without any manifest, the input-uuid heuristic matches the + # most recent action + older.effects = None + newer.effects = None + assert hchain.find_analysis_action(panel, obj_uuid, "fwhm") is newer + # No match for another function name or another object + assert hchain.find_analysis_action(panel, obj_uuid, "fw1e2") is None + assert hchain.find_analysis_action(panel, "other-uuid", "fwhm") is None + + +def test_object_metadata_roi_signature_presence_and_stability() -> None: + """Expose a stable ROI signature and omit it for ROI-less objects.""" + obj = create_paracetamol_signal() + assert "roi" not in WorkspaceState.get_object_metadata(obj) + obj.roi = create_signal_roi([[10, 20]], indices=True) + signature = WorkspaceState.get_object_metadata(obj)["roi"] + obj.roi = create_signal_roi([[10, 20]], indices=True) + assert WorkspaceState.get_object_metadata(obj)["roi"] == signature + obj.roi = create_signal_roi([[15, 30]], indices=True) + assert WorkspaceState.get_object_metadata(obj)["roi"] != signature + + +def test_state_compatibility_handles_roi_signature_and_legacy_metadata() -> None: + """Tolerate legacy metadata without ROI key and flag ROI drift otherwise.""" + obj = create_paracetamol_signal() + obj.roi = create_signal_roi([[10, 20]], indices=True) + uuid = get_uuid(obj) + mainwindow = cast( + DLMainWindow, + SimpleNamespace( + signalpanel=SimpleNamespace( + PANEL_STR_ID="signal", objmodel=CascadeObjectModel([obj]) + ), + imagepanel=SimpleNamespace( + PANEL_STR_ID="image", objmodel=CascadeObjectModel([]) + ), + ), + ) + state = WorkspaceState() + state.selection = {"signal": [uuid]} + recorded = WorkspaceState.get_object_metadata(obj) + state.object_metadata = {"signal": {uuid: recorded}} + assert state.is_current_state_compatible(mainwindow) + # Legacy tolerance: metadata recorded without "roi" vs current object with ROI + legacy = dict(recorded) + del legacy["roi"] + state.object_metadata = {"signal": {uuid: legacy}} + assert state.is_current_state_compatible(mainwindow) + # ROI drift against a recorded signature flags incompatibility + state.object_metadata = {"signal": {uuid: dict(recorded, roi="0" * 16)}} + assert not state.is_current_state_compatible(mainwindow) + + +def test_mutation_action_model_contract() -> None: + """Round-trip, copy and remap mutation actions with and without payload.""" + + def roi_as_dict(roi: SignalROI) -> dict: + return numpy_to_json_safe(roi.to_dict()) + + def build_mutation_action(payload: SignalROI | None) -> HistoryAction: + return HistoryAction( + title="Edit regions of interest", + kind=HistoryAction.KIND_MUTATION, + panel_str="signal", + mutation_key="roi", + target_uuids=["first-uuid", "second-uuid"], + kwargs={"payload": payload}, + ) + + def roundtrip(action: HistoryAction) -> HistoryAction: + session = HistorySession(number=1) + session.add_action(action) + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "history.dlhist") + with NativeH5Writer(path) as writer: + writer.write_object_list([session], "history_session") + return read_history_sessions(path)[0].actions[0] + + payload = create_signal_roi([[26, 41], [125, 146]], indices=True) + loaded = roundtrip(build_mutation_action(payload)) + assert loaded.kind == HistoryAction.KIND_MUTATION + assert loaded.mutation_key == "roi" + assert loaded.target_uuids == ["first-uuid", "second-uuid"] + assert loaded.panel_str == "signal" + loaded_payload = loaded.kwargs.get("payload") + assert isinstance(loaded_payload, SignalROI) + assert type(loaded_payload) is type(payload) + assert roi_as_dict(loaded_payload) == roi_as_dict(payload) + # A None payload (ROI deletion) is dropped at construction time and + # decoded back as a missing kwarg + deletion = build_mutation_action(None) + assert "payload" not in deletion.kwargs + loaded = roundtrip(deletion) + assert loaded.kind == HistoryAction.KIND_MUTATION + assert loaded.target_uuids == ["first-uuid", "second-uuid"] + assert loaded.kwargs.get("payload") is None + # Copies are independent; UUID remapping rewrites the mutation targets + action = build_mutation_action(create_signal_roi([[26, 41]], indices=True)) + copied = action.copy() + assert copied is not action and copied.uuid != action.uuid + assert copied.mutation_key == "roi" + assert copied.target_uuids == action.target_uuids + assert copied.target_uuids is not action.target_uuids + assert roi_as_dict(copied.kwargs["payload"]) == roi_as_dict( + action.kwargs["payload"] + ) + remapped = action.copy_with_uuid_remap( + {"signal": {"first-uuid": "new-first", "second-uuid": "new-second"}} + ) + assert remapped.target_uuids == ["new-first", "new-second"] + assert action.target_uuids == ["first-uuid", "second-uuid"] + + +def test_capture_effects_metadata_and_roi_diff() -> None: + """Diff metadata keys and flag only genuine ROI changes.""" + obj = create_image_from_param(Gauss2DParam.create(height=16, width=16)) + obj.metadata["untouched"] = 1 + obj.metadata["changed_scalar"] = 5 + obj.metadata["changed_array"] = np.arange(3) + with capture_effects(obj) as effects: + obj.metadata["new_key"] = "hello" + obj.metadata["changed_scalar"] = 6 + obj.metadata["changed_array"] = np.arange(4) + obj.metadata["__uuid"] = "synthetic-uuid" + obj.metadata["__number"] = 42 + assert effects.metadata_added == ["new_key"] + assert effects.metadata_replaced == ["changed_array", "changed_scalar"] + assert "untouched" not in effects.metadata_added + effects.metadata_replaced + assert "__uuid" not in effects.metadata_added + assert "__number" not in effects.metadata_added + assert effects.roi_modified is False + # No ROI before/after: unmodified + with capture_effects(obj) as effects: + pass + assert effects.roi_modified is False + # ROI creation flags the capture + with capture_effects(obj) as effects: + obj.roi = create_image_roi("rectangle", [2, 2, 5, 5]) + assert effects.roi_modified is True + # An existing ROI left untouched by the analysis is not modified + with capture_effects(obj) as effects: + obj.metadata["another_key"] = 0 + assert effects.roi_modified is False + # Re-assigning an equal ROI is not a modification (relies on ROI equality) + with capture_effects(obj) as effects: + obj.roi = create_image_roi("rectangle", [2, 2, 5, 5]) + assert effects.roi_modified is False + # Changing the ROI geometry is a modification + with capture_effects(obj) as effects: + obj.roi = create_image_roi("rectangle", [3, 3, 6, 6]) + assert effects.roi_modified is True + + +def test_analysis_effects_round_trip_merge_and_persistence() -> None: + """Round-trip manifests through dict/HDF5 and merge recompute captures.""" + effects = AnalysisEffects( + metadata_added=["Geometry_peak_detection_dict"], + metadata_replaced=["analysis_parameters"], + roi_modified=True, + ) + payload = effects.to_dict() + assert payload == { + "metadata_added": ["Geometry_peak_detection_dict"], + "metadata_replaced": ["analysis_parameters"], + "roi_modified": True, + } + assert AnalysisEffects.from_dict(payload) == effects + assert AnalysisEffects.from_dict({}) == AnalysisEffects() + # Merge semantics: added-stays-added, sticky roi_modified, sorted output + new = AnalysisEffects( + metadata_added=["b", "a"], metadata_replaced=["c"], roi_modified=False + ) + merged = merge_effects(None, new) + assert merged == AnalysisEffects(["a", "b"], ["c"], False) + previous = AnalysisEffects(metadata_added=["result"], roi_modified=True) + recomputed = AnalysisEffects(metadata_replaced=["result", "params"]) + merged = merge_effects(previous, recomputed) + assert merged.metadata_added == ["result"] + assert merged.metadata_replaced == ["params"] + assert merged.roi_modified is True + # HDF5 round-trip on an action, with legacy tolerance (no effects group) + action = build_history_action() + action.effects = {"source-uuid": payload} + session = HistorySession(number=1) + session.add_action(action) + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "history.dlhist") + with NativeH5Writer(path) as writer: + writer.write_object_list([session], "history_session") + loaded = read_history_sessions(path)[0].actions[0] + assert loaded.effects == action.effects + with NativeH5Writer(path) as writer: + writer.write_object_list([session], "history_session") + delete_hdf5_items_by_name(writer.h5, "effects") + legacy = read_history_sessions(path)[0].actions[0] + assert legacy.effects is None + + +def test_update_obj_in_place_preserves_roi() -> None: + """In-place recompute keeps the target's ROI when the new object has none.""" + target = create_paracetamol_signal() + target.roi = create_signal_roi([[10, 20]], indices=True) + saved_roi_dict = numpy_to_json_safe(target.roi.to_dict()) + new_obj = create_paracetamol_signal() + assert new_obj.roi is None + hrec.update_obj_in_place(target, new_obj) + assert target.roi is not None + assert numpy_to_json_safe(target.roi.to_dict()) == saved_roi_dict + + +def test_recompute_dispatch_guards_and_missing_feature() -> None: + """Reject non-recomputable actions and diagnose missing plugin features.""" + warnings: list[str] = [] + panel = SimpleNamespace( + runtime=SimpleNamespace(execution=SimpleNamespace(cascade_warnings=warnings)), + mainwindow=SimpleNamespace( + signalpanel=SimpleNamespace(objmodel=CascadeObjectModel([])), + imagepanel=SimpleNamespace(objmodel=CascadeObjectModel([])), + ), + ) + # Non-creation UI actions are silently not recomputable + noncompute = HistoryAction(kind=HistoryAction.KIND_UI, method_name="select_next") + assert hrec.recompute_action_in_place(panel, noncompute) is False + assert warnings == [] + # Unsupported compute patterns queue a warning + unsupported = HistoryAction( + kind=HistoryAction.KIND_COMPUTE, func_name="mystery", pattern="3_to_2" + ) + assert hrec.recompute_action_in_place(panel, unsupported) is False + assert any("mystery" in warning for warning in warnings) + # A missing plugin feature flags the action and queues a diagnostic + action = HistoryAction( + kind=HistoryAction.KIND_COMPUTE, func_name="plugin_func", pattern="1_to_1" + ) + error = FeatureNotFoundError( + "plugin_func", + plugin_origin={"directory": "myplugin"}, + paramclass_name="MyParam", + ) + with patch.object(hrec, "recompute_compute_in_place", side_effect=error): + assert hrec.recompute_action_in_place(panel, action) is False + assert action.is_stale is True + assert any( + "myplugin/plugins:plugin_func" in warning and "MyParam" in warning + for warning in warnings + ) + # Mutation guards: unresolved data panel, then no recorded targets + orphan_mutation = HistoryAction( + title="Edit ROI", kind=HistoryAction.KIND_MUTATION, mutation_key="roi" + ) + assert hrec.recompute_action_in_place(panel, orphan_mutation) is False + targetless = HistoryAction( + title="Edit ROI", + kind=HistoryAction.KIND_MUTATION, + panel_str="signal", + mutation_key="roi", + ) + assert hrec.recompute_action_in_place(panel, targetless) is False + assert sum("Edit ROI" in warning for warning in warnings) == 2 + + +def test_find_creation_action_for_output_fallback_scan() -> None: + """Fall back to scanning creation outputs when the mapping is stale.""" + creation = HistoryAction( + title="New signal", + kind=HistoryAction.KIND_UI, + target="signalpanel", + method_name="new_object", + ) + compute = HistoryAction( + kind=HistoryAction.KIND_COMPUTE, func_name="derivative", pattern="1_to_1" + ) + session = HistorySession(number=1) + session.add_action(creation) + session.add_action(compute) + runtime = SimpleNamespace( + objects=SimpleNamespace( + output_to_action={"created-uuid": compute.uuid}, + action_output_uuids={creation.uuid: ["created-uuid"]}, + ) + ) + panel = SimpleNamespace(history_sessions=[session], runtime=runtime) + # The mapped action is not a creation: the fallback scan finds the head + assert hchain.find_creation_action_for_output(panel, "created-uuid") is creation + assert hchain.find_creation_action_for_output(panel, "unknown-uuid") is None + empty = SimpleNamespace(history_sessions=[]) + assert hchain.find_creation_action_for_output(empty, "created-uuid") is None + + +def test_plan_reconnection_dead_source_warning_and_producer_removal() -> None: + """Warn on dead sources, then reconnect and remove the dead producer.""" + source = create_paracetamol_signal() + source_uuid = get_uuid(source) + removed_uuid = "removed-output" + consumer_obj = create_paracetamol_signal() + consumer_uuid = get_uuid(consumer_obj) + insert_processing_parameters( + consumer_obj, + ProcessingParameters( + func_name="derivative", pattern="1-to-1", source_uuid=removed_uuid + ), + ) + producer = HistoryAction( + title="Normalize", + kind=HistoryAction.KIND_COMPUTE, + panel_str="signal", + func_name="normalize", + pattern="1_to_1", + state=build_workspace_state([source_uuid]), + ) + producer.output_uuids = [removed_uuid] + consumer_action = HistoryAction( + title="Derivative", + kind=HistoryAction.KIND_COMPUTE, + panel_str="signal", + func_name="derivative", + pattern="1_to_1", + state=build_workspace_state([removed_uuid]), + ) + session = HistorySession(number=1) + session.add_action(producer) + session.add_action(consumer_action) + + def make_panel(objects: list) -> tuple[SimpleNamespace, SimpleNamespace]: + signal_panel = SimpleNamespace( + PANEL_STR_ID="signal", objmodel=CascadeObjectModel(objects) + ) + panel = SimpleNamespace( + history_sessions=[session], + runtime=SimpleNamespace( + objects=SimpleNamespace( + output_to_action={removed_uuid: producer.uuid}, + action_output_uuids={producer.uuid: [removed_uuid]}, + remove_action_outputs=Mock(), + ) + ), + mainwindow=SimpleNamespace( + signalpanel=signal_panel, + imagepanel=SimpleNamespace( + PANEL_STR_ID="image", objmodel=CascadeObjectModel([]) + ), + ), + ) + return panel, signal_panel + + # Dead source: the plan carries a warning and applying it is a no-op + panel, signal_panel = make_panel([consumer_obj]) + plan = hchain.plan_reconnection(panel, signal_panel, removed_uuid) + assert plan.warning is not None and "Normalize" in plan.warning + assert [target.object_uuid for target in plan.targets] == [consumer_uuid] + assert plan.targets[0].action is consumer_action + roots: list[HistoryAction] = [] + hchain.apply_reconnection_plan(panel, signal_panel, plan, roots) + assert roots == [] + assert extract_processing_parameters(consumer_obj).source_uuid == removed_uuid + # Reconnection warnings are silenced in unattended mode + with ( + patch.object(hchain, "execenv", SimpleNamespace(unattended=True)), + patch.object(hchain.QW, "QMessageBox") as message_box, + ): + hchain.show_reconnection_warnings(panel, [plan.warning]) + message_box.warning.assert_not_called() + + # Alive source: reconnect the consumer and remove the outputless producer + panel, signal_panel = make_panel([source, consumer_obj]) + plan = hchain.plan_reconnection(panel, signal_panel, removed_uuid) + assert plan.warning is None + assert plan.source_uuid == source_uuid + assert plan.remove_producer is True + roots = [] + hchain.apply_reconnection_plan(panel, signal_panel, plan, roots) + assert roots == [consumer_action] + assert extract_processing_parameters(consumer_obj).source_uuid == source_uuid + assert consumer_action.state.selection["signal"] == [source_uuid] + assert producer not in session.actions + panel.runtime.objects.remove_action_outputs.assert_called_once_with(producer) + + +def test_prepare_action_param_edit_skips_paramless_actions() -> None: + """Return no edit target (and skip the dialog) for param-less actions.""" + paramless = ( + HistoryAction(kind=HistoryAction.KIND_UI, method_name="new_object"), + HistoryAction(kind=HistoryAction.KIND_COMPUTE, pattern="1_to_1"), + HistoryAction(kind=HistoryAction.KIND_COMPUTE, pattern="1_to_n"), + HistoryAction(kind=HistoryAction.KIND_MUTATION, mutation_key="roi"), + ) + panel = SimpleNamespace(mainwindow=None) + for action in paramless: + assert hireplay.prepare_action_param_edit(action) is None + assert hireplay.prompt_edit_action_params(panel, action) is None + + +def test_make_synthetic_heads_falls_back_to_default_title() -> None: + """Use the default head title when the cloned object cannot be resolved.""" + root = HistoryAction( + kind=HistoryAction.KIND_COMPUTE, + panel_str="signal", + func_name="derivative", + pattern="1_to_1", + state=build_workspace_state(["external-uuid"]), + ) + chain = ProcessingChain(root=root, session=HistorySession(number=1), actions=[root]) + registry = UuidCloneRegistry() + registry.register("signal", "external-uuid", "clone-uuid", object()) + + class RaisingModel: + """Object model whose lookups always fail.""" + + def __getitem__(self, uuid: str) -> None: + raise KeyError(uuid) + + panel = SimpleNamespace( + mainwindow=SimpleNamespace( + signalpanel=SimpleNamespace(objmodel=RaisingModel()), imagepanel=None + ) + ) + heads = hops.make_synthetic_heads(panel, chain, registry) + assert len(heads) == 1 + head = heads[0] + assert head.method_name == "new_object" + assert head.output_uuids == ["clone-uuid"] + assert head.title == hops._("Initial state") diff --git a/datalab/tests/features/common/history_panel_app_test.py b/datalab/tests/features/common/history_panel_app_test.py new file mode 100644 index 000000000..96b3bbd51 --- /dev/null +++ b/datalab/tests/features/common/history_panel_app_test.py @@ -0,0 +1,67 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +History Panel application test +(essentially for the screenshot...) + +Records a representative sequence of UI and computation actions and grabs +a screenshot of the history panel, used in the documentation +(:ref:`historypanel`). +""" + +# guitest: show + +import sigima.objects +import sigima.proc.signal as sips + +from datalab import config +from datalab.tests import datalab_test_app_context +from datalab.utils import qthelpers as qth + + +def test_history_panel(screenshots: bool = False) -> None: + """Record a representative session and grab the History Panel screenshot.""" + config.reset() # Reset configuration (remove configuration file and initialize it) + config.Conf.proc.history_new_session_behavior.set("no") + with datalab_test_app_context( + console=False, exec_loop=not screenshots, history=True + ) as win: + history = win.historypanel + history.toggle_record_mode(True) + + panel = win.signalpanel + + # [New Voigt, New Lorentzian, New Lorentzian] + panel.new_object(param=sigima.objects.VoigtParam(), edit=False) + panel.new_object(param=sigima.objects.LorentzParam(), edit=False) + panel.new_object(param=sigima.objects.LorentzParam(), edit=False) + + # Remove the third signal + panel.objview.select_objects([3]) + panel.remove_object(force=True) + + # New Gaussian + panel.new_object(param=sigima.objects.GaussParam(), edit=False) + + # Average of the 3 remaining signals + panel.objview.select_objects([1, 2, 3]) + panel.processor.run_feature(sips.average) + + # Add Gaussian noise to the average + noise_param = sigima.objects.NormalDistributionParam() + noise_param.sigma = 0.05 + panel.objview.select_objects([4]) + panel.processor.run_feature(sips.add_gaussian_noise, noise_param) + + # Gaussian fit + panel.processor.run_feature(sips.gaussian_fit) + + # Make sure the History Panel dock is raised over the Macro Panel + win.docks[history].raise_() + + if screenshots: + qth.grab_save_window(history, "history_panel", add_timestamp=False) + + +if __name__ == "__main__": + test_history_panel() diff --git a/datalab/tests/features/common/history_panel_test.py b/datalab/tests/features/common/history_panel_test.py new file mode 100644 index 000000000..e57b34dee --- /dev/null +++ b/datalab/tests/features/common/history_panel_test.py @@ -0,0 +1,235 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""History panel replay and cross-panel navigation contracts.""" + +from __future__ import annotations + +from contextlib import nullcontext +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest +import sigima.proc.image as sipi +import sigima.proc.signal as sips +from qtpy import QtCore as QC +from qtpy import QtWidgets as QW +from sigima.tests.data import create_paracetamol_signal, create_sincos_image + +from datalab.gui import historytools_ops as htools +from datalab.gui.panel.history import HistoryTree +from datalab.gui.panel.history import interactive_replay as hireplay +from datalab.gui.panel.history.ui import HistoryPanelUI +from datalab.history.action import HistoryAction +from datalab.history.session import HistorySession +from datalab.objectmodel import get_uuid +from datalab.tests import datalab_test_app_context +from datalab.tests.features.common.history_test_helpers import ( + build_signal_chain, + get_tree_item, + is_session_bold, + select_tree_entry, +) + + +@pytest.mark.parametrize("column", (0, 2)) +@pytest.mark.parametrize("selected_kind", ("action", "session")) +def test_history_tree_double_click_replays_current_selection_without_restoring( + selected_kind: str, column: int +) -> None: + """Replay the current action or session selection from either tree column.""" + if selected_kind == "action": + selected_row: HistoryAction | HistorySession = HistoryAction() + expected_actions = [selected_row] + else: + selected_row = HistorySession() + selected_row.add_action(HistoryAction()) + selected_row.add_action(HistoryAction()) + expected_actions = list(selected_row.actions) + selected_row.is_current_state_compatible = Mock(return_value=True) + clicked_row = HistoryAction() + tree = SimpleNamespace( + customContextMenuRequested=Mock(), + itemDoubleClicked=Mock(), + itemSelectionChanged=Mock(), + get_selected_actions_or_sessions=Mock(return_value=[selected_row]), + ) + mainwindow = object() + panel = SimpleNamespace( + tree=tree, + history_sessions=[], + mainwindow=mainwindow, + refresh_compatibility_items=Mock(), + replaying=nullcontext, + output_suppressed=nullcontext, + runtime=SimpleNamespace(execution=SimpleNamespace(edit_mode=False)), + navigation=SimpleNamespace( + sync_panel_selection=Mock(), + update_state_widget=Mock(), + set_active_session_from_selection=Mock(), + ), + ) + panel.replay_restore_actions = lambda **kwargs: hireplay.replay_restore_actions( + panel, **kwargs + ) + ui = HistoryPanelUI.__new__(HistoryPanelUI) + ui.panel = panel + + ui.setup_connections() + double_click_slot = tree.itemDoubleClicked.connect.call_args.args[0] + with patch.object(hireplay, "replay_actions") as replay_actions_mock: + double_click_slot(clicked_row, column) + + selected_row.is_current_state_compatible.assert_called_once_with(mainwindow) + replay_actions_mock.assert_called_once_with(panel, expected_actions, prompt=False) + assert clicked_row not in replay_actions_mock.call_args.args[1] + + +def test_panel_replay_restores_selection_without_outputs() -> None: + """Use panel replay to restore selection without recording or new output.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + panel.add_object(create_paracetamol_signal()) + source_uuid = get_uuid(panel.objmodel.get_object_from_number(1)) + panel.objview.select_objects([1]) + panel.processor.run_feature(sips.derivative) + action = history[len(history)] + output_uuid = action.output_uuids[0] + select_tree_entry(history, action.uuid) + assert panel.objview.get_sel_object_uuids() == [output_uuid] + object_count, action_count = len(panel.objmodel), len(history) + history.replay_restore_actions(replay=True, restore_selection=True) + # In-place recompute keeps counts unchanged and selects the refreshed output + assert ( + len(panel.objmodel), + len(history), + panel.objview.get_sel_object_uuids(), + ) == (object_count, action_count, [output_uuid]) + assert action in history.history_sessions[-1].actions and ( + history.runtime.objects.action_output_uuids[action.uuid] == [output_uuid] + ) + assert ( + output_uuid in panel.objmodel.get_object_ids() + and history.runtime.objects.output_to_action[output_uuid] == action.uuid + ) + panel.objview.select_objects([output_uuid]) + panel.remove_object(force=True) + assert ( + action in history.history_sessions[-1].actions + and output_uuid not in panel.objmodel.get_object_ids() + and action.uuid not in history.runtime.objects.action_output_uuids + and output_uuid not in history.runtime.objects.output_to_action + ) + select_tree_entry(history, action.uuid) + history.replay_restore_actions(replay=False, restore_selection=True) + assert panel.objview.get_sel_object_uuids() == [source_uuid] + + +def test_cross_panel_sessions_navigation_and_tree_state() -> None: + """Coordinate active sessions, navigation, tree state and selection fallback.""" + with datalab_test_app_context(history=True) as win: + history = win.historypanel + signal_panel, image_panel = win.signalpanel, win.imagepanel + history.toggle_record_mode(True) + signal_chain = build_signal_chain(signal_panel, history) + first_signal_action, middle_signal_action, last_signal_action = ( + signal_chain.actions + ) + signal_uuid = first_signal_action.state.selection["signal"][0] + signal_session = next( + session + for session in history.history_sessions + if first_signal_action in session.actions + ) + assert all(action in signal_session.actions for action in signal_chain.actions) + navigation_states = [] + select_tree_entry(history, first_signal_action.uuid) + navigation_states.append( + ( + history.ui.actions["step_prev"].isEnabled(), + history.ui.actions["step_next"].isEnabled(), + ) + ) + select_tree_entry(history, middle_signal_action.uuid) + navigation_states.append( + ( + history.ui.actions["step_prev"].isEnabled(), + history.ui.actions["step_next"].isEnabled(), + ) + ) + select_tree_entry(history, last_signal_action.uuid) + navigation_states.append( + ( + history.ui.actions["step_prev"].isEnabled(), + history.ui.actions["step_next"].isEnabled(), + ) + ) + assert navigation_states == [(False, True), (True, True), (True, False)] + image_panel.add_object(create_sincos_image()) + image_panel.objview.select_objects([1]) + image_panel.processor.run_feature(sipi.inverse) + image_action = history[len(history)] + # Unified model: the image action is chained into the single active + # recording session, alongside the signal actions. + assert history.navigation.get_active_session() is signal_session + assert image_action in signal_session.actions + bold_before = is_session_bold(history, signal_session) + history.tree.populate_tree(history.history_sessions) + assert bold_before is True + assert is_session_bold(history, signal_session) is True + output_uuid = first_signal_action.output_uuids[0] + select_tree_entry(history, first_signal_action.uuid) + assert signal_panel.objview.get_sel_object_uuids() == [output_uuid] + signal_panel.objview.select_objects([output_uuid]) + signal_panel.remove_object(force=True) + history.toggle_record_mode(False) + image_panel.objview.select_objects([1]) + image_panel.remove_object(force=True) + history.refresh_compatibility_items() + tree_action_uuids = set() + iterator = QW.QTreeWidgetItemIterator(history.tree) + while iterator.value(): + uuid = iterator.value().data(0, QC.Qt.UserRole) + if uuid is not None: + tree_action_uuids.add(uuid) + iterator += 1 + image_item = get_tree_item(history, image_action.uuid) + assert ( + all( + first_signal_action not in session.actions + for session in history.history_sessions + ) + and middle_signal_action in signal_session.actions + and last_signal_action in signal_session.actions + and middle_signal_action.state.selection["signal"] == [signal_uuid] + and first_signal_action.uuid + not in history.runtime.objects.action_output_uuids + and output_uuid not in history.runtime.objects.output_to_action + and first_signal_action.uuid not in tree_action_uuids + and {middle_signal_action.uuid, last_signal_action.uuid}.issubset( + tree_action_uuids + ) + and image_item.data(0, HistoryTree.COMPATIBILITY_ROLE) is False + and image_item.foreground(0).color().isValid() + and image_item.data(0, QC.Qt.UserRole) == image_action.uuid + ) + # Remove-incompatible tool purges flagged actions and keeps the rest + incompatible = [ + action + for session in history.history_sessions + for action in session.actions + if not action.is_current_state_compatible(win) + ] + assert image_action in incompatible + htools.remove_incompatible_actions(history) + remaining = [ + action for session in history.history_sessions for action in session.actions + ] + assert not [action for action in incompatible if action in remaining] + assert all(action.is_current_state_compatible(win) for action in remaining) + assert all(session.actions for session in history.history_sessions) + # Second run: everything is compatible, nothing changes + htools.remove_incompatible_actions(history) + assert [ + action for session in history.history_sessions for action in session.actions + ] == remaining diff --git a/datalab/tests/features/common/history_replay_fixes_test.py b/datalab/tests/features/common/history_replay_fixes_test.py new file mode 100644 index 000000000..7fb2a309e --- /dev/null +++ b/datalab/tests/features/common/history_replay_fixes_test.py @@ -0,0 +1,557 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Regression tests for History panel replay fixes. + +Covers cross-panel output routing, synthetic session heads, file-save +confirmation and load-action replay (plan ``history-replay-fixes``). +""" + +from __future__ import annotations + +import os +import os.path as osp +from unittest.mock import patch + +import numpy as np +import sigima.params +import sigima.proc.signal as sips +from sigima.tests.data import create_paracetamol_signal, create_sincos_image + +from datalab.config import _ +from datalab.env import execenv +from datalab.gui.panel.history import interactive_replay as hireplay +from datalab.gui.panel.history import recompute as hrec +from datalab.objectmodel import get_uuid +from datalab.tests import datalab_test_app_context +from datalab.tests.features.common.history_test_helpers import add_paracetamol_signals + + +def record_line_profile(win) -> tuple: + """Record an image line-profile compute and return (action, output_uuid). + + Args: + win: DataLab main window with history recording enabled + + Returns: + Recorded cross-panel compute action and its output signal UUID + """ + history, ipanel = win.historypanel, win.imagepanel + ipanel.add_object(create_sincos_image()) + ipanel.objview.select_objects([1]) + param = sigima.params.LineProfileParam.create( + direction="horizontal", row=100, col=100 + ) + ipanel.processor.run_feature("line_profile", param) + action = history[len(history)] + return action, action.output_uuids[0] + + +def test_replay_cross_panel_output_stays_in_destination_panel() -> None: + """Keep cross-panel compute outputs in the destination panel on replay.""" + with datalab_test_app_context(history=True) as win: + history = win.historypanel + history.toggle_record_mode(True) + action, output_uuid = record_line_profile(win) + spanel, ipanel = win.signalpanel, win.imagepanel + assert spanel.objmodel.has_uuid(output_uuid) + assert not ipanel.objmodel.has_uuid(output_uuid) + image_count = len(ipanel.objmodel) + signal_count = len(spanel.objmodel) + for _replay_nb in range(2): + hireplay.replay_actions(history, [action], prompt=False) + assert action.is_stale is False + assert spanel.objmodel.has_uuid(output_uuid) + assert not ipanel.objmodel.has_uuid(output_uuid) + assert len(ipanel.objmodel) == image_count + assert len(spanel.objmodel) == signal_count + + +def test_replay_recreates_deleted_cross_panel_output_in_destination_panel() -> None: + """Re-create a deleted cross-panel output in the destination panel.""" + with datalab_test_app_context(history=True) as win: + history = win.historypanel + history.toggle_record_mode(True) + action, output_uuid = record_line_profile(win) + spanel, ipanel = win.signalpanel, win.imagepanel + expected_data = spanel.objmodel[output_uuid].xydata.copy() + image_count = len(ipanel.objmodel) + spanel.objview.select_objects([output_uuid]) + spanel.remove_object(force=True) + assert not spanel.objmodel.has_uuid(output_uuid) + + with patch.object(hrec, "flush_cascade_warnings"): + hireplay.replay_actions(history, [action], prompt=False) + + assert history.runtime.execution.cascade_warnings == [] + assert action.is_stale is False + assert spanel.objmodel.has_uuid(output_uuid) + assert not ipanel.objmodel.has_uuid(output_uuid) + assert len(ipanel.objmodel) == image_count + recreated = spanel.objmodel[output_uuid] + assert np.array_equal(recreated.xydata, expected_data) + + +def test_failed_compute_replay_leaves_no_temporary_objects() -> None: + """Detach fresh temporaries when a compute raises mid-batch on replay.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + add_paracetamol_signals(panel, 2) + panel.objview.select_objects([1, 2]) + panel.processor.run_feature( + sips.normalize, sigima.params.NormalizeParam.create() + ) + action = history[len(history)] + object_count = len(panel.objmodel) + original_execute = hrec.execute_compute_via_ui + + def failing_execute(panel_data, act, obj2_uuids) -> None: + """Insert the whole batch of fresh outputs, then fail: simulates + a compute raising after some objects were already created.""" + original_execute(panel_data, act, obj2_uuids) + raise RuntimeError("simulated mid-batch failure") + + with patch.object(hrec, "execute_compute_via_ui", failing_execute): + assert hrec.recompute_action_in_place(history, action) is False + + assert action.is_stale is True + assert len(panel.objmodel) == object_count + history.runtime.execution.cascade_warnings.clear() + + +def build_synthetic_head_chain(win) -> list: + """Record a creation + two computes, then clear the head kwargs. + + Mirrors the synthetic session head produced by *Duplicate chain* + (``new_object`` UI action with empty kwargs). + + Args: + win: DataLab main window with history recording enabled + + Returns: + The three recorded actions in session order (head first) + """ + history, panel = win.historypanel, win.signalpanel + panel.new_object(edit=False) + head = history[len(history)] + assert head.method_name == "new_object" + panel.objview.select_objects(head.output_uuids) + panel.processor.run_feature( + sips.gaussian_filter, sigima.params.GaussianParam.create(sigma=1.5) + ) + first = history[len(history)] + panel.objview.select_objects(first.output_uuids) + panel.processor.run_feature(sips.derivative) + second = history[len(history)] + head.kwargs.clear() # Simulate the Duplicate-chain synthetic head + return [head, first, second] + + +def test_replay_synthetic_head_without_param_is_noop_success() -> None: + """Treat a parameterless creation head with live outputs as a no-op.""" + with datalab_test_app_context(history=True) as win: + history = win.historypanel + history.toggle_record_mode(True) + actions = build_synthetic_head_chain(win) + + with patch.object(hrec, "flush_cascade_warnings"): + hireplay.replay_actions(history, actions, prompt=False) + + assert history.runtime.execution.cascade_warnings == [] + assert all(not action.is_stale for action in actions) + + +def test_replay_synthetic_head_with_deleted_output_warns() -> None: + """Warn and keep the chain stale when the head object was deleted.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + actions = build_synthetic_head_chain(win) + head = actions[0] + deleted_uuids = [ + output_uuid for action in actions for output_uuid in action.output_uuids + ] + panel.objview.select_objects(deleted_uuids) + panel.remove_object(force=True) + + with patch.object(hrec, "flush_cascade_warnings"): + hireplay.replay_actions(history, actions, prompt=False) + + warnings = history.runtime.execution.cascade_warnings + assert warnings + assert any(head.title in warning for warning in warnings) + assert all(action.is_stale for action in actions) + history.runtime.execution.cascade_warnings.clear() + + +def record_file_save(win, filename: str): + """Record a ``save_to_files`` action writing one signal to ``filename``. + + Args: + win: DataLab main window with history recording enabled + filename: Destination file name + + Returns: + Recorded file-save UI action + """ + history, panel = win.historypanel, win.signalpanel + add_paracetamol_signals(panel, 1) + panel.objview.select_objects([1]) + panel.save_to_files([filename]) + action = history[len(history)] + assert action.method_name == "save_to_files" + assert osp.isfile(filename) + return action + + +def test_replay_skips_file_save_without_confirmation(tmp_path) -> None: + """Skip file-save actions on unattended replay without accept_dialogs.""" + with datalab_test_app_context(history=True) as win: + history = win.historypanel + history.toggle_record_mode(True) + filename = str(tmp_path / "signal.csv") + action = record_file_save(win, filename) + os.remove(filename) + assert execenv.unattended and not execenv.accept_dialogs + + hireplay.replay_actions(history, [action], prompt=False) + + assert not osp.isfile(filename) + assert action.is_stale is False + + +def test_replay_file_save_with_accept_dialogs(tmp_path) -> None: + """Replay file-save actions when accept_dialogs is enabled.""" + with datalab_test_app_context(history=True) as win: + history = win.historypanel + history.toggle_record_mode(True) + filename = str(tmp_path / "signal.csv") + action = record_file_save(win, filename) + os.remove(filename) + saved_accept_dialogs = execenv.accept_dialogs + execenv.accept_dialogs = True + try: + hireplay.replay_actions(history, [action], prompt=False) + finally: + execenv.accept_dialogs = saved_accept_dialogs + + assert osp.isfile(filename) + assert action.is_stale is False + + +def populate_signal_directory(win, directory, layout: dict[str, list[str]]) -> None: + """Write round-trip-compatible signal files into ``directory``. + + Signals are saved through the panel I/O registry so the written files are + guaranteed to load back. No history entry is recorded (record mode off). + + Args: + win: DataLab main window (record mode must be disabled) + directory: Base directory (``pathlib.Path``) + layout: Mapping of subdirectory name ("" for the base directory) to + file names + """ + panel = win.signalpanel + assert not win.historypanel.record_mode_enabled + add_paracetamol_signals(panel, 1) + panel.objview.select_objects([1]) + for subdir_name, filenames in layout.items(): + subdir = directory / subdir_name if subdir_name else directory + subdir.mkdir(exist_ok=True) + for filename in filenames: + panel.save_to_files([str(subdir / filename)]) + panel.remove_object(force=True) + assert len(panel.objmodel) == 0 + + +def test_replay_load_from_directory_reloads_deleted_objects(tmp_path) -> None: + """Reload deleted objects (and groups) by replaying a directory load.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + populate_signal_directory( + win, + tmp_path, + {"suba": ["s1.csv", "s2.csv"], "subb": ["s3.csv", "s4.csv"]}, + ) + history.toggle_record_mode(True) + objs = panel.load_from_directory(str(tmp_path)) + assert len(objs) == 4 + action = history[len(history)] + assert action.method_name == "load_from_directory" + assert len(action.output_uuids) == 4 + group_titles = [group.title for group in panel.objmodel.get_groups()] + assert "suba" in group_titles and "subb" in group_titles + # Delete every loaded object + history.toggle_record_mode(False) + panel.objview.select_objects(panel.objmodel.get_object_ids()) + panel.remove_object(force=True) + assert len(panel.objmodel) == 0 + history.toggle_record_mode(True) + + hireplay.replay_actions(history, [action], prompt=False) + + assert len(panel.objmodel) == 4 + assert action.is_stale is False + # Outputs re-bound to the freshly loaded objects + assert len(action.output_uuids) == 4 + assert all( + panel.objmodel.has_uuid(output_uuid) for output_uuid in action.output_uuids + ) + # Group structure (one group per subdirectory) is re-created + reloaded_groups = { + group.title: len(group.get_object_ids()) + for group in panel.objmodel.get_groups() + if group.get_object_ids() + } + assert reloaded_groups.get("suba") == 2 + assert reloaded_groups.get("subb") == 2 + + +def test_replay_load_skipped_when_outputs_still_exist(tmp_path) -> None: + """Skip a load-action replay when every loaded object still exists.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + populate_signal_directory(win, tmp_path, {"": ["s1.csv", "s2.csv"]}) + history.toggle_record_mode(True) + objs = panel.load_from_directory(str(tmp_path)) + assert len(objs) == 2 + action = history[len(history)] + assert action.method_name == "load_from_directory" + object_count = len(panel.objmodel) + group_count = len(panel.objmodel.get_groups()) + output_uuids_before = list(action.output_uuids) + + hireplay.replay_actions(history, [action], prompt=False) + + assert len(panel.objmodel) == object_count + assert len(panel.objmodel.get_groups()) == group_count + assert action.output_uuids == output_uuids_before + assert action.is_stale is False + + +def test_replay_load_legacy_add_objects_false_self_heals(tmp_path) -> None: + """Self-heal legacy load entries recorded with ``add_objects=False``.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + populate_signal_directory(win, tmp_path, {"": ["s1.csv", "s2.csv"]}) + history.toggle_record_mode(True) + fnames = [str(tmp_path / "s1.csv"), str(tmp_path / "s2.csv")] + objs = panel.load_from_files(fnames) + assert len(objs) == 2 + action = history[len(history)] + assert action.method_name == "load_from_files" + # Simulate a legacy entry recorded by the old ``load_from_directory`` + action.kwargs["add_objects"] = False + # Delete every loaded object + history.toggle_record_mode(False) + panel.objview.select_objects(panel.objmodel.get_object_ids()) + panel.remove_object(force=True) + assert len(panel.objmodel) == 0 + history.toggle_record_mode(True) + + hireplay.replay_actions(history, [action], prompt=False) + + assert len(panel.objmodel) == 2 + assert action.is_stale is False + assert action.kwargs["add_objects"] is True + assert all( + panel.objmodel.has_uuid(output_uuid) for output_uuid in action.output_uuids + ) + + +def write_unreadable_files(directory, names: list[str]) -> list[str]: + """Write files that no signal reader can load into ``directory``. + + Args: + directory: Base directory (``pathlib.Path``) + names: File names to create + + Returns: + Full paths of the created files + """ + fnames = [] + for name in names: + path = directory / name + path.write_bytes(b"\x89PNG\r\n\x1a\nnot actually loadable") + fnames.append(str(path)) + return fnames + + +def test_load_from_files_without_loadable_file_records_no_entry(tmp_path) -> None: + """Discard the load entry when no file produced any object.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + fnames = write_unreadable_files(tmp_path, ["i1.png", "i2.png"]) + objs = panel.load_from_files(fnames, ignore_errors=True) + assert objs == [] + assert len(history) == 0 + + +def test_load_from_directory_without_loadable_file_records_no_entry(tmp_path) -> None: + """Discard the directory-load entry when nothing could be loaded.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + write_unreadable_files(tmp_path, ["i1.png", "i2.png"]) + objs = panel.load_from_directory(str(tmp_path)) + assert objs == [] + assert len(history) == 0 + + +def test_load_from_files_partial_failure_updates_entry(tmp_path) -> None: + """Reflect the actually loaded files in the recorded load entry.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + populate_signal_directory(win, tmp_path, {"": ["s1.csv", "s2.csv"]}) + bad = write_unreadable_files(tmp_path, ["i1.png", "i2.png"]) + good = [str(tmp_path / "s1.csv"), str(tmp_path / "s2.csv")] + history.toggle_record_mode(True) + objs = panel.load_from_files(sorted(good + bad), ignore_errors=True) + assert len(objs) == 2 + assert len(history) == 1 + action = history[len(history)] + assert action.method_name == "load_from_files" + assert action.title == _("Load from %d files") % 2 + assert action.kwargs["filenames"] == sorted(good) + assert len(action.output_uuids) == 2 + + +def test_load_from_files_single_success_updates_entry(tmp_path) -> None: + """Use the single-file title when only one file could be loaded.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + populate_signal_directory(win, tmp_path, {"": ["s1.csv"]}) + bad = write_unreadable_files(tmp_path, ["i1.png"]) + good = str(tmp_path / "s1.csv") + history.toggle_record_mode(True) + objs = panel.load_from_files(sorted([good] + bad), ignore_errors=True) + assert len(objs) == 1 + assert len(history) == 1 + action = history[len(history)] + assert action.title == _('Load "%s"') % "s1.csv" + assert action.kwargs["filenames"] == [good] + + +def record_group_gaussian_filter(win) -> tuple: + """Record a 1-to-1 compute applied to a whole image group. + + Args: + win: DataLab main window with history recording enabled + + Returns: + Recorded compute action and the list of source image UUIDs + """ + history, panel = win.historypanel, win.imagepanel + for _index in range(3): + panel.add_object(create_sincos_image()) + source_uuids = panel.objmodel.get_object_ids()[-3:] + group = panel.objmodel.get_group_from_object(panel.objmodel[source_uuids[0]]) + panel.objview.select_groups([get_uuid(group)]) + panel.processor.run_feature( + "gaussian_filter", sigima.params.GaussianParam.create(sigma=2.0) + ) + action = history[len(history)] + assert action.pattern == "1_to_1" + assert len(action.output_uuids) == 3 + return action, source_uuids + + +def test_replay_recreates_single_deleted_1_to_1_output() -> None: + """Re-create one deleted output of a group-wide 1-to-1 compute.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.imagepanel + history.toggle_record_mode(True) + action, _source_uuids = record_group_gaussian_filter(win) + recorded = list(action.output_uuids) + deleted_uuid = recorded[1] + survivors = [uuid for uuid in recorded if uuid != deleted_uuid] + survivor_group_id = panel.objmodel.get_object_group_id( + panel.objmodel[survivors[0]] + ) + expected_data = panel.objmodel[deleted_uuid].data.copy() + object_count = len(panel.objmodel) + survivor_identities = {uuid: id(panel.objmodel[uuid]) for uuid in survivors} + panel.objview.select_objects([deleted_uuid]) + panel.remove_object(force=True) + assert not panel.objmodel.has_uuid(deleted_uuid) + + with patch.object(hrec, "flush_cascade_warnings"): + hireplay.replay_actions(history, [action], prompt=False) + + assert history.runtime.execution.cascade_warnings == [] + assert action.is_stale is False + assert len(panel.objmodel) == object_count + assert action.output_uuids == recorded + # The deleted output is re-created under its recorded UUID, next to + # its surviving siblings + assert panel.objmodel.has_uuid(deleted_uuid) + recreated = panel.objmodel[deleted_uuid] + assert np.array_equal(recreated.data, expected_data) + assert panel.objmodel.get_object_group_id(recreated) == survivor_group_id + # Surviving outputs are updated in place (identity preserved) + for uuid in survivors: + assert id(panel.objmodel[uuid]) == survivor_identities[uuid] + + +def test_replay_recreates_all_deleted_1_to_1_outputs() -> None: + """Re-create every deleted output of a group-wide 1-to-1 compute.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.imagepanel + history.toggle_record_mode(True) + action, source_uuids = record_group_gaussian_filter(win) + recorded = list(action.output_uuids) + expected_data = [panel.objmodel[uuid].data.copy() for uuid in recorded] + object_count = len(panel.objmodel) + panel.objview.select_objects(recorded) + panel.remove_object(force=True) + assert all(not panel.objmodel.has_uuid(uuid) for uuid in recorded) + + with patch.object(hrec, "flush_cascade_warnings"): + hireplay.replay_actions(history, [action], prompt=False) + + assert history.runtime.execution.cascade_warnings == [] + assert action.is_stale is False + assert len(panel.objmodel) == object_count + assert action.output_uuids == recorded + for uuid, data in zip(recorded, expected_data): + assert panel.objmodel.has_uuid(uuid) + assert np.array_equal(panel.objmodel[uuid].data, data) + # Sources are untouched + assert all(panel.objmodel.has_uuid(uuid) for uuid in source_uuids) + + +def test_replay_recreates_deleted_pairwise_n_to_1_output() -> None: + """Re-create a deleted output of a pairwise n-to-1 compute.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + group_a = panel.add_group("group_a") + group_b = panel.add_group("group_b") + for group in (group_a, group_b): + for _index in range(2): + panel.add_object(create_paracetamol_signal(), group_id=get_uuid(group)) + panel.objview.select_groups([get_uuid(group_a), get_uuid(group_b)]) + feature = panel.processor.get_feature("average") + panel.processor.compute_n_to_1(feature.function, edit=False, pairwise=True) + action = history[len(history)] + assert action.pattern == "n_to_1" + assert action.kwargs.get("pairwise") is True + recorded = list(action.output_uuids) + assert len(recorded) == 2 + deleted_uuid = recorded[0] + expected_data = panel.objmodel[deleted_uuid].xydata.copy() + object_count = len(panel.objmodel) + panel.objview.select_objects([deleted_uuid]) + panel.remove_object(force=True) + assert not panel.objmodel.has_uuid(deleted_uuid) + + with patch.object(hrec, "flush_cascade_warnings"): + hireplay.replay_actions(history, [action], prompt=False) + + assert history.runtime.execution.cascade_warnings == [] + assert action.is_stale is False + assert len(panel.objmodel) == object_count + assert action.output_uuids == recorded + assert panel.objmodel.has_uuid(deleted_uuid) + assert np.array_equal(panel.objmodel[deleted_uuid].xydata, expected_data) diff --git a/datalab/tests/features/common/history_test_helpers.py b/datalab/tests/features/common/history_test_helpers.py new file mode 100644 index 000000000..fada09779 --- /dev/null +++ b/datalab/tests/features/common/history_test_helpers.py @@ -0,0 +1,172 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Typed builders and selectors for History panel tests.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import sigima.params +import sigima.proc.signal as sips +from qtpy import QtCore as QC +from qtpy import QtWidgets as QW +from sigima.tests.data import create_paracetamol_signal + +from datalab.h5.native import NativeH5Reader +from datalab.history.action import HistoryAction +from datalab.history.session import HistorySession +from datalab.history.workspace_state import WorkspaceState +from datalab.objectmodel import get_uuid + +if TYPE_CHECKING: + from datalab.gui.panel.history import HistoryPanel + from datalab.gui.panel.signal import SignalPanel + + +class CascadeObjectModel: + """Minimal object model for pure cascade recomputation tests.""" + + def __init__(self, objects: list[Any]) -> None: + self.objects = {get_uuid(obj): obj for obj in objects} + + def __getitem__(self, uuid: str) -> Any: + """Return the object identified by ``uuid``.""" + return self.objects[uuid] + + def __iter__(self): + """Iterate over the model's objects.""" + return iter(self.objects.values()) + + def has_uuid(self, uuid: str) -> bool: + """Return whether ``uuid`` exists in the model.""" + return uuid in self.objects + + def get_object_ids(self) -> list[str]: + """Return all object UUIDs in insertion order.""" + return list(self.objects) + + +@dataclass(frozen=True) +class SignalChain: + """Objects and actions produced by a three-step signal chain.""" + + actions: tuple[HistoryAction, HistoryAction, HistoryAction] + outputs: tuple[Any, Any, Any] + + +def build_workspace_state( + selection: list[str], titles: list[str] | None = None +) -> WorkspaceState: + """Build a signal workspace state with stable object metadata.""" + state = WorkspaceState() + state.selection = {"signal": selection} + state.states = {"signal": ["(10,)"] * len(selection)} + state.titles = {"signal": titles or [f"Object {index}" for index in selection]} + state.object_metadata = { + "signal": { + uuid: {"shape": [10], "ndim": 1, "title": title} + for uuid, title in zip(selection, state.titles["signal"]) + } + } + return state + + +def build_history_action() -> HistoryAction: + """Build a serializable compute action containing every UUID reference.""" + action = HistoryAction( + title="Difference", + kind=HistoryAction.KIND_COMPUTE, + panel_str="signal", + func_name="difference", + pattern="2_to_1", + kwargs={"obj2_uuids": ["second-uuid"], "pairwise": False}, + state=build_workspace_state(["source-uuid"], ["Source"]), + ) + action.output_uuids = ["output-uuid"] + return action + + +def add_paracetamol_signals(panel: SignalPanel, count: int) -> list[str]: + """Add paracetamol signals and return their UUIDs in panel order.""" + for _index in range(count): + panel.add_object(create_paracetamol_signal()) + return panel.objmodel.get_object_ids()[-count:] + + +def build_signal_chain(panel: SignalPanel, history: HistoryPanel) -> SignalChain: + """Build Gaussian, derivative and moving-average processing outputs.""" + add_paracetamol_signals(panel, 1) + panel.objview.select_objects([1]) + panel.processor.run_feature( + sips.gaussian_filter, sigima.params.GaussianParam.create(sigma=1.5) + ) + first_action = history[len(history)] + first_output = panel.objmodel.get_object_from_number(2) + panel.objview.select_objects([2]) + panel.processor.run_feature(sips.derivative) + second_action = history[len(history)] + second_output = panel.objmodel.get_object_from_number(3) + panel.objview.select_objects([3]) + parameter = sigima.params.MovingAverageParam.create(n=3) + panel.processor.run_feature(sips.moving_average, parameter) + third_action = history[len(history)] + third_output = panel.objmodel.get_object_from_number(4) + return SignalChain( + (first_action, second_action, third_action), + (first_output, second_output, third_output), + ) + + +def read_history_sessions( + path: str, section: str = "history_session" +) -> list[HistorySession]: + """Read serialized history sessions from a file.""" + with NativeH5Reader(path) as reader: + return reader.read_object_list(section, HistorySession) + + +def delete_hdf5_items_by_name(group: Any, item_name: str) -> None: + """Delete HDF5 attributes and groups with a name recursively.""" + if item_name in group.attrs: + del group.attrs[item_name] + if not hasattr(group, "keys"): + return + for key in list(group.keys()): + if key == item_name: + del group[key] + else: + delete_hdf5_items_by_name(group[key], item_name) + + +def get_tree_item(history: HistoryPanel, uuid: str) -> QW.QTreeWidgetItem: + """Return the tree item identified by an entry UUID.""" + iterator = QW.QTreeWidgetItemIterator(history.tree) + while iterator.value(): + item = iterator.value() + if item.data(0, QC.Qt.UserRole) == uuid: + return item + iterator += 1 + raise LookupError(uuid) + + +def select_tree_entry(history: HistoryPanel, uuid: str) -> None: + """Select the tree item identified by an entry UUID.""" + item = get_tree_item(history, uuid) + history.tree.clearSelection() + history.tree.setCurrentItem(item) + item.setSelected(True) + + +def select_tree_session(history: HistoryPanel, session: HistorySession) -> None: + """Select a session's top-level tree item.""" + item = history.tree.topLevelItem(history.history_sessions.index(session)) + history.tree.clearSelection() + history.tree.setCurrentItem(item) + item.setSelected(True) + + +def is_session_bold(history: HistoryPanel, session: HistorySession) -> bool: + """Return whether a session's tree item uses a bold font.""" + item = history.tree.topLevelItem(history.history_sessions.index(session)) + return item is not None and item.font(0).bold() diff --git a/datalab/tests/features/common/history_workflow_test.py b/datalab/tests/features/common/history_workflow_test.py new file mode 100644 index 000000000..f45f4a051 --- /dev/null +++ b/datalab/tests/features/common/history_workflow_test.py @@ -0,0 +1,1474 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Application workflow contracts for the History panel.""" + +from __future__ import annotations + +import copy +import os +import tempfile +from types import SimpleNamespace +from unittest.mock import patch + +import numpy as np +import pytest +import sigima.params +import sigima.proc.signal as sips +from sigima.objects import Gauss2DParam, create_signal_roi +from sigima.tests.data import ( + create_paracetamol_signal, + create_peak_image, + create_sincos_image, +) + +from datalab.adapters_metadata.common import ResultData +from datalab.config import Conf +from datalab.gui import historytools_ops as htools +from datalab.gui.creation import create_image_from_param, extract_creation_parameters +from datalab.gui.panel.history import HistoryAction +from datalab.gui.panel.history import chain as hchain +from datalab.gui.panel.history import interactive_replay as hireplay +from datalab.gui.panel.history import recompute as hrec +from datalab.gui.panel.history.chainmodel import ( + build_session_chains, + remap_processing_parameters, +) +from datalab.gui.processor.base import ( + ProcessingParameters, + extract_analysis_parameters, + extract_processing_parameters, + insert_processing_parameters, +) +from datalab.h5.native import NativeH5Reader, NativeH5Writer +from datalab.history.core import numpy_to_json_safe +from datalab.history.effects import AnalysisEffects +from datalab.objectmodel import get_uuid +from datalab.tests import datalab_test_app_context +from datalab.tests.features.common.history_test_helpers import ( + add_paracetamol_signals, + build_signal_chain, + get_tree_item, + read_history_sessions, + select_tree_entry, + select_tree_session, +) + +SIZE = 200 +SROI1 = [26, 41] +SROI2 = [125, 146] + + +def assert_compute_action( + action: HistoryAction, pattern: str, selection: list[str] +) -> None: + """Check the reusable recording invariant for a compute action.""" + assert action.kind == HistoryAction.KIND_COMPUTE + assert action.pattern == pattern + assert action.state.selection["signal"] == selection + assert action.output_uuids + + +def assert_duplicate_head(history, panel, session) -> None: + """Check the synthetic head of an operation-rooted duplicate.""" + head = session.actions[0] + assert head.kind == HistoryAction.KIND_UI + assert head.method_name == "new_object" + assert not head.kwargs and not head.state.selection + assert len(head.output_uuids) == 1 + clone_uuid = head.output_uuids[0] + assert history.runtime.objects.action_output_uuids[head.uuid] == [clone_uuid] + assert history.runtime.objects.output_to_action[clone_uuid] == head.uuid + assert clone_uuid in panel.objmodel.get_object_ids() + + +def build_independent_signal_branch(panel, history) -> tuple[HistoryAction, ...]: + """Build a three-action branch using UUID-based selections.""" + source_uuid = add_paracetamol_signals(panel, 1)[0] + panel.objview.select_objects([source_uuid]) + panel.processor.run_feature( + sips.gaussian_filter, sigima.params.GaussianParam.create(sigma=1.5) + ) + first_action = history[len(history)] + panel.objview.select_objects(first_action.output_uuids) + panel.processor.run_feature(sips.derivative) + second_action = history[len(history)] + panel.objview.select_objects(second_action.output_uuids) + panel.processor.run_feature( + sips.moving_average, sigima.params.MovingAverageParam.create(n=3) + ) + return first_action, second_action, history[len(history)] + + +def test_remap_processing_parameters_preserves_plugin_origin() -> None: + """Preserve plugin provenance while remapping processing source UUIDs.""" + plugin_origin = {"module": "test_plugin.operations", "directory": "test_plugin"} + parameters = ProcessingParameters( + func_name="difference", + pattern="2-to-1", + source_uuids=["source-1", "source-2"], + plugin_origin=plugin_origin, + ) + + remapped = remap_processing_parameters( + parameters, {"source-1": "copy-1", "source-2": "copy-2"} + ) + + assert remapped.source_uuids == ["copy-1", "copy-2"] + assert remapped.plugin_origin == plugin_origin + + +def test_history_recording_contract_and_output_index() -> None: + """Record producing patterns and index every output.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + source_uuids = add_paracetamol_signals(panel, 2) + panel.objview.select_objects([1]) + panel.processor.run_feature(sips.derivative) + derivative = history[len(history)] + panel.objview.select_objects([1, 2]) + panel.processor.run_feature(sips.average) + average = history[len(history)] + panel.objview.select_objects([1]) + panel.processor.run_feature( + sips.difference, panel.objmodel.get_object_from_number(2) + ) + difference = history[len(history)] + assert_compute_action(derivative, "1_to_1", [source_uuids[0]]) + assert_compute_action(average, "n_to_1", source_uuids) + assert_compute_action(difference, "2_to_1", [source_uuids[0]]) + assert difference.kwargs["obj2_uuids"] == [source_uuids[1]] + for action in (derivative, average, difference): + for output_uuid in action.output_uuids: + assert ( + history.runtime.objects.output_to_action[output_uuid] == action.uuid + ) + + +def test_history_hdf5_pristine_load_and_nonempty_import() -> None: + """Distinguish pristine loading, non-empty import and missing history.""" + with tempfile.TemporaryDirectory() as tmpdir: + history_path = os.path.join(tmpdir, "session.dlhist") + empty_path = os.path.join(tmpdir, "without_history.h5") + with datalab_test_app_context(history=True) as source: + history, panel = source.historypanel, source.signalpanel + history.toggle_record_mode(True) + add_paracetamol_signals(panel, 1) + panel.objview.select_objects([1]) + panel.processor.run_feature(sips.derivative) + titles = [action.title for action in history] + assert history.save_to_dlhist_file(history_path) + with NativeH5Writer(empty_path) as writer: + panel.serialize_to_hdf5(writer) + with datalab_test_app_context(history=True) as target: + history, panel = target.historypanel, target.signalpanel + with NativeH5Reader(empty_path) as reader: + history.deserialize_from_hdf5(reader) + assert len(history) == 0 + assert history.open_dlhist_file(history_path) + assert [action.title for action in history] == titles + assert history.runtime.objects.action_output_uuids + assert history.runtime.objects.output_to_action + with NativeH5Reader(empty_path) as reader: + history.deserialize_from_hdf5(reader) + assert not history.history_sessions + assert not history.runtime.objects.action_output_uuids + assert not history.runtime.objects.output_to_action + pristine_counts = (len(history.history_sessions), len(panel.objmodel)) + panel.add_object(create_paracetamol_signal()) + assert history.open_dlhist_file(history_path) + assert len(history.history_sessions) > pristine_counts[0] + assert len(panel.objmodel) > pristine_counts[1] + 1 + # Full history reset drops sessions, mappings, navigation and tree + assert isinstance(history.create_object(), HistoryAction) + history.remove_all_objects() + assert len(history) == 0 and not history.history_sessions + assert not history.runtime.objects.action_output_uuids + assert not history.runtime.objects.output_to_action + assert history.navigation.get_active_session() is None + assert history.tree.topLevelItemCount() == 0 + with pytest.raises(IndexError): + history[1] # pylint: disable=pointless-statement + + +def test_duplicate_creation_and_operation_rooted_chains() -> None: + """Duplicate both root kinds and synthesize a head only when required.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + win.add_object(create_paracetamol_signal()) + panel.objview.select_objects([1]) + panel.processor.run_feature(sips.derivative) + first_source = history.history_sessions[-1] + first_source.actions[-1].plugin_origin = { + "module": "example.plugin", + "metadata": {"entry_points": ["derivative"]}, + } + history.create_new_session() + win.add_object(create_paracetamol_signal()) + panel.objview.select_objects([3]) + panel.processor.run_feature(sips.derivative) + second_source = history.history_sessions[-1] + history.tree.clearSelection() + for source in (first_source, second_source): + source_item = history.tree.topLevelItem( + history.history_sessions.index(source) + ) + source_item.setSelected(True) + htools.duplicate_selected_entries(history) + first_duplicate = history.history_sessions[1] + second_duplicate = history.history_sessions[3] + assert history.history_sessions == [ + first_source, + first_duplicate, + second_source, + second_duplicate, + ] + duplicate = first_duplicate + assert len(duplicate.actions) == len(first_source.actions) + assert duplicate.actions[0].method_name == "new_object" + assert duplicate.actions[0].uuid != first_source.actions[0].uuid + assert set(duplicate.actions[0].output_uuids).isdisjoint( + first_source.actions[0].output_uuids + ) + duplicate_compute = duplicate.actions[-1] + assert duplicate_compute.plugin_origin == first_source.actions[-1].plugin_origin + duplicate_compute.plugin_origin["metadata"]["entry_points"].append("average") + assert first_source.actions[-1].plugin_origin["metadata"]["entry_points"] == [ + "derivative" + ] + duplicate_output = panel.objmodel[duplicate_compute.output_uuids[0]] + processing = extract_processing_parameters(duplicate_output) + assert processing is not None + assert processing.source_uuid == duplicate.actions[0].output_uuids[0] + assert history.runtime.objects.output_to_action[get_uuid(duplicate_output)] == ( + duplicate_compute.uuid + ) + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + add_paracetamol_signals(panel, 1) + panel.objview.select_objects([1]) + panel.processor.run_feature(sips.derivative) + original = history.history_sessions[-1] + select_tree_session(history, original) + htools.duplicate_selected_entries(history) + duplicate = history.history_sessions[-1] + assert len(duplicate.actions) == len(original.actions) + 1 + assert_duplicate_head(history, panel, duplicate) + chains = build_session_chains(duplicate) + assert len(chains) == 1 and chains[0].root is duplicate.actions[0] + + +def test_duplicate_clones_only_chain_objects() -> None: + """Duplicate clones only chain inputs/outputs, not unrelated objects.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + # Unrelated objects alive at record time (captured in workspace state) + unrelated = create_paracetamol_signal() + unrelated.title = "Unrelated signal" + panel.add_object(unrelated) + win.imagepanel.add_object(create_sincos_image()) + source_uuid = add_paracetamol_signals(panel, 1)[0] + panel.objview.select_objects([source_uuid]) + panel.processor.run_feature(sips.derivative) + original = history.history_sessions[-1] + signal_count_before = len(panel.objmodel) + image_count_before = len(win.imagepanel.objmodel) + image_group_count_before = len(win.imagepanel.objmodel.get_groups()) + title_count_before = sum( + panel.objmodel[uuid].title == unrelated.title + for uuid in panel.objmodel.get_object_ids() + ) + select_tree_session(history, original) + htools.duplicate_selected_entries(history) + # Only the chain source and its derivative output are cloned + assert len(panel.objmodel) == signal_count_before + 2 + title_count_after = sum( + panel.objmodel[uuid].title == unrelated.title + for uuid in panel.objmodel.get_object_ids() + ) + assert title_count_after == title_count_before + # Image panel is untouched + assert len(win.imagepanel.objmodel) == image_count_before + assert len(win.imagepanel.objmodel.get_groups()) == image_group_count_before + duplicate = history.history_sessions[-1] + original_outputs = { + uuid for action in original.actions for uuid in action.output_uuids + } + duplicate_outputs = { + uuid for action in duplicate.actions for uuid in action.output_uuids + } + assert duplicate_outputs.isdisjoint(original_outputs) + + +def test_edit_cascade_preserves_identity_and_action_state() -> None: + """Cascade in place while preserving identities, metadata and edit baseline.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + history.toggle_edit_mode(True) + chain = build_signal_chain(panel, history) + root_action, middle_action, leaf_action = chain.actions + root_output, middle_output, leaf_output = chain.outputs + plugin_origin = { + "module": "test_plugin.operations", + "directory": "test_plugin", + } + middle_action.plugin_origin = plugin_origin + leaf_action.plugin_origin = plugin_origin + panel.objview.select_objects([leaf_output]) + panel.processor.run_feature(sips.stats) + analysis_action = history[len(history)] + assert analysis_action.pattern == "1_to_0" + middle_parameters = extract_processing_parameters(middle_output) + assert middle_parameters is not None + middle_parameters.plugin_origin = plugin_origin + insert_processing_parameters(middle_output, middle_parameters) + middle_action.plugin_origin = None + analysis_action.plugin_origin = None + analysis_parameters = extract_analysis_parameters(leaf_output) + assert analysis_parameters is not None + analysis_parameters.plugin_origin = plugin_origin + leaf_output.set_metadata_option( + "analysis_parameters", analysis_parameters.to_dict() + ) + leaf_uuid = get_uuid(leaf_output) + leaf_number = panel.objmodel.get_number(leaf_output) + leaf_data = leaf_output.xydata.copy() + leaf_output.metadata["user_marker"] = 123 + panel.objview.select_objects([2]) + assert panel.objprop.setup_processing_tab(root_output, reset_params=False) + editor = panel.objprop.processing_param_editor + assert editor is not None + editor.dataset.sigma = 7.0 + with patch.object( + panel.processor, + "recompute_1_to_0", + wraps=panel.processor.recompute_1_to_0, + ) as recompute_analysis: + report = panel.objprop.apply_processing_parameters( + root_output, interactive=False + ) + recompute_analysis.assert_called_once() + assert recompute_analysis.call_args.kwargs["plugin_origin"] == plugin_origin + assert report.success and root_action.has_pending_edits + assert root_action.kwargs["param"].sigma == 7.0 + assert get_uuid(panel.objmodel[leaf_uuid]) == leaf_uuid + assert panel.objmodel.get_number(panel.objmodel[leaf_uuid]) == leaf_number + assert panel.objmodel[leaf_uuid].metadata["user_marker"] == 123 + assert not np.array_equal(panel.objmodel[leaf_uuid].xydata, leaf_data) + for output in (middle_output, leaf_output): + parameters = extract_processing_parameters(output) + assert parameters is not None + assert parameters.plugin_origin == plugin_origin + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "edited.dlhist") + assert history.save_to_dlhist_file(path) + sessions = read_history_sessions(path, history.H5_PREFIX) + restored = next( + action + for session in sessions + for action in session.actions + if action.uuid == root_action.uuid + ) + assert restored.has_pending_edits and restored.kwargs["param"].sigma == 7.0 + restored.restore_kwargs() + assert restored.kwargs["param"].sigma == 1.5 + assert leaf_action.is_stale is False + + +def test_edit_cascade_stops_after_failed_descendant() -> None: + """Keep a failed action and unexecuted analysis stale after cascade failure.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + add_paracetamol_signals(panel, 1) + panel.objview.select_objects([1]) + panel.processor.run_feature( + sips.gaussian_filter, sigima.params.GaussianParam.create(sigma=1.5) + ) + first_action = history[len(history)] + panel.objview.select_objects([2]) + panel.processor.run_feature(sips.derivative) + failed_action = history[len(history)] + failed_output = panel.objmodel[failed_action.output_uuids[0]] + failed_data = failed_output.xydata.copy() + panel.objview.select_objects([failed_action.output_uuids[0]]) + panel.processor.run_feature(sips.stats) + analysis_action = history[len(history)] + first_action.is_stale = True + original_compute = panel.processor.compute_1_to_1 + call_count = 0 + + def fail_second_compute(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 2: + return None # No output produced: the replay reconcile fails + return original_compute(*args, **kwargs) + + with ( + patch.object( + panel.processor, + "compute_1_to_1", + side_effect=fail_second_compute, + ), + patch.object(panel.processor, "recompute_1_to_0") as recompute_analysis, + ): + history.recompute_cascade(first_action) + + recompute_analysis.assert_not_called() + assert first_action.is_stale is False + assert failed_action.is_stale is True + assert analysis_action.is_stale is True + assert np.array_equal(failed_output.xydata, failed_data) + + +def test_multi_action_edit_single_session_planning() -> None: + """Plan selected ancestors, descendants and full-session selections once. + + Selected ancestors are prompted exactly once and their analysis descendant + is recomputed once; selecting the whole session plus one of its (stale) + actions routes through the global replay planner without duplicates. + """ + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + history.toggle_edit_mode(True) + chain = build_signal_chain(panel, history) + panel.objview.select_objects([chain.outputs[-1]]) + panel.processor.run_feature(sips.stats) + analysis_action = history[len(history)] + selected = [chain.actions[0], chain.actions[1]] + expected = [*chain.actions, analysis_action] + + with ( + patch.object( + hireplay, "prompt_edit_action_params", return_value=True + ) as prompt, + patch.object( + hrec, "recompute_action_in_place", return_value=True + ) as recompute, + ): + hireplay.replay_actions(history, selected) + + assert [call.args[1] for call in prompt.call_args_list] == selected + assert [call.args[1] for call in recompute.call_args_list] == expected + assert all(action.is_stale is False for action in expected) + + # Selecting the session plus one stale action plans each action once + session = history.history_sessions[-1] + session_expected = list(session.actions) + stale_action = session_expected[1] + stale_action.is_stale = True + select_tree_session(history, session) + get_tree_item(history, stale_action.uuid).setSelected(True) + selected_items = history.tree.get_selected_actions_or_sessions( + history.history_sessions + ) + assert selected_items == [session, stale_action] + + with ( + patch.object(hrec, "recompute_cascade") as direct_cascade, + patch.object( + hireplay, + "replay_actions", + wraps=hireplay.replay_actions, + ) as edit_planner, + patch.object( + hireplay, "prompt_edit_action_params", return_value=True + ) as prompt, + patch.object( + hrec, "recompute_action_in_place", return_value=True + ) as recompute, + ): + hireplay.replay_restore_actions(history) + + direct_cascade.assert_not_called() + edit_planner.assert_called_once_with( + history, [*session_expected, stale_action], prompt=True + ) + assert [call.args[1] for call in prompt.call_args_list] == session_expected + assert [call.args[1] for call in recompute.call_args_list] == session_expected + assert all(action.is_stale is False for action in session_expected) + + +def test_downstream_actions_follow_every_registered_output() -> None: + """Follow second registered outputs through transitive dependencies.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + producer, consumer, descendant = build_signal_chain(panel, history).actions + producer_second_output = "producer-second-output" + consumer_second_output = "consumer-second-output" + producer.output_uuids.append(producer_second_output) + consumer.output_uuids.append(consumer_second_output) + history.runtime.objects.action_output_uuids[producer.uuid] = list( + producer.output_uuids + ) + history.runtime.objects.action_output_uuids[consumer.uuid] = list( + consumer.output_uuids + ) + history.runtime.objects.output_to_action[producer_second_output] = producer.uuid + history.runtime.objects.output_to_action[consumer_second_output] = consumer.uuid + history.runtime.objects.prune_output_mapping() + assert producer_second_output in producer.output_uuids + assert consumer_second_output in consumer.output_uuids + assert ( + producer_second_output + not in (history.runtime.objects.action_output_uuids[producer.uuid]) + ) + assert ( + consumer_second_output + not in (history.runtime.objects.action_output_uuids[consumer.uuid]) + ) + consumer.state.selection["signal"] = [producer_second_output] + descendant.state.selection["signal"] = [consumer_second_output] + + assert hchain.get_downstream_actions(history, producer) == [ + consumer, + descendant, + ] + + +def test_multi_action_edit_cascades_across_independent_sessions() -> None: + """Recompute edited branches from multiple sessions in global order.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + history.toggle_edit_mode(True) + first_chain = build_independent_signal_branch(panel, history) + history.create_new_session() + second_chain = build_independent_signal_branch(panel, history) + selected = [second_chain[0], first_chain[0]] + expected = [*first_chain, *second_chain] + + with ( + patch.object(hireplay, "prompt_edit_action_params", return_value=True), + patch.object( + hrec, "recompute_action_in_place", return_value=True + ) as recompute, + ): + hireplay.replay_actions(history, selected) + + assert [call.args[1] for call in recompute.call_args_list] == expected + assert all(action.is_stale is False for action in expected) + + +def test_multi_action_edit_failure_skips_dependents_and_continues() -> None: + """Leave a failed branch stale while recomputing an independent session.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + history.toggle_edit_mode(True) + failed_chain = build_independent_signal_branch(panel, history) + history.create_new_session() + successful_chain = build_independent_signal_branch(panel, history) + failed_root = failed_chain[0] + failed_output_uuid = failed_root.output_uuids[0] + failed_root.output_uuids.clear() + history.runtime.objects.action_output_uuids.pop(failed_root.uuid) + history.runtime.objects.output_to_action.pop(failed_output_uuid) + failed_output = panel.objmodel[failed_output_uuid] + processing_parameters = extract_processing_parameters(failed_output) + assert not failed_root.output_uuids + assert failed_root.uuid not in history.runtime.objects.action_output_uuids + assert failed_output_uuid not in history.runtime.objects.output_to_action + assert processing_parameters is not None + assert processing_parameters.func_name == failed_root.func_name + assert hchain.recorded_action_output_uuids(history, failed_root) == [ + failed_output_uuid + ] + recomputed: list[HistoryAction] = [] + + def recompute_action(_panel, action): + recomputed.append(action) + return action is not failed_root + + with ( + patch.object(hireplay, "prompt_edit_action_params", return_value=True), + patch.object( + hrec, "recompute_action_in_place", side_effect=recompute_action + ), + ): + hireplay.replay_actions(history, [failed_root, successful_chain[0]]) + + assert recomputed == [failed_root, *successful_chain] + assert all(action.is_stale is True for action in failed_chain) + assert all(action.is_stale is False for action in successful_chain) + + +def test_multi_action_edit_cancel_restores_entry_pending_edit() -> None: + """Restore current kwargs and their saved baseline after a later cancel.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + history.toggle_edit_mode(True) + first_action, second_action = build_signal_chain(panel, history).actions[:2] + first_action.snapshot_kwargs() + first_action.kwargs["param"].sigma = 2.5 + + def prompt(_panel, action): + if action is first_action: + action.kwargs["param"].sigma = 3.5 + return True + return False + + with patch.object(hireplay, "prompt_edit_action_params", side_effect=prompt): + hireplay.replay_actions(history, [first_action, second_action]) + + assert first_action.kwargs["param"].sigma == 2.5 + assert first_action.saved_kwargs["param"].sigma == 1.5 + + +def test_multi_action_edit_cancel_skips_deferred_ui_replay() -> None: + """Do not replay noncompute UI actions when a later dialog is cancelled.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + history.toggle_edit_mode(True) + compute_action = build_signal_chain(panel, history).actions[0] + ui_action = HistoryAction( + title="Select next", + kind=HistoryAction.KIND_UI, + target="signalpanel", + method_name="select_next", + ) + + with ( + patch.object(ui_action, "replay") as replay, + patch.object(hireplay, "prompt_edit_action_params", return_value=False), + ): + hireplay.replay_actions(history, [ui_action, compute_action]) + + replay.assert_not_called() + + +def test_multi_action_edit_preserves_mixed_ui_compute_order() -> None: + """Execute deferred UI and planned compute actions in global session order.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + history.toggle_edit_mode(True) + source_uuid = add_paracetamol_signals(panel, 1)[0] + panel.objview.select_objects([source_uuid]) + panel.processor.run_feature(sips.derivative) + first_compute = history[len(history)] + ui_action = history.add_ui_entry("Select next", "signalpanel", "select_next") + assert ui_action is not None + panel.objview.select_objects(first_compute.output_uuids) + panel.processor.run_feature(sips.derivative) + second_compute = history[len(history)] + execution_order = [] + + def recompute(_panel, action): + execution_order.append(action) + return True + + def replay_ui(*_args, **_kwargs): + execution_order.append(ui_action) + + with ( + patch.object(hireplay, "prompt_edit_action_params", return_value=True), + patch.object(hrec, "recompute_action_in_place", side_effect=recompute), + patch.object(ui_action, "replay", side_effect=replay_ui), + ): + hireplay.replay_actions(history, [first_compute, ui_action]) + + assert execution_order == [first_compute, ui_action, second_compute] + + +def test_multi_action_edit_flushes_cascade_warnings_once() -> None: + """Flush warnings exactly once after executing a custom replay plan.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + history.toggle_edit_mode(True) + action = build_signal_chain(panel, history).actions[0] + + def recompute(_panel, _action): + history.runtime.execution.cascade_warnings.append("expected warning") + return True + + with ( + patch.object(hireplay, "prompt_edit_action_params", return_value=True), + patch.object(hrec, "recompute_action_in_place", side_effect=recompute), + patch.object( + hrec, + "flush_cascade_warnings", + wraps=hrec.flush_cascade_warnings, + ) as flush, + ): + hireplay.replay_actions(history, [action]) + + flush.assert_called_once_with(history) + assert history.runtime.execution.cascade_warnings == [] + + +def test_restore_failure_marks_action_stale_without_cascade() -> None: + """Stop restore recomputation when its root action fails.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + action = build_signal_chain(panel, history).actions[0] + action.snapshot_kwargs() + action.kwargs["param"].sigma = 7.0 + + with ( + patch.object(hrec, "recompute_action_in_place", return_value=False), + patch.object(hrec, "recompute_cascade") as recompute_cascade, + ): + hireplay.restore_action_params(history, action) + + recompute_cascade.assert_not_called() + assert action.is_stale is True + + +def test_restore_recomputes_stale_action_without_pending_edits() -> None: + """Recompute a stale action on restore even without pending edits.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + history.toggle_edit_mode(True) + action = build_signal_chain(panel, history).actions[0] + action.is_stale = True + assert not action.has_pending_edits + + with ( + patch.object( + hrec, "recompute_action_in_place", return_value=True + ) as recompute, + patch.object(hrec, "recompute_cascade") as recompute_cascade, + ): + hireplay.restore_action_params(history, action) + + recompute.assert_called_once_with(history, action) + recompute_cascade.assert_called_once_with(history, action) + assert action.is_stale is False + + +def test_empty_analysis_result_is_successful() -> None: + """Treat an executed analysis with no detections as successful.""" + with datalab_test_app_context(history=True) as win: + panel = win.signalpanel + panel.new_object(edit=False) + signal = panel.objview.get_current_object() + assert signal is not None + + with patch.object(panel.processor, "compute_1_to_0", return_value=ResultData()): + success = panel.processor.recompute_1_to_0("stats", signal) + + assert success is True + + +def test_legacy_resultdata_defaults_execution_success() -> None: + """Use the dataclass default when legacy state lacks execution_success.""" + result = ResultData() + del result.__dict__["execution_success"] + + assert result.execution_success is True + + +def test_2_to_1_failure_does_not_partially_mutate_outputs() -> None: + """Discard fresh outputs and warn on a replay cardinality mismatch.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + add_paracetamol_signals(panel, 4) + actions = [] + for first, second in ((1, 2), (3, 4)): + panel.objview.select_objects([first]) + panel.processor.run_feature( + sips.difference, panel.objmodel.get_object_from_number(second) + ) + actions.append(history[len(history)]) + action = actions[0] + action.output_uuids.extend(actions[1].output_uuids) + history.runtime.objects.action_output_uuids[action.uuid] = list( + action.output_uuids + ) + outputs = [panel.objmodel[uuid] for uuid in action.output_uuids] + original_data = [obj.xydata.copy() for obj in outputs] + object_count = len(panel.objmodel) + + # The synthetic action records two outputs but its captured selection + # only produces one: the replay must discard the fresh output and + # leave the recorded outputs untouched + success = hrec.recompute_action_in_place(history, action) + + assert success is False + assert action.is_stale is True + assert len(panel.objmodel) == object_count + assert any( + "expected 2" in warning + for warning in history.runtime.execution.cascade_warnings + ) + history.runtime.execution.cascade_warnings.clear() + for output, data in zip(outputs, original_data): + assert np.array_equal(output.xydata, data) + + +def test_2_to_1_refresh_failure_rolls_back_and_resyncs_outputs() -> None: + """Commit all 2-to-1 outputs before refresh and fully roll back on failure.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + add_paracetamol_signals(panel, 3) + panel.objview.select_objects([1, 2]) + panel.processor.run_feature( + sips.difference, panel.objmodel.get_object_from_number(3) + ) + action = history[len(history)] + assert len(action.output_uuids) == 2 + outputs = [panel.objmodel[uuid] for uuid in action.output_uuids] + identities = [id(obj) for obj in outputs] + # Mutate the outputs so a successful commit is distinguishable from a + # rollback restoring the pre-replay state + original_titles = [] + original_data = [] + for index, output in enumerate(outputs): + output.title = f"mutated-{index}" + output.xydata = output.xydata * 0.0 + original_titles.append(output.title) + original_data.append(output.xydata.copy()) + refresh_effects = [] + + def refresh_with_failure(_panel, output_uuid): + refresh_effects.append((output_uuid, [obj.title for obj in outputs])) + if len(refresh_effects) == 2: + raise RuntimeError(f"refresh failed #{len(refresh_effects)}") + + with patch.object(hrec, "refresh_target", side_effect=refresh_with_failure): + success = hrec.recompute_action_in_place(history, action) + + assert success is False + assert [effect[0] for effect in refresh_effects] == [ + action.output_uuids[0], + action.output_uuids[1], + action.output_uuids[0], + action.output_uuids[1], + ] + # Both outputs were committed before the first refresh... + assert all( + title != original + for title, original in zip(refresh_effects[0][1], original_titles) + ) + # ...and both were restored before the rollback refreshes + assert refresh_effects[2][1] == original_titles + for index, output in enumerate(outputs): + assert id(output) == identities[index] + assert output.title == original_titles[index] + assert np.array_equal(output.xydata, original_data[index]) + history.runtime.execution.cascade_warnings.clear() + + +def test_1_to_n_refresh_failure_rolls_back_and_resyncs_outputs() -> None: + """Commit all 1-to-n outputs before refresh and fully roll back on failure.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + source_uuid = add_paracetamol_signals(panel, 1)[0] + panel.objview.select_objects([source_uuid]) + feature = panel.processor.get_feature("gaussian_filter") + params = [ + sigima.params.GaussianParam.create(sigma=1.5), + sigima.params.GaussianParam.create(sigma=2.5), + ] + panel.processor.compute_1_to_n(feature.function, params=params, edit=False) + action = history[len(history)] + assert action.pattern == "1_to_n" + assert len(action.output_uuids) == 2 + outputs = [panel.objmodel[uuid] for uuid in action.output_uuids] + identities = [id(obj) for obj in outputs] + # Mutate the outputs so a successful commit is distinguishable from a + # rollback restoring the pre-replay state + original_titles = [] + original_data = [] + for index, output in enumerate(outputs): + output.title = f"mutated-{index}" + output.xydata = output.xydata * 0.0 + original_titles.append(output.title) + original_data.append(output.xydata.copy()) + refresh_effects = [] + + def refresh_with_failure(_panel, output_uuid): + refresh_effects.append((output_uuid, [obj.title for obj in outputs])) + if len(refresh_effects) == 2: + raise RuntimeError(f"refresh failed #{len(refresh_effects)}") + + with patch.object(hrec, "refresh_target", side_effect=refresh_with_failure): + success = hrec.recompute_action_in_place(history, action) + + assert success is False + assert [effect[0] for effect in refresh_effects] == [ + action.output_uuids[0], + action.output_uuids[1], + action.output_uuids[0], + action.output_uuids[1], + ] + assert all( + title != original + for title, original in zip(refresh_effects[0][1], original_titles) + ) + assert refresh_effects[2][1] == original_titles + for index, output in enumerate(outputs): + assert id(output) == identities[index] + assert output.title == original_titles[index] + assert np.array_equal(output.xydata, original_data[index]) + history.runtime.execution.cascade_warnings.clear() + + # Misaligned recording: with a params list longer than the recorded + # outputs, the replay produces more objects than expected and the + # cardinality guard rejects the recompute + action.kwargs["params"].append(sigima.params.GaussianParam.create(sigma=3.5)) + object_count = len(panel.objmodel) + assert hrec.recompute_action_in_place(history, action) is False + assert len(panel.objmodel) == object_count + assert any( + "expected 2" in warning + for warning in history.runtime.execution.cascade_warnings + ) + history.runtime.execution.cascade_warnings.clear() + + +def test_1_to_0_failure_rolls_back_all_source_metadata() -> None: + """Roll back analysis sources on failure, full-snapshot and targeted alike.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + source_uuids = add_paracetamol_signals(panel, 2) + panel.objview.select_objects([1, 2]) + panel.processor.run_feature(sips.stats) + action = history[len(history)] + sources = [panel.objmodel[uuid] for uuid in source_uuids] + for index, source in enumerate(sources): + source.metadata["user_marker"] = index + + call_count = 0 + + def fail_second_analysis(_func_name, source, _param, plugin_origin=None): + del plugin_origin + nonlocal call_count + call_count += 1 + source.metadata["temporary_analysis"] = call_count + return call_count == 1 + + with patch.object( + panel.processor, + "recompute_1_to_0", + side_effect=fail_second_analysis, + ): + success = hrec.recompute_action_in_place(history, action) + + assert success is False + for index, source in enumerate(sources): + assert source.metadata["user_marker"] == index + assert "temporary_analysis" not in source.metadata + + # Manifest-driven analysis: a failed recompute rolls back only the + # manifest keys and leaves unrelated user metadata untouched + image_panel = win.imagepanel + img = create_peak_image() + image_panel.add_object(img) + det_param = sigima.params.Peak2DDetectionParam.create( + create_rois=False, threshold=0.5 + ) + with Conf.proc.show_result_dialog.temp(False): + image_panel.processor.run_feature("peak_detection", det_param) + img_action = history[len(history)] + img_uuid = get_uuid(img) + manifest = AnalysisEffects.from_dict(img_action.effects[img_uuid]) + manifest_keys = manifest.metadata_added + manifest.metadata_replaced + geometry_key = next(key for key in manifest_keys if key.startswith("Geometry_")) + # Simulate a user having deleted one analysis result key beforehand + del img.metadata[geometry_key] + present_key = next(key for key in manifest_keys if key in img.metadata) + value_before = copy.deepcopy(img.metadata[present_key]) + img.metadata["user_marker"] = 123 + effects_before = copy.deepcopy(img_action.effects) + + def failing_recompute(_func_name, obj, _param, plugin_origin=None): + del plugin_origin + obj.metadata[geometry_key] = "recreated-by-failed-attempt" + obj.metadata[present_key] = "corrupted" + raise RuntimeError("forced recompute failure") + + with patch.object( + image_panel.processor, "recompute_1_to_0", side_effect=failing_recompute + ): + try: + hrec.recompute_1_to_0_in_place(history, img_action) + except RuntimeError: + pass + else: + raise AssertionError("RuntimeError should have propagated") + + assert img.metadata["user_marker"] == 123, "Unrelated key must be untouched" + assert geometry_key not in img.metadata, ( + "Manifest key absent before the recompute must be deleted on rollback" + ) + assert img.metadata[present_key] == value_before, ( + "Manifest key must be restored to its pre-recompute value" + ) + assert img_action.effects == effects_before, "Manifest must be unchanged" + + +def test_1_to_0_cascade_uses_roi_safe_parameter_copy() -> None: + """Disable ROI creation on a copy during analysis cascade recomputation.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + source_uuid = add_paracetamol_signals(panel, 1)[0] + param = SimpleNamespace(create_rois=True) + action = HistoryAction() + action.kind = HistoryAction.KIND_COMPUTE + action.pattern = "1_to_0" + action.target = "signalpanel" + action.panel_str = "signal" + action.func_name = "stats" + action.kwargs = {"param": param} + action.state.selection = {panel.PANEL_STR_ID: [source_uuid]} + + # The guard now lives inside recompute_1_to_0: spy on compute_1_to_0 + # to observe the parameter actually passed to the executed analysis + with patch.object( + panel.processor, + "compute_1_to_0", + return_value=SimpleNamespace(execution_success=True), + ) as compute: + success = hrec.recompute_action_in_place(history, action) + + assert success is True + passed_param = compute.call_args.args[1] + assert passed_param is not param + assert passed_param.create_rois is False + assert action.kwargs["param"].create_rois is True + + +def test_replay_recreates_deleted_output_under_recorded_uuid() -> None: + """Re-create a deleted compute output under its recorded UUID on replay.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + source_uuid = add_paracetamol_signals(panel, 1)[0] + panel.objview.select_objects([source_uuid]) + panel.processor.run_feature(sips.derivative) + action = history[len(history)] + output_uuid = action.output_uuids[0] + expected_data = panel.objmodel[output_uuid].xydata.copy() + object_count = len(panel.objmodel) + panel.objview.select_objects([output_uuid]) + panel.remove_object(force=True) + assert not panel.objmodel.has_uuid(output_uuid) + assert action.output_uuids == [output_uuid] + + with patch.object(hrec, "flush_cascade_warnings"): + hireplay.replay_actions(history, [action], prompt=False) + + assert history.runtime.execution.cascade_warnings == [] + assert action.is_stale is False + assert len(panel.objmodel) == object_count + assert panel.objmodel.has_uuid(output_uuid) + recreated = panel.objmodel[output_uuid] + assert get_uuid(recreated) == output_uuid + assert np.array_equal(recreated.xydata, expected_data) + assert history.runtime.objects.output_to_action[output_uuid] == action.uuid + assert history.runtime.objects.action_output_uuids[action.uuid] == [output_uuid] + parameters = extract_processing_parameters(recreated) + assert parameters is not None + assert parameters.source_uuid == source_uuid + + +def test_deletion_reconnects_and_splices_chain() -> None: + """Reconnect after data deletion, then splice the producing action.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + win.add_object(create_paracetamol_signal()) + source_uuid = get_uuid(panel.objmodel.get_object_from_number(1)) + panel.objview.select_objects([1]) + panel.processor.run_feature( + sips.normalize, sigima.params.NormalizeParam.create(method="maximum") + ) + normalize = history[len(history)] + intermediate_uuid = normalize.output_uuids[0] + panel.objview.select_objects([2]) + panel.processor.run_feature(sips.derivative) + derivative = history[len(history)] + panel.objview.select_objects([2]) + panel.remove_object(force=True) + assert intermediate_uuid not in derivative.state.selection["signal"] + assert source_uuid in derivative.state.selection["signal"] + panel.objview.select_objects([source_uuid]) + panel.processor.run_feature( + sips.normalize, sigima.params.NormalizeParam.create(method="maximum") + ) + action_to_delete = history[len(history)] + panel.objview.select_objects([action_to_delete.output_uuids[0]]) + panel.processor.run_feature(sips.derivative) + downstream_action = history[len(history)] + session = history.history_sessions[-1] + object_count = len(panel.objmodel) + select_tree_entry(history, action_to_delete.uuid) + htools.delete_selected(history) + assert action_to_delete not in session.actions + assert action_to_delete.uuid not in history.runtime.objects.action_output_uuids + assert ( + action_to_delete.output_uuids[0] + not in history.runtime.objects.output_to_action + ) + assert derivative in session.actions and downstream_action in session.actions + assert len(panel.objmodel) == object_count + 1 + assert ( + action_to_delete.output_uuids[0] + not in downstream_action.state.selection["signal"] + ) + chains = build_session_chains(session) + assert sum(len(chain.actions) for chain in chains) == len(session.actions) + # Orphan cleanup: unattended runs never auto-remove orphans; explicit + # removal purges the surviving output object + orphan_uuid = action_to_delete.output_uuids[0] + assert panel.objmodel.has_uuid(orphan_uuid) + assert not htools.confirm_orphan_removal(history, [(panel, orphan_uuid)]) + htools.remove_orphan_objects(history, [(panel, orphan_uuid)]) + assert not panel.objmodel.has_uuid(orphan_uuid) + assert len(panel.objmodel) == object_count + # Leaf deletion: no downstream chain to split, output object survives + leaf_output_uuid = downstream_action.output_uuids[0] + select_tree_entry(history, downstream_action.uuid) + htools.delete_selected(history) + assert downstream_action not in session.actions + assert panel.objmodel.has_uuid(leaf_output_uuid) + # Deleting a producer whose output is already gone yields no orphan + derivative_output_uuid = derivative.output_uuids[0] + panel.objview.select_objects([derivative_output_uuid]) + panel.remove_object(force=True) + assert derivative in session.actions + # Keep the session alive with a fresh action before splicing the last + # original one out + panel.objview.select_objects([source_uuid]) + panel.processor.run_feature(sips.derivative) + assert history[len(history)] in session.actions + object_count_before = len(panel.objmodel) + select_tree_entry(history, derivative.uuid) + htools.delete_selected(history) + assert derivative not in session.actions + assert len(panel.objmodel) == object_count_before + removed_action_uuids = [action.uuid for action in session.actions] + removed_output_uuids = [ + output_uuid + for action in session.actions + for output_uuid in action.output_uuids + ] + select_tree_session(history, session) + htools.delete_selected(history) + assert session not in history.history_sessions + assert all( + action_uuid not in history.runtime.objects.action_output_uuids + for action_uuid in removed_action_uuids + ) + assert all( + output_uuid not in history.runtime.objects.output_to_action + for output_uuid in removed_output_uuids + ) + + +def test_replay_survives_unexpected_recompute_exception() -> None: + """Contain unexpected exceptions and warn about pattern-less computes.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + history.toggle_edit_mode(True) + acts = list(build_signal_chain(panel, history).actions) + patternless = HistoryAction( + title="Legacy compute", + kind=HistoryAction.KIND_COMPUTE, + panel_str="signal", + func_name="mystery", + pattern=None, + ) + history.history_sessions[-1].add_action(patternless) + original = hrec.recompute_compute_in_place + + def flaky(panel_, action_): + if action_ is acts[1]: + raise IndexError("boom") + return original(panel_, action_) + + with ( + patch.object(hireplay, "prompt_edit_action_params", return_value=True), + patch.object(hrec, "recompute_compute_in_place", flaky), + patch.object(hrec, "flush_cascade_warnings") as flush, + ): + hireplay.replay_actions(history, [*acts, patternless], prompt=True) + + assert acts[0].is_stale is False + assert acts[1].is_stale is True # failed action stays flagged + assert acts[2].is_stale is True # downstream blocked by the failure + flush.assert_called() + warnings = history.runtime.execution.cascade_warnings + assert any("boom" in w for w in warnings) + assert any("mystery" in w for w in warnings) + + +def test_analysis_effects_manifest_populated_and_recomputed() -> None: + """Populate the effects manifest by a 1-to-0 analysis and keep it stable.""" + with datalab_test_app_context(console=False, history=True) as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.imagepanel + img = create_peak_image() + panel.add_object(img) + det_param = sigima.params.Peak2DDetectionParam.create( + create_rois=True, threshold=0.5 + ) + with Conf.proc.show_result_dialog.temp(False): + panel.processor.run_feature("peak_detection", det_param) + action = history[len(history)] + assert action.effects is not None, "1-to-0 action must carry effects" + src_uuid = get_uuid(img) + assert src_uuid in action.effects + manifest = AnalysisEffects.from_dict(action.effects[src_uuid]) + assert any( + key.startswith("Geometry_") and key.endswith("_dict") + for key in manifest.metadata_added + ), f"Expected a Geometry_*_dict key, got {manifest.metadata_added}" + assert manifest.roi_modified is True, "Detection ROIs must flag roi_modified" + added_before = manifest.metadata_added + # A history recompute keeps first-run keys under metadata_added + assert hrec.recompute_1_to_0_in_place(history, action) is True + manifest = AnalysisEffects.from_dict(action.effects[src_uuid]) + assert set(added_before) <= set(manifest.metadata_added), ( + "First-run keys must stay under metadata_added after recompute" + ) + assert not set(added_before) & set(manifest.metadata_replaced) + + +def test_roi_mutation_recording_replay_and_partial_targets() -> None: + """Record paste/delete ROI mutations, replay them, tolerate deleted targets.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + sig1 = create_paracetamol_signal(SIZE) + sig1.roi = create_signal_roi([SROI1, SROI2], indices=True) + panel.add_object(sig1) + sig2 = create_paracetamol_signal(SIZE) + panel.add_object(sig2) + sig2 = panel.objmodel[get_uuid(sig2)] + sig3 = create_paracetamol_signal(SIZE) + panel.add_object(sig3) + sig3 = panel.objmodel[get_uuid(sig3)] + # Paste onto two targets: one mutation entry per object carrying the + # post-combination ROI payload + panel.objview.select_objects([1]) + panel.copy_roi() + panel.objview.select_objects([2, 3]) + panel.paste_roi() + actions = history.history_sessions[-1].actions + paste2, paste3 = actions[-2], actions[-1] + for action, sig in ((paste2, sig2), (paste3, sig3)): + assert action.kind == HistoryAction.KIND_MUTATION + assert action.mutation_key == "roi" + assert action.target_uuids == [get_uuid(sig)] + payload = action.kwargs.get("payload") + assert payload is not None + assert numpy_to_json_safe(payload.to_dict()) == numpy_to_json_safe( + sig.roi.to_dict() + ) + # Direct replay re-applies the payload after the ROI was cleared + sig2.roi = None + paste2.replay(win, restore_selection=True, edit=False) + assert sig2.roi is not None + assert numpy_to_json_safe(sig2.roi.to_dict()) == numpy_to_json_safe( + paste2.kwargs["payload"].to_dict() + ) + # Deleting ROIs records one empty-payload mutation for both targets + panel.objview.select_objects([2, 3]) + panel.processor.delete_regions_of_interest() + assert sig2.roi is None and sig3.roi is None + delete_action = history.history_sessions[-1].actions[-1] + assert delete_action.kind == HistoryAction.KIND_MUTATION + assert delete_action.mutation_key == "roi" + assert delete_action.kwargs.get("payload") is None + assert set(delete_action.target_uuids) == {get_uuid(sig2), get_uuid(sig3)} + # Replaying the recorded sequence restores then removes the ROI + paste2.replay(win, restore_selection=True, edit=False) + assert sig2.roi is not None + delete_action.replay(win, restore_selection=True, edit=False) + assert sig2.roi is None + # Cascade recompute tolerates a deleted target: warn and apply to the rest + sig2.roi = create_signal_roi([SROI1], indices=True) + panel.objview.select_objects([get_uuid(sig3)]) + panel.remove_object(force=True) + assert hrec.recompute_mutation_in_place(history, delete_action) is True + assert sig2.roi is None + assert any( + "deleted" in warning + for warning in history.runtime.execution.cascade_warnings + ) + history.runtime.execution.cascade_warnings.clear() + + +def test_cascade_reapplies_roi_mutation() -> None: + """Cascade recompute re-applies, blocks or edits a downstream ROI mutation.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + sig1 = create_paracetamol_signal(SIZE) + sig1.roi = create_signal_roi([SROI1, SROI2], indices=True) + panel.add_object(sig1) + src = create_paracetamol_signal(SIZE) + panel.add_object(src) + # Record a compute action producing the output object + panel.objview.select_objects([2]) + panel.processor.run_feature(sips.derivative) + compute_action = history[len(history)] + output = panel.objmodel[compute_action.output_uuids[0]] + # Paste a ROI onto the compute output (records a mutation action) + panel.objview.select_objects([1]) + panel.copy_roi() + panel.objview.select_objects([get_uuid(output)]) + panel.paste_roi() + mutation_action = history.history_sessions[-1].actions[-1] + assert mutation_action.kind == HistoryAction.KIND_MUTATION + # The mutation belongs to the compute action's downstream closure + downstream = hchain.get_downstream_actions(history, compute_action) + assert mutation_action in downstream + # Wipe the ROI, then recompute the cascade from the compute action + output.roi = None + history.recompute_cascade(compute_action) + assert output.roi is not None + assert numpy_to_json_safe(output.roi.to_dict()) == numpy_to_json_safe( + mutation_action.kwargs["payload"].to_dict() + ) + assert mutation_action.is_stale is False + # A failed upstream recompute blocks the deferred mutation replay + output.roi = None + with ( + patch.object(hrec, "recompute_action_in_place", return_value=False), + patch.object(hrec, "flush_cascade_warnings"), + ): + hireplay.replay_actions( + history, [compute_action, mutation_action], prompt=False + ) + assert output.roi is None + assert compute_action.is_stale is True + compute_action.is_stale = False + history.runtime.execution.cascade_warnings.clear() + + # An edited mutation payload triggers a downstream cascade recompute + def edit_payload(_mainwindow, restore_selection=True, edit=False): + del restore_selection + assert edit is True + mutation_action.kwargs["payload"] = mutation_action.kwargs["payload"].copy() + + with ( + patch.object(mutation_action, "replay", side_effect=edit_payload), + patch.object(hrec, "recompute_cascade") as cascade, + ): + hireplay.replay_actions(history, [mutation_action], prompt=True) + cascade.assert_called_once_with(history, mutation_action) + + +def test_mutation_root_has_downstream_computes() -> None: + """A mutation root seeds its targets: consuming computes are downstream.""" + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.signalpanel + history.toggle_record_mode(True) + sig1 = create_paracetamol_signal(SIZE) + sig1.roi = create_signal_roi([SROI1], indices=True) + panel.add_object(sig1) + sig2 = create_paracetamol_signal(SIZE) + panel.add_object(sig2) + panel.objview.select_objects([1]) + panel.copy_roi() + panel.objview.select_objects([2]) + panel.paste_roi() + mutation_action = history.history_sessions[-1].actions[-1] + assert mutation_action.kind == HistoryAction.KIND_MUTATION + # Compute consuming the mutated object is downstream of the mutation + panel.objview.select_objects([2]) + panel.processor.run_feature(sips.derivative) + compute_action = history.history_sessions[-1].actions[-1] + assert compute_action.kind == HistoryAction.KIND_COMPUTE + downstream = hchain.get_downstream_actions(history, mutation_action) + assert compute_action in downstream + + +def test_edited_image_creation_recomputes_downstream_in_place() -> None: + """Regenerate an edited image before recomputing its existing descendant.""" + initial_param = Gauss2DParam.create( + title="Initial Gaussian", + height=24, + width=28, + x0=-4.0, + y0=2.0, + sigma=1.2, + a=25.0, + ) + edited_param = Gauss2DParam.create( + title="Edited Gaussian", + height=24, + width=28, + x0=3.0, + y0=-2.0, + sigma=3.5, + a=80.0, + ) + expected_source = create_image_from_param(edited_param) + with datalab_test_app_context(history=True) as win: + history, panel = win.historypanel, win.imagepanel + history.toggle_record_mode(True) + source = create_image_from_param(initial_param) + source_uuid = get_uuid(source) + panel.add_object(source) + source = panel.objmodel[source_uuid] + source_identity = id(source) + initial_source_data = source.data.copy() + # Synthetic creation action carrying the edited parameters (the + # recorded output is the pre-existing source object) + creation_action = HistoryAction( + title="Create edited Gaussian", + kind=HistoryAction.KIND_UI, + target="imagepanel", + method_name="new_object", + kwargs={"param": edited_param}, + ) + creation_action.output_uuids = [source_uuid] + # Record the real downstream compute through the UI path + panel.objview.select_objects([source_uuid]) + panel.processor.run_feature( + "gaussian_filter", sigima.params.GaussianParam.create(sigma=2.0) + ) + downstream_action = history[len(history)] + downstream_uuid = downstream_action.output_uuids[0] + downstream = panel.objmodel[downstream_uuid] + downstream_identity = id(downstream) + initial_downstream_data = downstream.data.copy() + + with patch.object( + hrec, "create_image_from_param", wraps=create_image_from_param + ) as create_image_mock: + assert hrec.recompute_creation_in_place(history, creation_action) + assert hrec.recompute_compute_in_place(history, downstream_action) + + create_image_mock.assert_called_once() + assert create_image_mock.call_args.args[0] is edited_param + assert history.runtime.execution.cascade_warnings == [] + + assert panel.objmodel[source_uuid] is source + assert id(source) == source_identity + np.testing.assert_allclose(source.data, expected_source.data) + assert not np.array_equal(source.data, initial_source_data) + creation_param = extract_creation_parameters(source) + assert isinstance(creation_param, Gauss2DParam) + for name in ("height", "width", "x0", "y0", "sigma", "a"): + assert getattr(creation_param, name) == getattr(edited_param, name) + + assert panel.objmodel[downstream_uuid] is downstream + assert id(downstream) == downstream_identity + assert not np.array_equal(downstream.data, initial_downstream_data) + downstream_params = extract_processing_parameters(downstream) + assert downstream_params is not None + assert downstream_params.source_uuid == source_uuid diff --git a/datalab/tests/features/common/interactive_processing_test.py b/datalab/tests/features/common/interactive_processing_test.py index 8765f849a..f1d395dd8 100644 --- a/datalab/tests/features/common/interactive_processing_test.py +++ b/datalab/tests/features/common/interactive_processing_test.py @@ -40,6 +40,7 @@ from sigima.tools.signal import fitting from sigima.tools.signal.pulse import GaussianModel, LegacyPeakParameterizationError +from datalab.adapters_metadata.common import ResultData from datalab.config import Conf from datalab.env import execenv from datalab.gui.newobject import ( @@ -50,11 +51,12 @@ ) from datalab.gui.processor.base import ( PROCESSING_PARAMETERS_OPTION, + _detect_plugin_origin, extract_analysis_parameters, extract_processing_parameters, ) from datalab.gui.processor.catcher import CompOut -from datalab.objectmodel import get_uuid +from datalab.objectmodel import get_short_id, get_uuid from datalab.tests import datalab_test_app_context @@ -168,7 +170,7 @@ def test_processing_without_parameters(): def test_recompute(): """Test recompute feature for signals""" with qt_app_context(): - with datalab_test_app_context() as win: + with datalab_test_app_context(history=True) as win: panel = win.signalpanel processor = panel.processor @@ -183,6 +185,10 @@ def test_recompute(): filtered_sig = panel.objview.get_current_object() original_data = filtered_sig.y.copy() + # In-place recompute requires History panel edit mode (otherwise a + # new object is created instead of mutating the existing one). + win.historypanel.toggle_edit_mode(True) + # Recompute with different input signal data constant = 1.23098765 signal.y += constant @@ -251,6 +257,146 @@ def record_recompute_analysis(*args, **kwargs): ) +def test_plugin_analysis_origin_is_stored_and_reused(): + """Test plugin provenance storage and reuse for 1-to-0 analyses.""" + with qt_app_context(): + with datalab_test_app_context(history=True) as win: + panel = win.signalpanel + processor = panel.processor + objprop = panel.objprop + plugin_origin = { + "plugin_class": "TestPlugin", + "module": "test_plugin.operations", + "directory": "test_plugin", + "version": "1.0", + } + + stats_func = processor.get_feature("stats").function + feature = processor.register_1_to_0(stats_func, "Plugin statistics") + feature.plugin_origin = plugin_origin + + panel.new_object(edit=False) + signal = panel.objview.get_current_object() + assert signal is not None + processor.run_feature(feature) + + proc_params = extract_analysis_parameters(signal) + assert proc_params is not None + assert proc_params.plugin_origin == plugin_origin + + calls = [] + original_recompute_1_to_0 = processor.recompute_1_to_0 + + def record_recompute_1_to_0(*args, **kwargs): + calls.append((args, kwargs)) + return original_recompute_1_to_0(*args, **kwargs) + + processor.recompute_1_to_0 = record_recompute_1_to_0 + try: + objprop.apply_analysis_parameters(signal, interactive=False) + processor.recompute_analysis(signal) + finally: + processor.recompute_1_to_0 = original_recompute_1_to_0 + + assert len(calls) == 2 + for _args, kwargs in calls: + assert kwargs["plugin_origin"] == plugin_origin + + +def test_wrapped_plugin_origin_uses_inner_callable_file() -> None: + """Use the origin candidate for both module and directory detection.""" + + def plugin_function(): + pass + + plugin_function.__module__ = "wrapped_plugin.operations" + + def wrapper(): + pass + + wrapper.__module__ = "datalab.gui.processor.base" + wrapper.__wrapped__ = plugin_function + + with ( + patch("datalab.plugins.PluginRegistry.get_plugins", return_value=[]), + patch( + "datalab.gui.processor.base.inspect.getfile", + return_value="/plugins/wrapped_plugin/operations.py", + ) as getfile, + ): + origin = _detect_plugin_origin(wrapper) + + assert origin is not None + assert origin["module"] == "wrapped_plugin.operations" + assert origin["directory"] == "wrapped_plugin" + getfile.assert_called_once_with(plugin_function) + + +def test_analysis_persistence_failure_is_isolated_per_object() -> None: + """Roll back one failed analysis and continue with the next object.""" + with qt_app_context(): + with datalab_test_app_context() as win: + panel = win.signalpanel + processor = panel.processor + panel.new_object(edit=False) + first = panel.objview.get_current_object() + panel.new_object(edit=False) + second = panel.objview.get_current_object() + assert first is not None and second is not None + first.metadata["existing"] = {"value": 1} + first_metadata = first.metadata.copy() + appended = [] + first_append_lengths: tuple[int, int, int] = () + original_append = ResultData.append + + def fail_after_first_append(rdata, adapter, obj): + nonlocal first_append_lengths + original_append(rdata, adapter, obj) + appended.append((adapter, obj)) + if obj is first: + first_append_lengths = ( + len(rdata.results), + len(rdata.ylabels), + len(rdata.short_ids), + ) + raise RuntimeError("Expected persistence error") + + stats_func = processor.get_feature("stats").function + with ( + execenv.context(catcher_test=True), + patch.object( + ResultData, + "append", + new=fail_after_first_append, + ), + patch("datalab.gui.processor.base.show_warning_error") as show_error, + ): + result = processor.compute_1_to_0( + stats_func, + edit=False, + target_objs=[first, second], + ) + + assert result is not None + assert result.execution_success is False + assert [obj for _adapter, obj in appended] == [first, second] + assert first_append_lengths + assert all(count > 0 for count in first_append_lengths) + assert first.metadata == first_metadata + assert extract_analysis_parameters(first) is None + assert extract_analysis_parameters(second) is not None + second_adapter = appended[1][0] + second_short_id = get_short_id(second) + assert result.results == [second_adapter] + assert result.ylabels == [f"{second_adapter.func_name}({second_short_id})"] + assert result.short_ids == [second_short_id] + error_calls = [ + call for call in show_error.call_args_list if call.args[1] == "error" + ] + assert len(error_calls) == 1 + assert "Expected persistence error" in error_calls[0].args[3] + + def test_recompute_selected_continues_after_1_to_1_error(): """Test that an ordinary 1-to-1 error is not treated as cancellation.""" with qt_app_context(): @@ -273,7 +419,10 @@ def test_recompute_selected_continues_after_1_to_1_error(): processing_count = [0] analysis_objects = [] - def recompute_1_to_1_with_first_error(_func_name, source_obj, _param): + def recompute_1_to_1_with_first_error( + _func_name, source_obj, _param, plugin_origin=None + ): + del plugin_origin processing_count[0] += 1 if processing_count[0] == 1: return CompOut(error_msg="Expected computation error") @@ -281,6 +430,7 @@ def recompute_1_to_1_with_first_error(_func_name, source_obj, _param): def record_recompute_analysis(obj, *_args, **_kwargs): analysis_objects.append(obj) + return True processor.recompute_1_to_1 = recompute_1_to_1_with_first_error processor.recompute_analysis = record_recompute_analysis @@ -303,6 +453,60 @@ def record_recompute_analysis(obj, *_args, **_kwargs): assert analysis_objects == [processed_images[1]] +def test_apply_analysis_parameters_failure_preserves_action() -> None: + """Do not record or announce a failed direct analysis recomputation.""" + with qt_app_context(): + with datalab_test_app_context(history=True) as win: + panel = win.signalpanel + history = win.historypanel + history.toggle_record_mode(True) + panel.new_object(edit=False) + signal = panel.objview.get_current_object() + assert signal is not None + panel.processor.run_feature("stats") + action = history[len(history)] + original_kwargs = action.kwargs.copy() + status_messages = [] + panel.SIG_STATUS_MESSAGE.connect(status_messages.append) + + with patch.object(panel.processor, "recompute_1_to_0", return_value=False): + success = panel.objprop.apply_analysis_parameters( + signal, interactive=False + ) + + assert success is False + assert action.kwargs == original_kwargs + assert status_messages == [] + + +def test_recompute_analyses_continues_after_failure_unattended() -> None: + """Continue with later analyses after an unattended object failure.""" + with qt_app_context(): + with datalab_test_app_context() as win: + panel = win.signalpanel + panel.new_object(edit=False) + first = panel.objview.get_current_object() + panel.new_object(edit=False) + second = panel.objview.get_current_object() + assert first is not None and second is not None + + with ( + execenv.context(unattended=True), + patch.object( + panel.processor, + "recompute_analysis", + side_effect=[False, True], + ) as recompute_analysis, + ): + recomputed, interrupted = panel.recompute_1_to_0_objects( + [first, second] + ) + + assert recompute_analysis.call_count == 2 + assert recomputed == {get_uuid(second)} + assert interrupted is False + + def test_apply_creation_parameters_signal(): """Test apply_creation_parameters for signals""" with qt_app_context(): @@ -374,11 +578,10 @@ def test_convert_legacy_creation_parameters_signal(): assert objprop.creation_param_editor is None assert objprop.creation_scroll is not None - buttons = { - button.text(): button - for button in objprop.creation_scroll.findChildren(QW.QPushButton) - } - buttons["Cancel"].click() + buttons = objprop.creation_scroll.findChildren(QW.QPushButton) + assert len(buttons) == 2 + convert_button, cancel_button = buttons + cancel_button.click() assert ( LEGACY_CREATION_PARAMETERS_OPTION in signal.get_metadata_options() ) @@ -386,7 +589,7 @@ def test_convert_legacy_creation_parameters_signal(): np.testing.assert_array_equal(signal.x, original_x) np.testing.assert_array_equal(signal.y, original_y) - buttons["Convert historical parameters"].click() + convert_button.click() assert objprop.creation_param_editor is not None converted = extract_creation_parameters(signal) @@ -840,7 +1043,7 @@ def test_no_creation_parameters_for_base_classes(): def test_apply_processing_parameters_signal(): """Test apply_processing_parameters for signals""" with qt_app_context(): - with datalab_test_app_context() as win: + with datalab_test_app_context(history=True) as win: panel = win.signalpanel processor = panel.processor objprop = panel.objprop @@ -878,6 +1081,10 @@ def test_apply_processing_parameters_signal(): # Change constant from 5.0 to 15.0 editor.dataset.value = v1 = 15.0 + # In-place update requires History panel edit mode (otherwise a new + # object is created instead of mutating the existing one). + win.historypanel.toggle_edit_mode(True) + # Apply the new processing parameters report = objprop.apply_processing_parameters() @@ -904,7 +1111,7 @@ def test_apply_processing_parameters_signal(): def test_apply_processing_parameters_image(): """Test apply_processing_parameters for images""" with qt_app_context(): - with datalab_test_app_context() as win: + with datalab_test_app_context(history=True) as win: panel = win.imagepanel processor = panel.processor objprop = panel.objprop @@ -939,6 +1146,10 @@ def test_apply_processing_parameters_image(): # Change constant from 7.0 to 20.0 editor.dataset.value = v1 = 20.0 + # In-place update requires History panel edit mode (otherwise a new + # object is created instead of mutating the existing one). + win.historypanel.toggle_edit_mode(True) + # Apply the new processing parameters report = objprop.apply_processing_parameters() @@ -962,6 +1173,101 @@ def test_apply_processing_parameters_image(): assert stored_param.value == v1 +def test_apply_processing_parameters_explicit_param(): + """apply_processing_parameters honors an explicit param, ignoring the editor.""" + with qt_app_context(): + with datalab_test_app_context(history=True) as win: + panel = win.signalpanel + processor = panel.processor + objprop = panel.objprop + + param = GaussParam.create( + mu=250.0, sigma=20.0, amplitude=100.0, y0=10.0, size=500 + ) + panel.new_object(param=param, edit=False) + signal = panel.objview.get_current_object() + assert signal is not None + signal_uuid = get_uuid(signal) + original_signal_data = signal.y.copy() + + v0 = 5.0 + processor.run_feature("addition_constant", ConstantParam.create(value=v0)) + processed_sig = panel.objview.get_current_object() + assert processed_sig is not None + processed_uuid = get_uuid(processed_sig) + assert np.allclose(processed_sig.y, original_signal_data + v0) + + # Select the processed signal to populate the Processing tab editor. + panel.objview.set_current_object(processed_sig) + assert objprop.processing_param_editor is not None + editor = objprop.processing_param_editor + + # Put a DECOY value in the editor: it must be ignored because an + # explicit param is passed to apply_processing_parameters. + editor.dataset.value = 99.0 + + win.historypanel.toggle_edit_mode(True) + + # Apply with an EXPLICIT param (not the editor's decoy value). + v1 = 15.0 + report = objprop.apply_processing_parameters( + param=ConstantParam.create(value=v1) + ) + assert report.success, f"Reprocessing failed: {report.message}" + assert report.obj_uuid == processed_uuid + assert get_uuid(processed_sig) == processed_uuid + + # Output must reflect the EXPLICIT param (original + 15.0), proving + # the editor decoy (99.0) was ignored -> editor-independent. + assert np.allclose(processed_sig.y, original_signal_data + v1) + + pp_dict = processed_sig.get_metadata_option(PROCESSING_PARAMETERS_OPTION) + assert pp_dict["source_uuid"] == signal_uuid + assert pp_dict["func_name"] == "addition_constant" + stored_param = json_to_dataset(pp_dict["param_json"]) + assert stored_param.value == v1 + + # When applying parameters to an object other than the one attached + # to the editor, use that object's stored parameters and origin. + plugin_origin = { + "plugin_class": "TestPlugin", + "module": "test_plugin.operations", + "directory": "test_plugin", + "version": "1.0", + } + pp_dict["plugin_origin"] = plugin_origin + processed_sig.set_metadata_option(PROCESSING_PARAMETERS_OPTION, pp_dict) + editor.dataset.value = 99.0 + objprop.current_processing_obj = signal + + calls = [] + original_recompute_1_to_1 = processor.recompute_1_to_1 + + def record_recompute_1_to_1(*args, **kwargs): + calls.append((args, kwargs)) + return original_recompute_1_to_1(*args, **kwargs) + + processor.recompute_1_to_1 = record_recompute_1_to_1 + try: + report = objprop.apply_processing_parameters( + processed_sig, interactive=False + ) + assert report.success, f"Reprocessing failed: {report.message}" + + win.historypanel.toggle_edit_mode(False) + report = objprop.apply_processing_parameters( + processed_sig, interactive=False + ) + assert report.success, f"Reprocessing failed: {report.message}" + finally: + processor.recompute_1_to_1 = original_recompute_1_to_1 + + assert len(calls) == 2 + for args, kwargs in calls: + assert args[2].value == v1 + assert kwargs["plugin_origin"] == plugin_origin + + def test_no_duplicate_processing_tabs(): """Test that applying processing parameters multiple times doesn't create duplicate tabs. @@ -1089,7 +1395,7 @@ def test_apply_processing_parameters_missing_source(): def test_cross_panel_image_to_signal(): """Test cross-panel processing: Image → Signal (radial profile)""" with qt_app_context(): - with datalab_test_app_context() as win: + with datalab_test_app_context(history=True) as win: image_panel = win.imagepanel signal_panel = win.signalpanel image_processor = image_panel.processor @@ -1136,6 +1442,10 @@ def test_cross_panel_image_to_signal(): editor.dataset.x0 = 40 editor.dataset.y0 = 40 + # In-place update + in-place recompute require History panel edit + # mode (otherwise new objects are created instead of mutating). + win.historypanel.toggle_edit_mode(True) + # Apply the new processing parameters report = signal_panel.objprop.apply_processing_parameters() @@ -1507,7 +1817,7 @@ def test_roi_mask_invalidation_on_processing_change(): 5. Verify ROI mask is properly recomputed """ with qt_app_context(): - with datalab_test_app_context() as win: + with datalab_test_app_context(history=True) as win: panel = win.imagepanel objprop = panel.objprop @@ -1547,6 +1857,10 @@ def test_roi_mask_invalidation_on_processing_change(): editor.dataset.sx = 4 editor.dataset.sy = 4 + # In-place update requires History panel edit mode (otherwise a new + # object is created instead of mutating the existing one). + win.historypanel.toggle_edit_mode(True) + # Apply the new processing parameters report = objprop.apply_processing_parameters(binned) assert report.success diff --git a/datalab/tests/features/common/metadata_io_unit_test.py b/datalab/tests/features/common/metadata_io_unit_test.py index 03cac70d2..9bc462296 100644 --- a/datalab/tests/features/common/metadata_io_unit_test.py +++ b/datalab/tests/features/common/metadata_io_unit_test.py @@ -19,6 +19,7 @@ from datalab.adapters_metadata import GeometryAdapter, TableAdapter from datalab.env import execenv +from datalab.objectmodel import get_uuid from datalab.tests import datalab_test_app_context, helpers @@ -42,12 +43,19 @@ def test_metadata_io_unit(): TableAdapter(table).add_to(ima) panel.add_object(ima) + orig_uuid = get_uuid(ima) orig_metadata = ima.metadata.copy() panel.export_metadata_from_file(fname) panel.delete_metadata() - # The +1 is for the "number" metadata option which has no default: - assert len(ima.metadata) == len(ima.get_metadata_options_defaults()) + 1 + assert get_uuid(ima) == orig_uuid + assert panel.objmodel[orig_uuid] is ima + assert ima.metadata["__number"] == panel.objmodel.get_number(ima) + expected_options = { + f"__{name}" for name in ima.get_metadata_options_defaults() + } + expected_options.update({"__uuid", "__number"}) + assert set(ima.metadata) == expected_options panel.import_metadata_from_file(fname) execenv.print("Check metadata export <--> import features:") diff --git a/datalab/tests/features/common/worker_unit_test.py b/datalab/tests/features/common/worker_unit_test.py index fa8b2e502..1fa01e357 100644 --- a/datalab/tests/features/common/worker_unit_test.py +++ b/datalab/tests/features/common/worker_unit_test.py @@ -28,6 +28,22 @@ from datalab.gui.processor.catcher import CompOut +@pytest.fixture(autouse=True) +def drain_pending_qt_timers(): + """Fire stale unattended auto-close timers before leaving each test. + + In unattended mode, ``qt_app_context`` schedules a zero-delay + ``close_widgets_and_quit`` timer on exit. Tests here never run the Qt + event loop afterwards, so without this drain the timer stays pending in + the shared ``QApplication`` and fires during a later test, closing that + test's freshly created main window (e.g. unregistering its plugins). + """ + yield + if QW.QApplication.instance() is not None: + for _ in range(3): + QW.QApplication.processEvents() + + class TestWorkerStateMachine: """Test suite for WorkerStateMachine class - independent from Worker class.""" diff --git a/datalab/tests/features/control/set_object_unit_test.py b/datalab/tests/features/control/set_object_unit_test.py index 38b8fe1c9..7232c3e8e 100644 --- a/datalab/tests/features/control/set_object_unit_test.py +++ b/datalab/tests/features/control/set_object_unit_test.py @@ -16,6 +16,7 @@ from sigima.tests.data import get_test_image, get_test_signal +from datalab.objectmodel import get_uuid from datalab.tests import datalab_in_background_context @@ -28,13 +29,18 @@ def test_set_object() -> None: proxy.set_current_panel("signal") sig_uuid = proxy.get_object_uuids("signal")[0] + proxy.select_objects([sig_uuid], panel="signal") + proxy.delete_metadata(refresh_plot=False, keep_roi=False) sig = proxy.get_object(sig_uuid) + assert get_uuid(sig) == sig_uuid + assert sig_uuid in proxy.get_object_uuids("signal") original_title = sig.title sig.title = "Modified signal title" sig.yunit = "modified_unit" proxy.set_object(sig) sig_back = proxy.get_object(sig_uuid) + assert get_uuid(sig_back) == sig_uuid assert sig_back.title == "Modified signal title" assert sig_back.yunit == "modified_unit" diff --git a/datalab/tests/features/image/annotations_unit_test.py b/datalab/tests/features/image/annotations_unit_test.py index 11a0d0699..60af9c69d 100644 --- a/datalab/tests/features/image/annotations_unit_test.py +++ b/datalab/tests/features/image/annotations_unit_test.py @@ -14,11 +14,13 @@ from plotpy.builder import make from plotpy.items import AnnotatedShape, PolygonShape from plotpy.plot import BasePlot +from qtpy import QtCore as QC from qtpy import QtWidgets as QW from sigima.tests import data as test_data from datalab.adapters_plotpy import create_adapter_from_object from datalab.env import execenv +from datalab.objectmodel import get_uuid from datalab.tests import datalab_test_app_context @@ -76,5 +78,36 @@ def test_annotations_unit(): execenv.print("OK") +def test_open_separate_view_without_main_plot_item() -> None: + """Open a separate view when the object has no item in the main plot.""" + with datalab_test_app_context() as win: + panel = win.imagepanel + reference = test_data.create_multigaussian_image() + target = test_data.create_annotated_image() + panel.add_object(reference) + panel.add_object(target) + + target_uuid = get_uuid(target) + panel.plothandler.remove_item(target_uuid) + assert panel.plothandler.get(target_uuid) is None + + existing_uuids = panel.plothandler.get_existing_oids() + existing_items = {uuid: panel.plothandler.get(uuid) for uuid in existing_uuids} + visibility = {uuid: item.isVisible() for uuid, item in existing_items.items()} + dialog = panel.open_separate_view(oids=[target_uuid]) + assert dialog is not None + dialog.close() + QW.QApplication.sendPostedEvents(None, QC.QEvent.Type.DeferredDelete) + QW.QApplication.processEvents() + + assert panel.plothandler.get_existing_oids() == existing_uuids + assert all( + panel.plothandler.get(uuid) is item for uuid, item in existing_items.items() + ) + assert { + uuid: panel.plothandler.get(uuid).isVisible() for uuid in existing_uuids + } == visibility + + if __name__ == "__main__": test_annotations_unit() diff --git a/datalab/tests/features/image/detection_roi_merge_unit_test.py b/datalab/tests/features/image/detection_roi_merge_unit_test.py index 1565d0751..b33e83330 100644 --- a/datalab/tests/features/image/detection_roi_merge_unit_test.py +++ b/datalab/tests/features/image/detection_roi_merge_unit_test.py @@ -22,6 +22,7 @@ from sigima.objects import NewImageParam, create_image_roi from sigima.tests.data import create_multigaussian_image, create_peak_image +from datalab.env import execenv from datalab.tests import datalab_test_app_context from datalab.tests.features.image.roi_app_test import IROI1, IROI2 @@ -221,6 +222,7 @@ def test_no_infinite_roi_recreation_loop(): if __name__ == "__main__": + execenv.unattended = True # Auto-close dialogs and event loops (standalone run) test_create_rois_no_existing_roi() test_create_rois_appends_to_existing_roi() test_create_rois_false_preserves_existing_roi() diff --git a/datalab/tests/features/plugins/plugin_history_policy_unit_test.py b/datalab/tests/features/plugins/plugin_history_policy_unit_test.py new file mode 100644 index 000000000..9648ffcf0 --- /dev/null +++ b/datalab/tests/features/plugins/plugin_history_policy_unit_test.py @@ -0,0 +1,488 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Pure unit contracts for plugin input history policies.""" + +from __future__ import annotations + +import importlib.util +from contextlib import contextmanager +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Generator, cast +from unittest.mock import patch + +import numpy as np +import pytest +from sigima import ImageObj, SignalObj +from sigima.io.image import ImageIORegistry +from sigima.io.signal import SignalIORegistry + +from datalab.config import Conf +from datalab.control.proxy import LocalProxy +from datalab.gui import historysession_ops as hsess +from datalab.gui.main import DLMainWindow +from datalab.plugins import PluginRegistry + +testdata_path = Path(__file__).parents[3] / "plugins" / "datalab_testdata.py" +testdata_spec = importlib.util.spec_from_file_location( + "datalab_testdata", testdata_path +) +if testdata_spec is None or testdata_spec.loader is None: + raise ImportError(f"Unable to load test data plugin from {testdata_path}") +testdata_plugin = importlib.util.module_from_spec(testdata_spec) +testdata_spec.loader.exec_module(testdata_plugin) +PluginTestData = testdata_plugin.PluginTestData + + +class AddCallRecorder: + """Record object-add calls received from a local proxy.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, hsess.SessionBehavior | None]] = [] + + def add_object( + self, + obj: SignalObj | ImageObj, + group_id: str = "", + set_current: bool = True, + new_session_behavior: hsess.SessionBehavior | None = None, + ) -> bool: + """Record an object add.""" + del obj, group_id, set_current + self.calls.append(("object", new_session_behavior)) + return True + + def add_signal( + self, *args: Any, new_session_behavior: hsess.SessionBehavior | None = None + ) -> bool: + """Record a signal add.""" + del args + self.calls.append(("signal", new_session_behavior)) + return True + + def add_image( + self, *args: Any, new_session_behavior: hsess.SessionBehavior | None = None + ) -> bool: + """Record an image add.""" + del args + self.calls.append(("image", new_session_behavior)) + return True + + +class HistoryPromptRecorder: + """Record prompt evaluations and suppression state for multi-load tests.""" + + def __init__(self) -> None: + self.suppressed = False + self.calls: list[tuple[bool, hsess.SessionBehavior | None, bool]] = [] + self.decision_count = 0 + + def maybe_start_session_for_input( + self, + *, + load: bool = False, + behavior: hsess.SessionBehavior | None = None, + ) -> bool: + """Record a validated session-policy evaluation.""" + if behavior is not None and behavior not in hsess.SESSION_BEHAVIORS: + raise ValueError(f"Invalid session behavior: {behavior!r}") + self.calls.append((load, behavior, self.suppressed)) + if not self.suppressed and behavior != "no": + self.decision_count += 1 + return False + + def add_ui_entry( + self, + action_title: str, + target: str, + method_name: str, + save_state: bool = True, + ) -> None: + """Record the nested creation evaluation from the main window.""" + del action_title, target, method_name, save_state + self.maybe_start_session_for_input() + + @contextmanager + def session_prompt_suppressed(self) -> Generator[None, None, None]: + """Suppress decisions while preserving nested context state.""" + previous = self.suppressed + self.suppressed = True + try: + yield + finally: + self.suppressed = previous + + +class MultiLoadMainRecorder: + """Exercise the production main-window boundary for local proxy adds.""" + + def __init__(self) -> None: + self.historypanel = HistoryPromptRecorder() + self.memory_allowed = True + self.memory_confirmation_count = 0 + self.added_objects: list[SignalObj | ImageObj] = [] + self.signalpanel = SimpleNamespace(add_object=self.record_signal_object) + self.imagepanel = SimpleNamespace(add_object=self.record_image_object) + + def confirm_memory_state(self) -> bool: + """Return the controlled memory confirmation result.""" + self.memory_confirmation_count += 1 + return self.memory_allowed + + def record_signal_object( + self, obj: SignalObj, group_id: str, set_current: bool + ) -> None: + """Record a signal panel mutation.""" + del group_id, set_current + assert isinstance(obj, SignalObj) + self.added_objects.append(obj) + + def record_image_object( + self, obj: ImageObj, group_id: str, set_current: bool + ) -> None: + """Record an image panel mutation.""" + del group_id, set_current + assert isinstance(obj, ImageObj) + self.added_objects.append(obj) + + def add_object( + self, + obj: SignalObj | ImageObj, + group_id: str = "", + set_current: bool = True, + new_session_behavior: hsess.SessionBehavior | None = None, + ) -> bool: + """Add an object through the production main-window method.""" + return DLMainWindow.add_object( + as_mainwindow(self), + obj, + group_id, + set_current, + new_session_behavior, + ) + + def add_signal( + self, *args: Any, new_session_behavior: hsess.SessionBehavior | None = None + ) -> bool: + """Add a signal through the production main-window method.""" + return DLMainWindow.add_signal( + as_mainwindow(self), *args, new_session_behavior=new_session_behavior + ) + + def add_image( + self, *args: Any, new_session_behavior: hsess.SessionBehavior | None = None + ) -> bool: + """Add an image through the production main-window method.""" + return DLMainWindow.add_image( + as_mainwindow(self), *args, new_session_behavior=new_session_behavior + ) + + +class PluginProxyRecorder: + """Record the Test Data plugin's batch scope and object ordering.""" + + def __init__(self) -> None: + self.events: list[tuple[str, object]] = [] + + @contextmanager + def multiload_session( + self, panel: str, new_session_behavior: hsess.SessionBehavior | None = None + ) -> Generator[None, None, None]: + """Record entry and exit of a plugin multi-load context.""" + del new_session_behavior + self.events.append(("enter", panel)) + try: + yield + finally: + self.events.append(("exit", panel)) + + def add_object(self, obj: object) -> None: + """Record an object in insertion order.""" + self.events.append(("add", obj)) + + +class ProgressRecorder: + """Controllable progress-bar context for plugin load tests.""" + + def __init__(self, cancel_states: list[bool]) -> None: + self.cancel_states = iter(cancel_states) + self.values: list[int] = [] + + def __enter__(self) -> ProgressRecorder: + return self + + def __exit__(self, *args: Any) -> None: + del args + + def setValue(self, value: int) -> None: # pylint: disable=invalid-name + """Record a progress value.""" + self.values.append(value) + + def wasCanceled(self) -> bool: # pylint: disable=invalid-name + """Return the next controlled cancellation state.""" + return next(self.cancel_states, False) + + +def as_mainwindow(mainwindow: object) -> DLMainWindow: + """Cast a pure test double to the local proxy's window interface.""" + return cast("DLMainWindow", mainwindow) + + +def test_local_proxy_resolves_plugin_policy_live_and_explicit_wins() -> None: + """Read plugin policy per add while preserving explicit priority.""" + mainwindow = AddCallRecorder() + proxy = LocalProxy(as_mainwindow(mainwindow), input_source="plugin") + option = Conf.proc.history_plugin_new_session_behavior + xdata = np.array([0.0, 1.0]) + ydata = np.array([1.0, 2.0]) + + with patch.object(option, "get", side_effect=["yes", "no"]) as get_behavior: + proxy.add_object(SignalObj()) + proxy.add_signal("signal", xdata, ydata) + proxy.add_image("image", np.ones((2, 2)), new_session_behavior="ask") + + assert mainwindow.calls == [ + ("object", "yes"), + ("signal", "no"), + ("image", "ask"), + ] + assert get_behavior.call_count == 2 + + local_mainwindow = AddCallRecorder() + LocalProxy(as_mainwindow(local_mainwindow)).add_object(ImageObj()) + assert local_mainwindow.calls == [("object", None)] + + +def test_plugin_registration_marks_proxy_for_live_plugin_policy() -> None: + """Create plugin proxies with the plugin input source marker.""" + plugin = PluginTestData() + mainwindow = AddCallRecorder() + option = Conf.proc.history_plugin_new_session_behavior + + with patch.object(PluginRegistry, "register_plugin"): + plugin.register(as_mainwindow(mainwindow)) + + assert plugin.proxy.input_source == "plugin" + with patch.object(option, "get", return_value="no") as get_behavior: + plugin.proxy.add_object(ImageObj()) + assert mainwindow.calls == [("object", "no")] + get_behavior.assert_called_once_with() + + +def test_plugin_multiload_decides_once_and_suppresses_internal_adds() -> None: + """Apply the batch policy once and use no for later additions.""" + mainwindow = MultiLoadMainRecorder() + proxy = LocalProxy(as_mainwindow(mainwindow), input_source="plugin") + multiload_option = Conf.proc.history_plugin_multiload_behavior + add_option = Conf.proc.history_plugin_new_session_behavior + first = SignalObj() + second = SignalObj() + + with ( + patch.object(multiload_option, "get", return_value="ask") as get_multiload, + patch.object(add_option, "get", return_value="no") as get_add, + ): + with proxy.multiload_session("signal"): + assert proxy.add_object(first) is True + assert proxy.add_object(second) is True + + assert mainwindow.added_objects == [first, second] + assert mainwindow.historypanel.calls == [ + (False, "ask", False), + (False, None, True), + (False, "no", False), + (False, None, True), + ] + assert mainwindow.historypanel.decision_count == 1 + assert mainwindow.historypanel.suppressed is False + assert proxy.multiload_state is None + get_multiload.assert_called_once_with() + get_add.assert_not_called() + + +def test_multiload_validates_inputs_and_explicit_policy_wins() -> None: + """Validate batch inputs before yielding and prioritize explicit policy.""" + mainwindow = MultiLoadMainRecorder() + proxy = LocalProxy(as_mainwindow(mainwindow), input_source="plugin") + option = Conf.proc.history_plugin_multiload_behavior + + with patch.object(option, "get") as get_behavior: + with proxy.multiload_session("image", new_session_behavior="no"): + assert proxy.multiload_state is not None + assert proxy.multiload_state.panel == "image" + assert proxy.multiload_state.behavior == "no" + assert proxy.multiload_state.decision_applied is False + get_behavior.assert_not_called() + assert proxy.multiload_state is None + assert mainwindow.historypanel.calls == [] + + with pytest.raises(ValueError, match="Invalid data panel"): + with proxy.multiload_session(cast(Any, "macro")): + pass + with pytest.raises(ValueError, match="Invalid session behavior"): + with proxy.multiload_session("signal", cast(hsess.SessionBehavior, "invalid")): + pass + + with proxy.multiload_session("signal", "ask"): + outer_state = proxy.multiload_state + with pytest.raises(RuntimeError, match="Nested multiload sessions"): + with proxy.multiload_session("image", "no"): + pass + assert proxy.multiload_state is outer_state + assert proxy.multiload_state is None + + +def test_multiload_defers_empty_and_pre_add_exception_decisions() -> None: + """Leave history untouched when a batch never attempts an insertion.""" + mainwindow = MultiLoadMainRecorder() + proxy = LocalProxy(as_mainwindow(mainwindow), input_source="plugin") + option = Conf.proc.history_plugin_multiload_behavior + + with patch.object(option, "get", return_value="ask") as get_behavior: + with proxy.multiload_session("signal"): + pass + with pytest.raises(ValueError, match="before first add"): + with proxy.multiload_session("image"): + raise ValueError("before first add") + + assert get_behavior.call_count == 2 + assert proxy.multiload_state is None + assert mainwindow.memory_confirmation_count == 0 + assert mainwindow.historypanel.calls == [] + assert mainwindow.added_objects == [] + + +def test_multiload_memory_rejection_does_not_consume_first_decision() -> None: + """Defer the batch decision until an object passes memory confirmation.""" + mainwindow = MultiLoadMainRecorder() + proxy = LocalProxy(as_mainwindow(mainwindow), input_source="plugin") + rejected = SignalObj() + accepted = SignalObj() + + with proxy.multiload_session("signal", "ask"): + mainwindow.memory_allowed = False + assert proxy.add_object(rejected) is False + assert proxy.multiload_state is not None + assert proxy.multiload_state.decision_applied is False + mainwindow.memory_allowed = True + assert proxy.add_object(accepted) is True + assert proxy.multiload_state.decision_applied is True + + assert mainwindow.memory_confirmation_count == 2 + assert mainwindow.added_objects == [accepted] + assert mainwindow.historypanel.calls == [ + (False, "ask", False), + (False, None, True), + ] + assert mainwindow.historypanel.decision_count == 1 + + +def test_multiload_rejects_panel_mismatch_before_mainwindow_mutation() -> None: + """Reject an object from another panel before memory or history changes.""" + mainwindow = MultiLoadMainRecorder() + proxy = LocalProxy(as_mainwindow(mainwindow), input_source="plugin") + + with proxy.multiload_session("signal", "ask"): + with pytest.raises(ValueError, match="during a signal multiload session"): + proxy.add_object(ImageObj()) + + assert mainwindow.memory_confirmation_count == 0 + assert mainwindow.historypanel.calls == [] + assert mainwindow.added_objects == [] + + +def test_typed_adds_report_memory_rejection() -> None: + """Keep add_signal and add_image return values truthful on rejection.""" + mainwindow = MultiLoadMainRecorder() + mainwindow.memory_allowed = False + proxy = LocalProxy(as_mainwindow(mainwindow)) + xdata = np.array([0.0, 1.0]) + ydata = np.array([1.0, 2.0]) + + assert proxy.add_signal("signal", xdata, ydata) is False + assert proxy.add_image("image", np.ones((2, 2))) is False + assert mainwindow.memory_confirmation_count == 2 + assert mainwindow.historypanel.calls == [] + assert mainwindow.added_objects == [] + + +@pytest.mark.parametrize( + ("registry_class", "panel_str", "panel_attribute"), + ( + (SignalIORegistry, "signal", "signalpanel"), + (ImageIORegistry, "image", "imagepanel"), + ), +) +def test_testdata_multiload_selects_registry_panel_and_preserves_order( + registry_class, panel_str: str, panel_attribute: str +) -> None: + """Select the registry's panel and preserve progress and object order.""" + plugin = PluginTestData() + signalpanel = object() + imagepanel = object() + plugin.main = cast( + Any, SimpleNamespace(signalpanel=signalpanel, imagepanel=imagepanel) + ) + proxy = PluginProxyRecorder() + plugin.proxy = cast(LocalProxy, proxy) + first = object() + second = object() + progress = ProgressRecorder([False, False]) + progress_calls = [] + + def create_progress(parent, title, max_): + progress_calls.append((parent, title, max_)) + return progress + + with ( + patch.object( + testdata_plugin.helpers, + "read_test_objects", + return_value=[("first", first), ("second", second)], + ), + patch.object(testdata_plugin, "create_progress_bar", create_progress), + ): + plugin.load_test_objs(registry_class, "Load objects") + + expected_panel = getattr(plugin.main, panel_attribute) + assert progress_calls == [(expected_panel, "Load objects", 2)] + assert progress.values == [1, 2] + assert proxy.events == [ + ("enter", panel_str), + ("add", first), + ("add", second), + ("exit", panel_str), + ] + + +def test_testdata_multiload_preserves_immediate_cancellation() -> None: + """Leave the lazy batch empty when progress is cancelled immediately.""" + plugin = PluginTestData() + signalpanel = object() + plugin.main = cast( + Any, SimpleNamespace(signalpanel=signalpanel, imagepanel=object()) + ) + proxy = PluginProxyRecorder() + plugin.proxy = cast(LocalProxy, proxy) + first = object() + second = object() + progress = ProgressRecorder([True]) + + with ( + patch.object( + testdata_plugin.helpers, + "read_test_objects", + return_value=[ + ("first", first), + ("second", second), + ], + ), + patch.object(testdata_plugin, "create_progress_bar", return_value=progress), + ): + plugin.load_test_objs(SignalIORegistry, "Load signals") + + assert progress.values == [1] + assert proxy.events == [ + ("enter", "signal"), + ("exit", "signal"), + ] diff --git a/datalab/tests/features/signal/fitdialog_unit_test.py b/datalab/tests/features/signal/fitdialog_unit_test.py index 1a08f1f77..0d9b12e8a 100644 --- a/datalab/tests/features/signal/fitdialog_unit_test.py +++ b/datalab/tests/features/signal/fitdialog_unit_test.py @@ -58,6 +58,45 @@ def test_fit_dialog(): ep(fdlg.piecewiseexponential_fit(s4.x, s4.y, name=tn("12"))) +def test_evaluate_fit_matches_models(): + """Test the GUI-free deterministic fit evaluator (no dialog).""" + x = np.linspace(-5, 5, 50) + + # Polynomial (degree 2) + poly_values = [2.0, -1.0, 3.0] + assert np.allclose( + fdlg.evaluate_fit("polynomial", x, poly_values), + np.polyval(poly_values, x), + ) + + # Gaussian: [amplitude, sigma, x0, y0] + gauss_values = [5.0, 1.5, 0.3, 0.2] + assert np.allclose( + fdlg.evaluate_fit("gaussian", x, gauss_values), + pulse.GaussianModel.evaluate(x, *gauss_values), + ) + + # Multi-Gaussian: [A1, σ1, A2, σ2, y0] + fixed peak abscissas + multi_values = [1.0, 0.5, 2.0, 0.4, 0.1] + a_x0 = [-1.0, 1.5] + assert np.allclose( + fdlg.evaluate_fit("multigaussian", x, multi_values, extra={"a_x0": a_x0}), + fdlg.multigaussian(x, *multi_values, a_x0=np.array(a_x0)), + ) + + # Canonical mapping helper + assert fdlg.fit_type_from_dlgfunc_name("gaussian_fit") == "gaussian" + assert fdlg.fit_type_from_dlgfunc_name("unknown") is None + + # Unknown fit type raises + try: + fdlg.evaluate_fit("nope", x, []) + except ValueError: + pass + else: + raise AssertionError("evaluate_fit should raise ValueError for unknown type") + + def test_peak_fit_metadata(monkeypatch): """Peak fit dialogs return canonical metadata when accepted.""" @@ -258,3 +297,4 @@ def test_fit_dialog_bounds_contain_the_optimum( if __name__ == "__main__": test_fit_dialog() + test_evaluate_fit_matches_models() diff --git a/datalab/tests/features/utilities/settings_unit_test.py b/datalab/tests/features/utilities/settings_unit_test.py index 167839f1c..58b61e497 100644 --- a/datalab/tests/features/utilities/settings_unit_test.py +++ b/datalab/tests/features/utilities/settings_unit_test.py @@ -11,9 +11,15 @@ from guidata.qthelpers import qt_app_context from qtpy import QtWidgets as QW -from datalab.config import _ +from datalab.config import Conf, _ from datalab.env import execenv -from datalab.gui.settings import create_dataset_dict, edit_settings +from datalab.gui.settings import ( + ProcSettings, + conf_to_datasets, + create_dataset_dict, + datasets_to_conf, + edit_settings, +) from datalab.utils import qthelpers as qth @@ -24,6 +30,38 @@ def test_edit_settings(): execenv.print(changed) +def test_proc_history_policy_settings_round_trip() -> None: + """Load and save history policies through generic settings machinery.""" + option_names = ( + "history_new_session_behavior", + "history_plugin_new_session_behavior", + "history_plugin_multiload_behavior", + ) + original_values = tuple(getattr(Conf.proc, name).get() for name in option_names) + loaded_values = ("yes", "ask", "no") + saved_values = ("no", "yes", "ask") + + try: + for name, value in zip(option_names, loaded_values): + getattr(Conf.proc, name).set(value) + + settings = ProcSettings() + paramdict = {"proc": settings} + conf_to_datasets(paramdict) + assert tuple(getattr(settings, name) for name in option_names) == loaded_values + + for name, value in zip(option_names, saved_values): + setattr(settings, name, value) + datasets_to_conf(paramdict) + assert ( + tuple(getattr(Conf.proc, name).get() for name in option_names) + == saved_values + ) + finally: + for name, value in zip(option_names, original_values): + getattr(Conf.proc, name).set(value) + + def capture_settings_screenshots(): """Capture screenshots of each settings tab diff --git a/datalab/widgets/fitdialog.py b/datalab/widgets/fitdialog.py index ffbad27b5..dfab520d5 100644 --- a/datalab/widgets/fitdialog.py +++ b/datalab/widgets/fitdialog.py @@ -4,6 +4,8 @@ # pylint: disable=invalid-name # Allows short reference names like x, y, ... +from __future__ import annotations + import numpy as np from guidata.configtools import get_icon from guidata.qthelpers import exec_dialog @@ -905,3 +907,74 @@ def fitfunc(x, params): y_fitted, ) return y_fitted, params, fit_params + + +# --- Deterministic fit evaluation (GUI-free) ---------------------------------- + +# Canonical fit-type identifiers, used to record and replay curve fits +# deterministically (without reopening the interactive dialog). +FIT_TYPE_BY_DLGFUNC = { + "polynomial_fit": "polynomial", + "linear_fit": "polynomial", + "gaussian_fit": "gaussian", + "lorentzian_fit": "lorentzian", + "voigt_fit": "voigt", + "multigaussian_fit": "multigaussian", + "multilorentzian_fit": "multilorentzian", +} + + +def fit_type_from_dlgfunc_name(name: str) -> str | None: + """Return the canonical fit-type id for a fit dialog function name. + + Args: + name: ``__name__`` of the fit dialog function (e.g. ``"gaussian_fit"``). + + Returns: + The canonical fit-type id (e.g. ``"gaussian"``), or ``None`` if the + function is not a recognised deterministic fit. + """ + return FIT_TYPE_BY_DLGFUNC.get(name) + + +def evaluate_fit( + fit_type: str, + x: np.ndarray, + values: list[float], + extra: dict | None = None, +) -> np.ndarray: + """Evaluate a recorded curve fit deterministically (no GUI). + + Args: + fit_type: Canonical fit-type id (see :data:`FIT_TYPE_BY_DLGFUNC`). + x: Abscissa values of the source signal. + values: Final fit parameter values, in the same order produced by the + corresponding interactive fit function. + extra: Optional structural data required by some models + (``{"a_x0": [...]}`` for multi-peak fits). + + Returns: + The fitted ordinate array ``y`` evaluated at ``x``. + + Raises: + ValueError: If ``fit_type`` is unknown or required ``extra`` is missing. + """ + x = np.asarray(x, dtype=float) + vals = list(values) + extra = extra or {} + if fit_type == "polynomial": + return np.polyval(vals, x) + if fit_type == "gaussian": + return pulse.GaussianModel.evaluate(x, *vals) + if fit_type == "lorentzian": + return pulse.LorentzianModel.evaluate(x, *vals) + if fit_type == "voigt": + return pulse.VoigtModel.evaluate(x, *vals) + if fit_type in ("multigaussian", "multilorentzian"): + a_x0 = extra.get("a_x0") + if a_x0 is None: + raise ValueError(f"Missing 'a_x0' in extra for {fit_type!r} fit evaluation") + a_x0 = np.asarray(a_x0, dtype=float) + func = multigaussian if fit_type == "multigaussian" else multilorentzian + return func(x, *vals, a_x0=a_x0) + raise ValueError(f"Unknown fit_type {fit_type!r}") diff --git a/datalab/widgets/historydescription.py b/datalab/widgets/historydescription.py new file mode 100644 index 000000000..e2ca7945a --- /dev/null +++ b/datalab/widgets/historydescription.py @@ -0,0 +1,92 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Collapsible description widget used by the History panel.""" + +from __future__ import annotations + +import html + +from qtpy import QtCore as QC +from qtpy import QtWidgets as QW + +from datalab.config import _ + + +class CollapsibleDescriptionWidget(QW.QWidget): + """Compact, expandable cell widget for the history Description column. + + Shows a single-line summary by default; a chevron toggle reveals the full + HTML description (mirroring the *Properties* tab rendering). + """ + + toggled = QC.Signal(bool) + + def __init__( + self, + summary: str, + html_text: str, + expanded: bool = False, + parent: QW.QWidget | None = None, + ) -> None: + super().__init__(parent) + self._summary = summary + self._html = html_text + self._expanded = expanded + + self._toggle = QW.QToolButton(self) + self._toggle.setAutoRaise(True) + self._toggle.setCheckable(True) + self._toggle.setFocusPolicy(QC.Qt.NoFocus) + self._toggle.setArrowType(QC.Qt.RightArrow) + self._toggle.setToolTip(_("Show details")) + + self._label = QW.QLabel(self) + self._label.setTextFormat(QC.Qt.RichText) + self._label.setWordWrap(True) + self._label.setTextInteractionFlags(QC.Qt.TextSelectableByMouse) + self._label.setAlignment(QC.Qt.AlignTop | QC.Qt.AlignLeft) + + layout = QW.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + layout.addWidget(self._toggle, 0, QC.Qt.AlignTop) + layout.addWidget(self._label, 1) + + # Hide the toggle when there is nothing more to show than the summary. + if not self._html or self.html_matches_summary(): + self._toggle.setVisible(False) + + self._toggle.toggled.connect(self.on_toggled) + self.refresh_widget() + + def html_matches_summary(self) -> bool: + """Return True when the HTML rendering would not add information.""" + return self._html.strip() == html.escape(self._summary).strip() + + def on_toggled(self, checked: bool) -> None: + """Handle the expand/collapse toggle being toggled.""" + self._expanded = checked + self.refresh_widget() + self.toggled.emit(checked) + + def refresh_widget(self) -> None: + """Refresh the widget contents to match the current expanded state.""" + if self._expanded: + self._toggle.setArrowType(QC.Qt.DownArrow) + self._toggle.setToolTip(_("Hide details")) + self._label.setText(self._html or html.escape(self._summary)) + else: + self._toggle.setArrowType(QC.Qt.RightArrow) + self._toggle.setToolTip(_("Show details")) + self._label.setText(html.escape(self._summary)) + self.updateGeometry() + + def is_expanded(self) -> bool: + """Return current expanded state.""" + return self._expanded + + def set_expanded(self, expanded: bool) -> None: + """Programmatically set the expanded state.""" + if expanded == self._expanded: + return + self._toggle.setChecked(expanded) diff --git a/datalab/widgets/historytree.py b/datalab/widgets/historytree.py new file mode 100644 index 000000000..ec274175e --- /dev/null +++ b/datalab/widgets/historytree.py @@ -0,0 +1,301 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""History tree widget used by the History panel.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from guidata.configtools import get_icon +from qtpy import QtCore as QC +from qtpy import QtGui as QG +from qtpy import QtWidgets as QW + +from datalab.config import _ +from datalab.history import HistoryAction, HistorySession +from datalab.widgets.historydescription import CollapsibleDescriptionWidget + +if TYPE_CHECKING: + from datalab.gui.main import DLMainWindow + from datalab.gui.panel.history.panel import HistoryPanel + + +class HistoryTree(QW.QTreeWidget): + """Tree widget for the history panel""" + + DESCRIPTION_COLUMN = 2 + COMPATIBILITY_ROLE = QC.Qt.UserRole + 1 + SESSION_NUMBER_ROLE = QC.Qt.UserRole + 2 + ITEM_KIND_ROLE = QC.Qt.UserRole + 3 + ITEM_SESSION = "session" + ITEM_ACTION = "action" + + def __init__(self, parent: QW.QWidget) -> None: + """Create a new history tree widget""" + super().__init__(parent) + self._panel: HistoryPanel = parent + self.setHeaderLabels([_("Title"), _("Date and time"), _("Description")]) + self.setContextMenuPolicy(QC.Qt.CustomContextMenu) + self.setSelectionMode(QW.QAbstractItemView.ContiguousSelection) + self.setUniformRowHeights(False) + header = self.header() + header.setSectionResizeMode(self.DESCRIPTION_COLUMN, QW.QHeaderView.Stretch) + # Per-action expanded state, preserved across repopulate (delete/replay). + self.__expanded_state: dict[str, bool] = {} + # Session number currently flagged as the active recording session. + # Used to highlight the active session and survive tree repopulation. + self.__active_session_number: int | None = None + + def on_description_toggled(self, uuid: str, expanded: bool) -> None: + """Remember the expanded state of a description cell.""" + self.__expanded_state[uuid] = expanded + # Force the tree to recompute row heights now that the label content + # has changed. + self.scheduleDelayedItemsLayout() + + def install_description_widget( + self, item: QW.QTreeWidgetItem, action: HistoryAction + ) -> None: + """Attach the collapsible description widget to ``item`` (column 2). + + The item must already be inserted in the tree before calling this. + """ + expanded = self.__expanded_state.get(action.uuid, False) + widget = CollapsibleDescriptionWidget( + action.description_summary, + action.description_html, + expanded=expanded, + parent=self, + ) + widget.toggled.connect( + lambda checked, uuid=action.uuid: self.on_description_toggled(uuid, checked) + ) + # Clear any text the item may carry for that column to avoid double + # rendering behind the widget. + item.setText(self.DESCRIPTION_COLUMN, "") + self.setItemWidget(item, self.DESCRIPTION_COLUMN, widget) + + @classmethod + def action_to_tree_item(cls, action: HistoryAction) -> QW.QTreeWidgetItem: + """Convert an action to a tree item + + Args: + action: Action to convert + + Returns: + QW.QTreeWidgetItem: Tree item + """ + # Description column is left empty: a CollapsibleDescriptionWidget is + # installed by ``HistoryTree`` once the item is inserted in the tree. + item = QW.QTreeWidgetItem([action.title, action.dtstr, ""]) + item.setData(0, QC.Qt.UserRole, action.uuid) + item.setData(0, cls.COMPATIBILITY_ROLE, True) + item.setData(0, cls.ITEM_KIND_ROLE, cls.ITEM_ACTION) + return item + + def update_compatibility_states( + self, history_sessions: list[HistorySession], mainwindow: DLMainWindow + ) -> None: + """Update action item visual state from workspace compatibility.""" + default_brush = QG.QBrush() + disabled_brush = QG.QBrush( + self.palette().color(QG.QPalette.Disabled, QG.QPalette.Text) + ) + compatible_tip = _("Action is compatible with the current workspace state.") + incompatible_tip = _( + "Action is not compatible with the current workspace state." + ) + actions_by_uuid = { + action.uuid: action + for session in history_sessions + for action in session.actions + } + iterator = QW.QTreeWidgetItemIterator(self) + while iterator.value(): + item = iterator.value() + if item.data(0, self.ITEM_KIND_ROLE) == self.ITEM_ACTION: + uuid = item.data(0, QC.Qt.UserRole) + action = actions_by_uuid.get(uuid) + # The tree can transiently reference an action that was just + # removed from the model (e.g. mid-cascade during + # reconnect_chain_after_removal, before the final repopulate). + # Skip such stale items instead of crashing. + if action is None: + iterator += 1 + continue + compatible = action.is_current_state_compatible(mainwindow) + item.setData(0, self.COMPATIBILITY_ROLE, compatible) + brush = default_brush if compatible else disabled_brush + icon = get_icon("apply.svg") if compatible else get_icon("delete.svg") + item.setIcon(0, icon) + for col in range(self.columnCount()): + item.setForeground(col, brush) + item.setToolTip( + col, compatible_tip if compatible else incompatible_tip + ) + iterator += 1 + + def forget_orphan_expanded_states( + self, history_sessions: list[HistorySession] + ) -> None: + """Drop expanded-state entries for actions that no longer exist.""" + live_uuids = { + action.uuid for session in history_sessions for action in session.actions + } + self.__expanded_state = { + uuid: state + for uuid, state in self.__expanded_state.items() + if uuid in live_uuids + } + + def populate_tree(self, history_sessions: list[HistorySession]) -> None: + """Populate the history tree widget + + Args: + history_sessions: List of history sessions + """ + self.forget_orphan_expanded_states(history_sessions) + self.clear() + for session in history_sessions: + ritem = QW.QTreeWidgetItem([session.title, session.dtstr]) + ritem.setData(0, self.COMPATIBILITY_ROLE, True) + ritem.setData(0, self.SESSION_NUMBER_ROLE, session.number) + ritem.setData(0, self.ITEM_KIND_ROLE, self.ITEM_SESSION) + self.addTopLevelItem(ritem) + self.build_session_children(ritem, session) + self.expandAll() + for col in (0, 1): + self.resizeColumnToContents(col) + self.__apply_active_highlight() + + def build_session_children( + self, session_item: QW.QTreeWidgetItem, session: HistorySession + ) -> None: + """(Re)build the action rows directly under ``session_item``. + + Args: + session_item: Top-level tree item for ``session``. + session: History session whose actions are displayed in order. + """ + session_item.takeChildren() + for action in session.actions: + child = self.action_to_tree_item(action) + session_item.addChild(child) + self.install_description_widget(child, action) + + def set_active_session(self, active_session_number: int | None) -> None: + """Flag the active recording session by session number. + + Args: + active_session_number: Session number of the active recording + session, or None when no session is active. + """ + self.__active_session_number = active_session_number + self.__apply_active_highlight() + + def __apply_active_highlight(self) -> None: + """Bold + tint the top-level item of the active recording session.""" + hl = self.palette().color(QG.QPalette.Highlight) + hl.setAlpha(60) + active_brush = QG.QBrush(hl) + normal_brush = QG.QBrush() + tip = _("Active recording session.") + for i in range(self.topLevelItemCount()): + item = self.topLevelItem(i) + number = item.data(0, self.SESSION_NUMBER_ROLE) + is_active = number is not None and number == self.__active_session_number + font = item.font(0) + font.setBold(is_active) + for col in (0, 1): + item.setFont(col, font) + item.setBackground(col, active_brush if is_active else normal_brush) + item.setToolTip(col, tip if is_active else "") + + def rearrange_tree(self) -> None: + """Rearrange the history tree widget""" + self.expandAll() + for col in (0, 1): + self.resizeColumnToContents(col) + + def rebuild_session(self, session_index: int) -> None: + """Rebuild the tree subtree for the session at ``session_index``. + + Args: + session_index: Top-level session item index. + """ + ritem = self.topLevelItem(session_index) + if ritem is None: + return + session = self._panel.history_sessions[session_index] + self.build_session_children(ritem, session) + ritem.setExpanded(True) + + def refresh_action_item(self, action: HistoryAction) -> None: + """Refresh the tree item corresponding to ``action``. + + Re-installs the description widget so it reflects the current + ``action.kwargs`` (e.g. after the user edited a ``param`` via the + Processing tab of the Signal/Image panel). Also applies a light + orange background when ``action.is_stale`` is True, to signal that + the action is currently being recomputed in a cascade. + """ + target_uuid = action.uuid + stale_brush = QG.QBrush(QG.QColor(255, 220, 150)) # light orange + normal_brush = QG.QBrush() + iterator = QW.QTreeWidgetItemIterator(self) + while iterator.value(): + item = iterator.value() + if item.data(0, QC.Qt.UserRole) == target_uuid: + # Remove and re-install the collapsible description widget so + # it reflects the mutated ``action.kwargs``. + self.removeItemWidget(item, self.DESCRIPTION_COLUMN) + self.install_description_widget(item, action) + item.setText(0, action.title) + brush = stale_brush if action.is_stale else normal_brush + for col in range(self.columnCount()): + item.setBackground(col, brush) + self.scheduleDelayedItemsLayout() + return + iterator += 1 + + def get_action_from_uuid( + self, uuid: str, history_sessions: list[HistorySession] + ) -> HistoryAction: + """Get the action from its UUID + + Args: + uuid: Action UUID + history_sessions: List of history sessions + + Returns: + HistoryAction: Action + """ + for session in history_sessions: + for action in session.actions: + if action.uuid == uuid: + return action + raise ValueError("Action not found") + + def get_selected_actions_or_sessions( + self, history_sessions: list[HistorySession] + ) -> list[HistoryAction | HistorySession]: + """Get the selected actions or sessions + + Args: + history_sessions: List of history sessions + + Returns: + list[HistoryAction | HistorySession]: List of selected actions or sessions + """ + selected: list[HistoryAction | HistorySession] = [] + for item in self.selectedItems(): + if item.parent() is None: + index = self.indexOfTopLevelItem(item) + selected.append(history_sessions[index]) + elif item.data(0, self.ITEM_KIND_ROLE) == self.ITEM_ACTION: + uuid = item.data(0, QC.Qt.UserRole) + try: + selected.append(self.get_action_from_uuid(uuid, history_sessions)) + except ValueError: + continue + return selected diff --git a/datalab/widgets/workspacestate_widget.py b/datalab/widgets/workspacestate_widget.py new file mode 100644 index 000000000..50b81c9e8 --- /dev/null +++ b/datalab/widgets/workspacestate_widget.py @@ -0,0 +1,82 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Workspace state display widget used by the History panel.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy import QtCore as QC +from qtpy import QtWidgets as QW + +from datalab.config import _ + +if TYPE_CHECKING: + from datalab.history import WorkspaceState + + +class WorkspaceStateWidget(QW.QWidget): + """Side-by-side tables showing the workspace state captured by a history action. + + Left table: signals (title + data shape). + Right table: images (title + data shape/dimensions). + """ + + def __init__(self, parent: QW.QWidget | None = None) -> None: + super().__init__(parent) + self._signal_table = QW.QTableWidget(0, 2, self) + self._signal_table.setHorizontalHeaderLabels([_("Signal"), _("Shape")]) + self._signal_table.horizontalHeader().setStretchLastSection(True) + self._signal_table.setEditTriggers(QW.QAbstractItemView.NoEditTriggers) + self._signal_table.setSelectionMode(QW.QAbstractItemView.NoSelection) + self._signal_table.verticalHeader().hide() + + self._image_table = QW.QTableWidget(0, 2, self) + self._image_table.setHorizontalHeaderLabels([_("Image"), _("Dimensions")]) + self._image_table.horizontalHeader().setStretchLastSection(True) + self._image_table.setEditTriggers(QW.QAbstractItemView.NoEditTriggers) + self._image_table.setSelectionMode(QW.QAbstractItemView.NoSelection) + self._image_table.verticalHeader().hide() + + splitter = QW.QSplitter(QC.Qt.Horizontal, self) + splitter.addWidget(self._signal_table) + splitter.addWidget(self._image_table) + layout = QW.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(splitter) + + def update_from_state(self, state: WorkspaceState | None) -> None: + """Populate tables from a WorkspaceState.""" + self._signal_table.setRowCount(0) + self._image_table.setRowCount(0) + if state is None: + return + self.populate_table(self._signal_table, state, "signal") + self.populate_table(self._image_table, state, "image") + + @staticmethod + def populate_table( + table: QW.QTableWidget, state: WorkspaceState, panel_key: str + ) -> None: + """Fill a table from the state for a given panel key.""" + titles = state.titles.get(panel_key, []) + shapes = state.states.get(panel_key, []) + metadata = state.object_metadata.get(panel_key, {}) + uuids = state.selection.get(panel_key, []) + # Use metadata keyed by UUID when available + rows: list[tuple[str, str]] = [] + for i, uuid in enumerate(uuids): + title = titles[i] if i < len(titles) else uuid[:8] + meta = metadata.get(uuid, {}) + shape = meta.get("shape") + if shape is not None: + shape_str = " × ".join(str(s) for s in shape) + elif i < len(shapes): + shape_str = shapes[i] + else: + shape_str = "—" + rows.append((title, shape_str)) + table.setRowCount(len(rows)) + for row_idx, (title, shape_str) in enumerate(rows): + table.setItem(row_idx, 0, QW.QTableWidgetItem(title)) + table.setItem(row_idx, 1, QW.QTableWidgetItem(shape_str)) diff --git a/doc/features/common/historypanel.rst b/doc/features/common/historypanel.rst new file mode 100644 index 000000000..ffca2c063 --- /dev/null +++ b/doc/features/common/historypanel.rst @@ -0,0 +1,322 @@ +.. _historypanel: + +History Panel +============= + +.. meta:: + :description: History Panel in DataLab, the open-source scientific data analysis and visualization platform + :keywords: DataLab, history, record, replay, session, scientific, data, analysis, visualization, platform + +Overview +-------- + +The "History Panel" records the sequence of actions performed by the user on +signals and images, organized into **sessions**. Each session is a chronological +list of either: + +- **UI actions** (creating a new signal, removing selected objects, saving the + workspace to HDF5, ...), +- **computations** (FFT, average, Gaussian fit, ...) dispatched by the DataLab + processors to Sigima, or +- **mutations** (in-place modifications of existing objects, such as editing + regions of interest). See :ref:`history-object-mutations`. + +A recorded session can be: + +- **Replayed** silently or **step by step**. Replaying recomputes each + computation action in place with its recorded parameters: existing output + objects are updated, and deleted outputs whose action is still part of the + history are re-created under their original identifiers, keeping the + downstream processing chain valid. In step-by-step mode, parameters may be + reviewed and edited before each step is recomputed. Recorded UI actions are + invoked through their own methods and may reproduce their side effects, + including creating, importing, or duplicating objects; +- **Duplicated** as independent processing chains in new history sessions, + with the required signal/image objects cloned as part of the operation; +- **Saved to a standalone history file** (``.dlhist``) or **embedded in the + workspace** when saving to HDF5, so that the full processing chain travels + with the data. + +.. figure:: ../../images/shots/history_panel.png + :align: center + :alt: History Panel + + The History Panel after recording a representative session: create three + signals (Voigt, Lorentzian, Lorentzian), remove one of them, create a + Gaussian signal, compute the average, add Gaussian noise to the result + and run a Gaussian fit. + +.. _history-object-mutations: + +Object mutations +---------------- + +Besides UI actions and computations, the panel records **mutations**: +in-place modifications of existing objects that do not create new ones. +Mutations currently cover regions of interest (ROI): defining or editing +ROIs graphically or numerically, deleting one or all ROIs, and pasting ROIs +each record a single generic mutation entry holding the affected objects +and the resulting ROI state. + +Replaying a mutation re-applies the recorded ROI state to its target +objects (an empty state removes the ROIs). In step-by-step mode, the ROI +parameters can be reviewed and edited in a dialog before being re-applied; +editing them triggers a recompute of the downstream dependent computations. + +When a recomputed action re-creates or updates an object, the mutations +recorded on that object are re-applied in order, and analyses depending on +the mutated object are recomputed. User ROIs present on an output object +are preserved by in-place recomputes, unless the recompute itself produces +a ROI. + +Mutation entries are saved with sessions (standalone ``.dlhist`` files and +HDF5 workspaces) like any other action; history files created with earlier +versions of DataLab load unchanged. + +Recording and session lifecycle +------------------------------- + +Actions are recorded only while **Record mode** is enabled. Turning record +mode off preserves existing sessions but does not add new entries. + +The Signals and Images panels each have their own active session. New actions +are added to the active session of the data panel they concern, so switching +between signals and images does not mix their recording contexts. + +When a new object is created or a file is loaded into a populated active +session, a configurable policy determines whether DataLab asks, starts a new +session, or continues the current one. Plugin-created objects use separate +policies. An explicit plugin multi-load scope supplies one durable session +policy for the whole batch. With ordinary **Ask** behavior, repeated prompts +for synchronous additions to the same panel are debounced during the current +Qt event-loop turn. + +These options are available under +``File > Settings > Processing > History sessions``. See +:ref:`history-session-settings` for the complete labels and default values. + +Toolbar +------- + +The toolbar at the top of the panel exposes the following actions: + +.. |record| image:: ../../../datalab/data/icons/record.svg + :width: 24px + :height: 24px + :class: dark-light no-scaled-link + +.. |new_session| image:: ../../../datalab/data/icons/libre-gui-add.svg + :width: 24px + :height: 24px + :class: dark-light no-scaled-link + +.. |open_history| image:: ../../../datalab/data/icons/io/fileopen_h5.svg + :width: 24px + :height: 24px + :class: dark-light no-scaled-link + +.. |save_history| image:: ../../../datalab/data/icons/io/filesave_h5.svg + :width: 24px + :height: 24px + :class: dark-light no-scaled-link + +.. |replay| image:: ../../../datalab/data/icons/replay.svg + :width: 24px + :height: 24px + :class: dark-light no-scaled-link + +.. |step_by_step| image:: ../../../datalab/data/icons/edit_mode.svg + :width: 24px + :height: 24px + :class: dark-light no-scaled-link + +.. |duplicate| image:: ../../../datalab/data/icons/edit/duplicate.svg + :width: 24px + :height: 24px + :class: dark-light no-scaled-link + +.. |step_prev| image:: ../../../datalab/data/icons/libre-gui-arrow-left.svg + :width: 24px + :height: 24px + :class: dark-light no-scaled-link + +.. |step_next| image:: ../../../datalab/data/icons/libre-gui-arrow-right.svg + :width: 24px + :height: 24px + :class: dark-light no-scaled-link + +.. |delete| image:: ../../../datalab/data/icons/edit/delete.svg + :width: 24px + :height: 24px + :class: dark-light no-scaled-link + +.. |remove_incompatible| image:: ../../../datalab/data/icons/edit/delete_all.svg + :width: 24px + :height: 24px + :class: dark-light no-scaled-link + +- |record| **Record mode**: toggle the recording of new actions. When off, no + new entry is added to the history (existing sessions are preserved). +- |new_session| **New session**: start a new active history session for the + current data panel. +- |open_history| **Open history file**: load recorded sessions from a standalone + ``.dlhist`` file. +- |save_history| **Save history file**: save the current recorded sessions to a + standalone ``.dlhist`` file. +- |step_prev| **Previous step**: select the preceding action in the current + session (keyboard shortcut: :kbd:`Ctrl+Left`). +- |step_next| **Next step**: select the following action in the current + session (keyboard shortcut: :kbd:`Ctrl+Right`). +- |replay| **Replay**: recompute the selection in place, silently (no + parameter dialogs). Selecting an action replays that action; selecting a + session replays all of its actions. A selection spanning several actions or + sessions is merged, deduplicated and executed in session order. Each + computation action re-runs with its recorded parameters and updates its + existing output object(s), keeping the same identifiers so that downstream + steps remain valid. Outputs that were deleted from the data panel are + re-created under their original identifiers (a typical workflow: delete a + bad result, edit its parameters, then replay to regenerate it). Actions + whose source objects no longer exist are skipped with a warning, and a + failed action blocks its downstream branch. Actions whose parameters were + changed (in step-by-step mode or from the **Processing** tab) are marked as + outdated; replaying recomputes them and, when parameters were edited, their + downstream dependent actions as well. Analysis actions replay by + recomputing their results on the source objects: each analysis records + which results it stored on the object (its *effects*), so replaying + updates exactly those results — previous values are replaced, and if the + recompute fails the previous results are restored. Analyses recorded with + earlier versions of DataLab replay using their saved state. UI actions + are replayed by invoking their recorded method and may reproduce side + effects, including creating, importing, or duplicating objects; + destructive actions are skipped when their captured targets no longer + resolve. +- |step_by_step| **Step-by-step**: replay the same selection one step at a + time, opening the parameter dialog for each supported action (object + creation, computation, ROI extraction) before recomputing it. Accepted + edits propagate to the downstream dependent actions, which are recomputed + as well. Cancelling a dialog stops the replay, restores the parameter + edits made during that run, and silently recomputes any actions left + outdated so the chain stays up to date. +- |duplicate| **Duplicate**: duplicate the processing chain containing each + selected action, or the processing chains in each selected session. DataLab + clones the required objects and creates independent history sessions. +- |remove_incompatible| **Remove incompatible**: remove all actions whose + workspace state is no longer compatible with the current workspace. A + confirmation dialog shows how many actions will be removed. +- |delete| **Delete**: remove the selected actions or sessions from the + history. Removing an intermediate action splices it out and preserves its + downstream steps as an independent chain. + +.. note:: + + Double-clicking a tree item invokes **Replay** for the current selection, + with the same in-place recompute semantics documented above. + +Tree view +--------- + +The tree view organizes recorded actions into expandable sessions: + +- Each top-level row is a **session** associated with the Signals or Images + panel. Sessions may be started when recording is enabled, with **New + session**, or according to the configured session policy. +- Each child row is an **action**, with its title, date/time and a description + summarising its parameters or resolved call when available. A UI action whose + call cannot be resolved may have an empty description. + +The selection of one or several rows determines which entries are targeted by +the toolbar and context-menu commands. The context menu exposes the same +commands as the toolbar. + +When an action row is selected, its result object is selected in the +corresponding data panel when available; otherwise, its existing input objects +are selected. DataLab then switches to that data panel. + +While Record mode is enabled, selecting a session row makes that session active +for its data panel. + +Actions that are not compatible with the current workspace state (for example +because a referenced object identifier no longer exists, or because its data +array shape changed) are shown with a disabled foreground and an explanatory +tooltip. +They cannot be replayed until the workspace matches the recorded state again. + +Workspace state display +----------------------- + +Below the action tree, a split-view widget shows the **workspace state** +captured at the time of the selected action: + +- **Left table**: lists the signals that were selected, with their array shape. +- **Right table**: lists the images that were selected, with their dimensions. + +This information helps the user understand the context in which each action +was originally executed and diagnose compatibility issues when replaying +the current selection. + +Persistence +----------- + +The history can be persisted in two complementary ways: + +- **Embedded in the workspace**: when the workspace is saved to HDF5 + (``File > Save to HDF5 file``), the History Panel content is automatically + saved alongside the signals and images. Reloading the workspace restores + the recorded sessions. +- **Standalone history file** (``.dlhist``): the file embeds both the + recorded sessions **and** all objects currently present in both the Signals + and Images panels, whether or not an action references them. This makes the + file fully self-contained: + + - Opening a ``.dlhist`` into a **pristine workspace** (with no data objects + and no existing history sessions) restores the saved objects and sessions + directly. + - If the workspace is **already in use** (it contains any data object or + history session), DataLab imports the objects into new signal/image groups, + remaps their identifiers to avoid collisions, and appends imported history + sessions that reference those fresh identifiers. + +.. warning:: + + Replaying a session that depends on external files (e.g. opening a + dataset from disk) will only succeed if those files are still available at + the same locations as when the session was recorded. + +Chain reconnection on deletion +------------------------------- + +When a result object is deleted from the **signal or image panel** (not +from the History Panel tree), and that object was produced by a recorded +processing step, the History Panel automatically reconnects the processing +chain: + +- All downstream steps that consumed the deleted object are rewired to use + the source of the deleted step as their new input. +- For ``2_to_1`` operations (e.g. *difference*), the first source is used + for reconnection. +- If no valid source can be determined (e.g. the source itself was already + deleted), a warning is displayed listing the unreconnectable operations, + but the deletion is allowed to proceed. + +This behaviour mirrors removing a link from a chain: the adjacent links +reconnect to preserve the processing flow. + +.. note:: + + Reconnection is only triggered by deletions initiated from the signal/image + panels. Deleting an action directly from the History Panel tree behaves + differently: the selected action is spliced out instead of truncating the + session. If downstream steps depend on it, DataLab preserves them as an + independent chain by cloning the required intermediate object and reconnecting + those steps to the clone. Deleting a session removes that complete session. + +Auto-recompute +-------------- + +.. note:: + + When a result object is selected in the signal/image panel and it has + processing parameters (i.e. was produced by a 1-to-1 computation), a + **Processing** tab appears in the Properties panel. Checking + **Auto-recompute on edit** in that tab will re-run the computation + automatically 300 ms after any parameter modification. diff --git a/doc/features/common/overview.rst b/doc/features/common/overview.rst index 496028793..db73472ad 100644 --- a/doc/features/common/overview.rst +++ b/doc/features/common/overview.rst @@ -13,12 +13,11 @@ Basic concepts Working with DataLab is very easy. The user interface is intuitive and self-explanatory. The main window is divided into two main areas: -- The left area shows the list of data sets which are currently loaded in - DataLab, distibuted over two tabs: **Signals** and **Images**. The user can - switch between the two tabs by clicking on the corresponding tab: this - switches the main window to the corresponding panel, as well as the menu - and toolbar contents. Below the list of data sets, a **Properties** view - shows information about the currently selected data set. +- The left area contains the **Signals** and **Images** data tabs, which list + the data sets currently loaded in each panel. Clicking a tab switches the + main window to the corresponding panel, as well as the menu and toolbar + contents. Below the list of data sets, a **Properties** view shows + information about the currently selected data set. - The right area shows the visualization of the currently selected data set. The visualization is updated automatically when the user selects a new data @@ -28,6 +27,15 @@ self-explanatory. The main window is divided into two main areas: DataLab main window, at startup. +History Panel +^^^^^^^^^^^^^ + +The :ref:`historypanel` is an additional dockable panel that records signal +and image actions into separate active sessions for each data panel. It +supports replay, step-by-step replay, duplication, and compatibility +diagnostics. Sessions may be saved in standalone ``.dlhist`` files or with +the workspace in HDF5 format. + Internal data model and workspace --------------------------------- @@ -35,14 +43,14 @@ DataLab has its own internal data model, in which data sets are organized around a tree structure. Each panel in the main window corresponds to a branch of the tree. Each data set shown in the panels corresponds to a leaf of the tree. Inside the data set, the data is organized in an object-oriented way, with a set of -attributes and methods. The data model is described in more details in the +attributes and methods. The data model is described in more detail in the API section (see :mod:`sigima.objects`). -For each data set (1D signal or 2D image), not only the data itself is stored, -but also a set of metadata, which describes the data or the way it has to be -displayed. The metadata is stored in a dictionary, which is accessible through -the ``metadata`` attribute of the data set (and may also be browsed in the -**Properties** view, with the **Metadata** button). +For each signal or image object, DataLab stores not only the data itself but +also a set of metadata describing the data and how it should be displayed. The +metadata is stored in a dictionary accessible through the object's ``metadata`` +attribute (and may also be browsed in the **Properties** view, with the +**Metadata** button). The DataLab **Workspace** is defined as the collection of all data sets which are currently loaded in DataLab, in both the **Signals** and **Images** panels. @@ -63,7 +71,7 @@ The following actions are available to manage the workspace from the **File** me Data sets may also be saved or loaded individually, using data formats such as `.txt` or `.npy` for 1D signals (see :ref:`open_signal` for the - list of supported formats), , or `.tiff` or `.dcm` for 2D images + list of supported formats), or `.tiff` or `.dcm` for 2D images (see :ref:`open_image` for the list of supported formats). Interactive object creation and processing @@ -75,17 +83,17 @@ parameters, allowing you to fine-tune results without creating multiple objects. Interactive object creation ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -When creating a new signal or image using the creation functions (e.g., Gaussian signal, -2D peak image, etc.), DataLab stores the creation parameters in the object's metadata. -This enables interactive parameter adjustment after creation: +When creating a new signal or image object using the creation functions (e.g., Gaussian +signal, 2D peak image, etc.), DataLab stores the creation parameters in the object's +metadata. This enables interactive parameter adjustment after creation: -1. Create a signal or image using **Operations** > **Create** menu +1. Create a signal or image object from the **Create** menu 2. Select the created object in the list 3. A **Creation** tab appears in the Properties panel (bottom-left) 4. Modify any creation parameter (amplitude, frequency, size, etc.) 5. Click **Apply** to regenerate the object with new parameters -The object is updated in-place, preserving any subsequent processing or analysis results. +The object is updated in place, preserving any subsequent processing or analysis results. This is particularly useful for: - Exploring different parameter values without cluttering the workspace @@ -104,18 +112,18 @@ When applying a 1-to-1 processing operation that has configurable parameters (e. Gaussian filter, threshold, morphological operations), DataLab stores the processing metadata, enabling parameter adjustment and re-processing: -1. Apply a processing operation with parameters (e.g., **Processing** > **Filtering** > **Gaussian filter**) +1. Apply a processing operation with parameters (e.g., **Processing** > **Noise reduction** > **Gaussian filter**) 2. The result object contains processing metadata (parameters, source object, function name) 3. Select the processed object in the list 4. A **Processing** tab appears in the Properties panel 5. Modify processing parameters (e.g., filter sigma value) 6. Click **Apply** to re-process with updated parameters -The processed object is updated in-place with the new results. This workflow is ideal for: +The processed object is updated in place with the new results. This workflow is ideal for: -- Iteratively tuning filter parameters while observing results in real-time +- Iteratively tuning filter parameters while observing results in real time - Adjusting threshold values without creating multiple intermediate objects -- Experimenting with different morphological structure element sizes +- Experimenting with different morphological structuring element sizes - Educational demonstrations of parameter effects on processing results .. note:: @@ -137,7 +145,7 @@ Example workflow Here's a typical workflow using interactive processing: -1. **Create a test signal**: Operations > Create > Gaussian signal +1. **Create a test signal**: Create > Gaussian signal - Initial parameters: amplitude=1.0, mu=50, sigma=10 @@ -145,7 +153,7 @@ Here's a typical workflow using interactive processing: - Signal is regenerated with new width -3. **Apply Gaussian filter**: Processing > Filtering > Gaussian filter +3. **Apply Gaussian filter**: Processing > Noise reduction > Gaussian filter - Initial sigma=2.0 diff --git a/doc/features/common/settings.rst b/doc/features/common/settings.rst index 4cba364ee..fd353ad53 100644 --- a/doc/features/common/settings.rst +++ b/doc/features/common/settings.rst @@ -8,8 +8,8 @@ Settings :keywords: DataLab, settings, scientific, data, analysis, visualization, platform DataLab provides a comprehensive settings dialog to customize the application behavior, -visualization defaults, and I/O operations. The settings are organized into five tabs: -General, Processing, Visualization, I/O, and Console. +visualization defaults, and I/O operations. The settings are organized into six tabs: +General, Processing, Visualization, I/O, AI Assistant, and Console. General ------- @@ -126,7 +126,7 @@ The Processing settings tab controls computation behavior and default parameters for easier visualization and analysis. **Extract multiple ROIs in a single object** - When enabled, multiple ROIs (Regions of Interest) are extracted into a single object. + When enabled, multiple regions of interest (ROIs) are extracted into a single object. When disabled, each ROI is extracted into a separate object. **Ignore warnings** @@ -138,6 +138,47 @@ The Processing settings tab controls computation behavior and default parameters - **Ask**: display a confirmation dialog (default) - **Interpolate**: automatically interpolate signals +.. _history-session-settings: + +History sessions +^^^^^^^^^^^^^^^^ + +These settings control how new inputs are assigned to history sessions. They +are evaluated only when **Record mode** is enabled and the target Signals or +Images panel has a populated active session. No policy decision is needed when +the active session is empty. The two data panels keep separate active sessions; +see :ref:`historypanel` for the complete workflow. + +**New object or file** + Choose what happens when a new object is created or a file is loaded: + + - **Ask** (default): ask whether to start a new session + - **Always start a new session**: start a session before recording the input + - **Continue in the current session**: append the input to the active session + +**Plugin-created object** + Choose what happens when a plugin adds one object: + + - **Ask**: ask whether to start a new session + - **Always start a new session**: start a session before recording the object + - **Continue in the current session** (default): append the object without a + modal prompt, so plugin execution is not blocked + +**Plugin multi-load** + Choose what happens when a plugin explicitly groups several object + additions in one multi-load scope: + + - **Ask once**: ask once whether the whole batch should start a new session + - **Start a new session**: start one session for the batch + - **Continue in the current session** (default): append the whole batch to + the active session + + **Ask once** is the UI label for one durable session decision covering the + complete explicit plugin multi-load scope, rather than one prompt per + object. With ordinary **Ask** behavior, repeated prompts for synchronous + additions to the same panel are debounced during the current Qt event-loop + turn. + Result management ^^^^^^^^^^^^^^^^^ diff --git a/doc/features/index.rst b/doc/features/index.rst index b05605bf2..09254926d 100644 --- a/doc/features/index.rst +++ b/doc/features/index.rst @@ -35,6 +35,7 @@ Overview & Common features common/overview common/settings common/h5browser + common/historypanel .. raw:: latex diff --git a/doc/images/shots/history_panel.en.png b/doc/images/shots/history_panel.en.png new file mode 100644 index 000000000..7aaade100 Binary files /dev/null and b/doc/images/shots/history_panel.en.png differ diff --git a/doc/images/shots/history_panel.fr.png b/doc/images/shots/history_panel.fr.png new file mode 100644 index 000000000..813407000 Binary files /dev/null and b/doc/images/shots/history_panel.fr.png differ diff --git a/doc/images/shots/history_panel.png b/doc/images/shots/history_panel.png new file mode 100644 index 000000000..0ebd0e327 Binary files /dev/null and b/doc/images/shots/history_panel.png differ diff --git a/doc/images/tutorials/laser_beam/07.png b/doc/images/tutorials/laser_beam/07.png new file mode 100644 index 000000000..6107178a8 Binary files /dev/null and b/doc/images/tutorials/laser_beam/07.png differ diff --git a/doc/images/tutorials/laser_beam/09.png b/doc/images/tutorials/laser_beam/09.png index d63b6d4b0..23e65baed 100644 Binary files a/doc/images/tutorials/laser_beam/09.png and b/doc/images/tutorials/laser_beam/09.png differ diff --git a/doc/intro/tutorials/laser_beam.rst b/doc/intro/tutorials/laser_beam.rst index bd997fa56..8d0cf8212 100644 --- a/doc/intro/tutorials/laser_beam.rst +++ b/doc/intro/tutorials/laser_beam.rst @@ -91,6 +91,11 @@ To pan the image, use the middle mouse button while dragging. operation sets all image origins to match the first image's origin, which means any initial differences in image origins will be lost. + Both the "Distribute on a grid" and "Reset image positions" options + modify the images in place, without creating new images. For this + reason, they are not registered in the History Panel, which only + lists newly created objects. + .. |distribute_on_grid| image:: ../../../datalab/data/icons/processing/distribute_on_grid.svg :width: 24px :height: 24px @@ -109,7 +114,8 @@ it beneficial to apply a threshold to the images. Several methods are available to estimate the background noise level. One approach utilizes the "Cross section" tool, which is provided by the -`PlotPy ` library that DataLab uses for signal and image visualization. +`PlotPy `__ library that DataLab uses +for signal and image visualization. Select an image from the "Image panel", choose the corresponding image in the visualization panel, and activate the "Cross section" tool |cross_section| from the vertical toolbar on the left side of the visualization panel. This @@ -123,7 +129,8 @@ reveals that the background noise level is approximately 30 lsb. select the profile curve and right-click to open the context menu, then choose "Markers > Bound to active item". -Another method for measuring background noise, also provided by `PlotPy `, involves using the "Image statistics" tool +Another method for measuring background noise, also provided by +`PlotPy `__, involves using the "Image statistics" tool |imagestats| from the vertical toolbar on the left side of the visualization panel. This tool displays statistical information for a rectangular region that you define by dragging the mouse across the image. This analysis confirms that the background noise level is approximately 30 lsb. @@ -165,6 +172,20 @@ The intensity profile will be displayed in the "Signal panel". We can then fit t to a Gaussian function using "Processing > Fitting > Gaussian fit". Here we have selected both signals for comparison. +.. note:: + + If history recording is enabled, creating a new signal starts a new history + session, separate from the session containing the image operations. Since + extracting the intensity profile is an operation performed on an image, the + new session starts with the Gaussian fit applied to the resulting signal. + + .. figure:: ../../images/tutorials/laser_beam/07.png + + The image-processing actions are recorded in the first session, while + the Gaussian fit starts a separate session for the resulting signal. + + + .. figure:: ../../images/tutorials/laser_beam/08.png The intensity profile fitted to a Gaussian function. Here both signals are diff --git a/doc/locale/fr/LC_MESSAGES/contributing/dependencies.po b/doc/locale/fr/LC_MESSAGES/contributing/dependencies.po index 6d4022490..af15f6c0b 100644 --- a/doc/locale/fr/LC_MESSAGES/contributing/dependencies.po +++ b/doc/locale/fr/LC_MESSAGES/contributing/dependencies.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/contributing/environment.po b/doc/locale/fr/LC_MESSAGES/contributing/environment.po index 1b9e36aa7..56cc6e57c 100644 --- a/doc/locale/fr/LC_MESSAGES/contributing/environment.po +++ b/doc/locale/fr/LC_MESSAGES/contributing/environment.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/contributing/gitworkflow.po b/doc/locale/fr/LC_MESSAGES/contributing/gitworkflow.po index d11e1a1c2..5cc36c9a8 100644 --- a/doc/locale/fr/LC_MESSAGES/contributing/gitworkflow.po +++ b/doc/locale/fr/LC_MESSAGES/contributing/gitworkflow.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/advanced/api.po b/doc/locale/fr/LC_MESSAGES/features/advanced/api.po index ebd1d8ebe..5a96ac49e 100644 --- a/doc/locale/fr/LC_MESSAGES/features/advanced/api.po +++ b/doc/locale/fr/LC_MESSAGES/features/advanced/api.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/advanced/api/index.po b/doc/locale/fr/LC_MESSAGES/features/advanced/api/index.po index 44d935dff..0bdc292fa 100644 --- a/doc/locale/fr/LC_MESSAGES/features/advanced/api/index.po +++ b/doc/locale/fr/LC_MESSAGES/features/advanced/api/index.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/advanced/macros.po b/doc/locale/fr/LC_MESSAGES/features/advanced/macros.po index 8d1d4e44a..f0ed5de78 100644 --- a/doc/locale/fr/LC_MESSAGES/features/advanced/macros.po +++ b/doc/locale/fr/LC_MESSAGES/features/advanced/macros.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/advanced/migration_v020_to_v100.po b/doc/locale/fr/LC_MESSAGES/features/advanced/migration_v020_to_v100.po index 05339cc3b..dca25a94f 100644 --- a/doc/locale/fr/LC_MESSAGES/features/advanced/migration_v020_to_v100.po +++ b/doc/locale/fr/LC_MESSAGES/features/advanced/migration_v020_to_v100.po @@ -5,7 +5,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/advanced/model.po b/doc/locale/fr/LC_MESSAGES/features/advanced/model.po index 978554e4e..9935ba672 100644 --- a/doc/locale/fr/LC_MESSAGES/features/advanced/model.po +++ b/doc/locale/fr/LC_MESSAGES/features/advanced/model.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/advanced/proxy.po b/doc/locale/fr/LC_MESSAGES/features/advanced/proxy.po index 6279448b2..3563e2bb0 100644 --- a/doc/locale/fr/LC_MESSAGES/features/advanced/proxy.po +++ b/doc/locale/fr/LC_MESSAGES/features/advanced/proxy.po @@ -6,7 +6,9 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" +"Project-Id-Version: PROJECT VERSION\n" +"POT-Creation-Date: 2026-08-05 10:58+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" @@ -15,19 +17,27 @@ msgstr "" msgid "Proxy objects (:mod:`datalab.control.proxy`)" msgstr "" -msgid "The :mod:`datalab.control.proxy` module provides a way to access DataLab features from a proxy class." +msgid "" +"The :mod:`datalab.control.proxy` module provides a way to access DataLab " +"features from a proxy class." msgstr "" msgid "Remote proxy" msgstr "" -msgid "The remote proxy is used when DataLab is started from a different process than the proxy. In this case, the proxy connects to DataLab XML-RPC server." +msgid "" +"The remote proxy is used when DataLab is started from a different process" +" than the proxy. In this case, the proxy connects to DataLab XML-RPC " +"server." msgstr "" msgid "DataLab remote proxy class." msgstr "" -msgid "This class provides access to DataLab features from a proxy class. This is the remote version of proxy, which is used when DataLab is started from a different process than the proxy." +msgid "" +"This class provides access to DataLab features from a proxy class. This " +"is the remote version of proxy, which is used when DataLab is started " +"from a different process than the proxy." msgstr "" msgid "Parameters" @@ -51,7 +61,9 @@ msgstr "" msgid "Examples" msgstr "" -msgid "Here is a simple example of how to use RemoteProxy in a Python script or in a Jupyter notebook:" +msgid "" +"Here is a simple example of how to use RemoteProxy in a Python script or " +"in a Jupyter notebook:" msgstr "" msgid "Add object annotations (annotation plot items)." @@ -63,7 +75,9 @@ msgstr "" msgid "refresh plot. Defaults to True." msgstr "" -msgid "panel name (valid values: \"signal\", \"image\"). If None, current panel is used." +msgid "" +"panel name (valid values: \"signal\", \"image\"). If None, current panel " +"is used." msgstr "" msgid "Add group to DataLab." @@ -168,7 +182,11 @@ msgstr "" msgid "Call computation feature ``name``" msgstr "" -msgid "This calls either the processor's ``compute_`` method (if it exists), or the processor's ```` computation feature (if it is registered, using the ``run_feature`` method). It looks for the function in all panels, starting with the current one." +msgid "" +"This calls either the processor's ``compute_`` method (if it " +"exists), or the processor's ```` computation feature (if it is " +"registered, using the ``run_feature`` method). It looks for the function " +"in all panels, starting with the current one." msgstr "" msgid "Compute function name" @@ -183,7 +201,9 @@ msgstr "" msgid "Call a public method on a panel or main window." msgstr "" -msgid "Method resolution order when panel is None: 1. Try main window (DLMainWindow) 2. If not found, try current panel (BaseDataPanel)" +msgid "" +"Method resolution order when panel is None: 1. Try main window " +"(DLMainWindow) 2. If not found, try current panel (BaseDataPanel)" msgstr "" msgid "Name of the method to call" @@ -192,7 +212,9 @@ msgstr "" msgid "Positional arguments to pass to the method" msgstr "" -msgid "Panel name (\"signal\", \"image\", or None for auto-detection). Defaults to None." +msgid "" +"Panel name (\"signal\", \"image\", or None for auto-detection). Defaults " +"to None." msgstr "" msgid "Keyword arguments to pass to the method" @@ -213,13 +235,19 @@ msgstr "" msgid "Try to connect to DataLab XML-RPC server." msgstr "" -msgid "XML-RPC port to connect to. If not specified, the port is automatically retrieved from DataLab configuration." +msgid "" +"XML-RPC port to connect to. If not specified, the port is automatically " +"retrieved from DataLab configuration." msgstr "" -msgid "Maximum time to wait for connection in seconds. Defaults to 5.0. This is the total maximum wait time, not per retry." +msgid "" +"Maximum time to wait for connection in seconds. Defaults to 5.0. This is " +"the total maximum wait time, not per retry." msgstr "" -msgid "Number of retries. Defaults to 10. This parameter is deprecated and will be removed in a future version (kept for backward compatibility)." +msgid "" +"Number of retries. Defaults to 10. This parameter is deprecated and will " +"be removed in a future version (kept for backward compatibility)." msgstr "" msgid "Return a context manager to temporarily disable auto refresh." @@ -270,7 +298,9 @@ msgstr "" msgid "Get object (signal/image) from index." msgstr "" -msgid "Object number, or object id, or object title. Defaults to None (current object)." +msgid "" +"Object number, or object id, or object title. Defaults to None (current " +"object)." msgstr "" msgid "Panel name. Defaults to None (current panel)." @@ -288,10 +318,14 @@ msgstr "" msgid "List of plot item shapes" msgstr "" -msgid "Get object (signal/image) list for current panel. Objects are sorted by group number and object index in group." +msgid "" +"Get object (signal/image) list for current panel. Objects are sorted by " +"group number and object index in group." msgstr "" -msgid "panel name (valid values: \"signal\", \"image\", \"macro\"). If None, current data panel is used (i.e. signal or image panel)." +msgid "" +"panel name (valid values: \"signal\", \"image\", \"macro\"). If None, " +"current data panel is used (i.e. signal or image panel)." msgstr "" msgid "List of object titles" @@ -300,7 +334,9 @@ msgstr "" msgid "if panel not found" msgstr "" -msgid "Get object (signal/image) uuid list for current panel. Objects are sorted by group number and object index in group." +msgid "" +"Get object (signal/image) uuid list for current panel. Objects are sorted" +" by group number and object index in group." msgstr "" msgid "Group number, or group id, or group title. Defaults to None (all groups)." @@ -369,10 +405,15 @@ msgstr "" msgid "Load native DataLab HDF5 workspace files without any GUI elements." msgstr "" -msgid "This method can be safely called from scripts (e.g., internal console, macros) as it does not create any Qt widgets, dialogs, or progress bars." +msgid "" +"This method can be safely called from scripts (e.g., internal console, " +"macros) as it does not create any Qt widgets, dialogs, or progress bars." msgstr "" -msgid "This method only supports native DataLab HDF5 files. For importing arbitrary HDF5 files (non-native), use :meth:`open_h5_files` or :meth:`import_h5_file` instead." +msgid "" +"This method only supports native DataLab HDF5 files. For importing " +"arbitrary HDF5 files (non-native), use :meth:`open_h5_files` or " +":meth:`import_h5_file` instead." msgstr "" msgid "List of native DataLab HDF5 filenames" @@ -429,10 +470,14 @@ msgstr "" msgid "Select groups in current panel." msgstr "" -msgid "List of group numbers (1 to N), or list of group uuids, or None to select all groups. Defaults to None." +msgid "" +"List of group numbers (1 to N), or list of group uuids, or None to select" +" all groups. Defaults to None." msgstr "" -msgid "panel name (valid values: \"signal\", \"image\"). If None, current panel is used. Defaults to None." +msgid "" +"panel name (valid values: \"signal\", \"image\"). If None, current panel " +"is used. Defaults to None." msgstr "" msgid "Select objects in current panel." @@ -450,10 +495,15 @@ msgstr "" msgid "Set object data in DataLab." msgstr "" -msgid "Update an existing object in DataLab with new data from ``obj``. The object is identified by its UUID (which is carried by ``obj`` from a previous :meth:`get_object` call)." +msgid "" +"Update an existing object in DataLab with new data from ``obj``. The " +"object is identified by its UUID (which is carried by ``obj`` from a " +"previous :meth:`get_object` call)." msgstr "" -msgid "Signal or image object (must have the same UUID as an existing object in DataLab)" +msgid "" +"Signal or image object (must have the same UUID as an existing object in " +"DataLab)" msgstr "" msgid "if no object with matching UUID is found" @@ -462,7 +512,9 @@ msgstr "" msgid "Set XML-RPC port to connect to." msgstr "" -msgid "XML-RPC port to connect to. If None, the port is automatically retrieved from DataLab configuration." +msgid "" +"XML-RPC port to connect to. If None, the port is automatically retrieved " +"from DataLab configuration." msgstr "" msgid "Start the WebAPI server." @@ -501,22 +553,51 @@ msgstr "" msgid "Local proxy" msgstr "" -msgid "The local proxy is used when DataLab is started from the same process as the proxy. In this case, the proxy is directly connected to DataLab main window instance. The typical use case is high-level scripting." +msgid "" +"The local proxy is used when DataLab is started from the same process as " +"the proxy. In this case, the proxy is directly connected to DataLab main " +"window instance. The typical use case is high-level scripting." msgstr "" msgid "DataLab local proxy class." msgstr "" -msgid "This class provides access to DataLab features from a proxy class. This is the local version of proxy, which is used when DataLab is started from the same process as the proxy." +msgid "" +"This class provides access to DataLab features from a proxy class. This " +"is the local version of proxy, which is used when DataLab is started from" +" the same process as the proxy." msgstr "" msgid "DLMainWindow instance." msgstr "" +msgid "Source of objects added through this proxy." +msgstr "" + +msgid "Optional history session creation policy" +msgstr "" + +msgid "True if the object was added successfully, False otherwise" +msgstr "" + +msgid "Apply one lazy session decision to a multi-object load." +msgstr "" + +msgid "Target data panel (\"signal\" or \"image\")" +msgstr "" + +msgid "If the panel or session behavior is invalid" +msgstr "" + +msgid "If another multiload session is already active" +msgstr "" + msgid "Compute function parameter. Defaults to None" msgstr "" -msgid "Object number, or object id, or object title. Defaults to None (current object)" +msgid "" +"Object number, or object id, or object title. Defaults to None (current " +"object)" msgstr "" msgid "Panel name. Defaults to None (current panel)" @@ -525,13 +606,18 @@ msgstr "" msgid "refresh plot. Defaults to True" msgstr "" -msgid "panel name (valid values: \"signal\", \"image\"). If None, current panel is used" +msgid "" +"panel name (valid values: \"signal\", \"image\"). If None, current panel " +"is used" msgstr "" msgid "Load HDF5 workspace files without showing file dialog." msgstr "" -msgid "This method loads one or more DataLab native HDF5 files directly, bypassing the file dialog. It is safe to call from the internal console or any context where Qt dialogs would cause threading issues." +msgid "" +"This method loads one or more DataLab native HDF5 files directly, " +"bypassing the file dialog. It is safe to call from the internal console " +"or any context where Qt dialogs would cause threading issues." msgstr "" msgid "Path(s) to HDF5 file(s). Can be a single path string or a list of paths" @@ -546,7 +632,10 @@ msgstr "" msgid "Save workspace to HDF5 file without showing file dialog." msgstr "" -msgid "This method saves the current workspace to a DataLab native HDF5 file directly, bypassing the file dialog. It is safe to call from the internal console or any context where Qt dialogs would cause threading issues." +msgid "" +"This method saves the current workspace to a DataLab native HDF5 file " +"directly, bypassing the file dialog. It is safe to call from the internal" +" console or any context where Qt dialogs would cause threading issues." msgstr "" msgid "Path to the output HDF5 file" @@ -576,19 +665,29 @@ msgstr "" msgid "Proxy context manager" msgstr "" -msgid "The proxy context manager is a convenient way to handle proxy creation and destruction. It is used as follows:" +msgid "" +"The proxy context manager is a convenient way to handle proxy creation " +"and destruction. It is used as follows:" msgstr "" -msgid "The proxy type can be \"local\" or \"remote\". For remote proxy, the port can be specified as \"remote:port\"." +msgid "" +"The proxy type can be \"local\" or \"remote\". For remote proxy, the port" +" can be specified as \"remote:port\"." msgstr "" -msgid "The proxy context manager allows to use the proxy in various contexts (Python script, Jupyter notebook, etc.). It also allows to switch seamlessly between local and remote proxy, keeping the same code inside the context." +msgid "" +"The proxy context manager allows to use the proxy in various contexts " +"(Python script, Jupyter notebook, etc.). It also allows to switch " +"seamlessly between local and remote proxy, keeping the same code inside " +"the context." msgstr "" msgid "Context manager handling DL proxy creation and destruction." msgstr "" -msgid "proxy type (\"local\" or \"remote\") For remote proxy, the port can be specified as \"remote:port\"" +msgid "" +"proxy type (\"local\" or \"remote\") For remote proxy, the port can be " +"specified as \"remote:port\"" msgstr "" msgid "Yields" @@ -597,7 +696,9 @@ msgstr "" msgid "proxy" msgstr "" -msgid "LocalProxy if what == \"local\" RemoteProxy if what == \"remote\" or \"remote:port\"" +msgid "" +"LocalProxy if what == \"local\" RemoteProxy if what == \"remote\" or " +"\"remote:port\"" msgstr "" msgid "with proxy_context(\"local\") as proxy:" @@ -609,7 +710,9 @@ msgstr "" msgid "Calling processor methods using proxy objects" msgstr "" -msgid "All the proxy objects provide access to the DataLab computing methods exposed by the processor classes:" +msgid "" +"All the proxy objects provide access to the DataLab computing methods " +"exposed by the processor classes:" msgstr "" msgid ":class:`datalab.gui.processor.signal.SignalProcessor`" @@ -618,6 +721,7 @@ msgstr "" msgid ":class:`datalab.gui.processor.image.ImageProcessor`" msgstr "" -msgid "To run a computation feature associated to a processor, you can use the :meth:`calc` method of the proxy object:" +msgid "" +"To run a computation feature associated to a processor, you can use the " +":meth:`calc` method of the proxy object:" msgstr "" - diff --git a/doc/locale/fr/LC_MESSAGES/features/advanced/webapi.po b/doc/locale/fr/LC_MESSAGES/features/advanced/webapi.po index d4bf5ba9e..99c5a26ee 100644 --- a/doc/locale/fr/LC_MESSAGES/features/advanced/webapi.po +++ b/doc/locale/fr/LC_MESSAGES/features/advanced/webapi.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/common/h5browser.po b/doc/locale/fr/LC_MESSAGES/features/common/h5browser.po index e0833be92..e61715288 100644 --- a/doc/locale/fr/LC_MESSAGES/features/common/h5browser.po +++ b/doc/locale/fr/LC_MESSAGES/features/common/h5browser.po @@ -2,7 +2,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/common/historypanel.po b/doc/locale/fr/LC_MESSAGES/features/common/historypanel.po new file mode 100644 index 000000000..a0ba6f4b9 --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/features/common/historypanel.po @@ -0,0 +1,758 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2023, DataLab Platform Developers +# This file is distributed under the same license as the DataLab package. +# FIRST AUTHOR , 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"POT-Creation-Date: 2026-08-06 09:11+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "" +"History Panel in DataLab, the open-source scientific data analysis and " +"visualization platform" +msgstr "" +"Panneau d'historique de DataLab, la plateforme open-source d'analyse et " +"de visualisation de données scientifiques" + +msgid "" +"DataLab, history, record, replay, session, scientific, data, analysis, " +"visualization, platform" +msgstr "" +"DataLab, historique, enregistrement, rejeu, session, scientifique, " +"données, analyse, visualisation, plateforme" + +msgid "History Panel" +msgstr "Panneau d'historique" + +msgid "Overview" +msgstr "Vue d'ensemble" + +msgid "" +"The \"History Panel\" records the sequence of actions performed by the " +"user on signals and images, organized into **sessions**. Each session is " +"a chronological list of either:" +msgstr "" +"Le « panneau d'historique » enregistre la séquence des actions effectuées" +" par l'utilisateur sur les signaux et les images, organisée en " +"**sessions**. Chaque session est une liste chronologique constituée soit " +":" + +msgid "" +"**UI actions** (creating a new signal, removing selected objects, saving " +"the workspace to HDF5, ...)," +msgstr "" +"d'**actions de l'interface** (création d'un nouveau signal, suppression " +"des objets sélectionnés, enregistrement de l'espace de travail au format " +"HDF5, ...), soit" + +msgid "" +"**computations** (FFT, average, Gaussian fit, ...) dispatched by the " +"DataLab processors to Sigima, or" +msgstr "" +"de **calculs** (FFT, moyenne, ajustement gaussien, ...) dont l'exécution " +"est confiée à Sigima par les processeurs de DataLab, soit" + +msgid "" +"**mutations** (in-place modifications of existing objects, such as " +"editing regions of interest). See :ref:`history-object-mutations`." +msgstr "" +"de **mutations** (modifications en place d'objets existants, telles que " +"l'édition de régions d'intérêt). Voir :ref:`history-object-mutations`." + +msgid "A recorded session can be:" +msgstr "Une session enregistrée peut être :" + +msgid "" +"**Replayed** silently or **step by step**. Replaying recomputes each " +"computation action in place with its recorded parameters: existing output" +" objects are updated, and deleted outputs whose action is still part of " +"the history are re-created under their original identifiers, keeping the " +"downstream processing chain valid. In step-by-step mode, parameters may " +"be reviewed and edited before each step is recomputed. Recorded UI " +"actions are invoked through their own methods and may reproduce their " +"side effects, including creating, importing, or duplicating objects;" +msgstr "" +"**rejouée** silencieusement ou **pas à pas**. Le rejeu recalcule en place" +" chaque action de calcul avec ses paramètres enregistrés : les objets de " +"sortie existants sont mis à jour, et les sorties supprimées dont l'action" +" fait toujours partie de l'historique sont recréées sous leurs " +"identifiants d'origine, ce qui préserve la validité de la chaîne de " +"traitement en aval. En mode pas à pas, les paramètres peuvent être " +"examinés et modifiés avant le recalcul de chaque étape. Les actions de " +"l'interface enregistrées sont invoquées par leurs propres méthodes et " +"peuvent reproduire leurs effets, notamment créer, importer ou dupliquer " +"des objets ;" + +msgid "" +"**Duplicated** as independent processing chains in new history sessions, " +"with the required signal/image objects cloned as part of the operation;" +msgstr "" +"**dupliquée** sous forme de chaînes de traitement indépendantes dans de " +"nouvelles sessions d'historique, les objets signal/image nécessaires " +"étant clonés au cours de l'opération ;" + +msgid "" +"**Saved to a standalone history file** (``.dlhist``) or **embedded in the" +" workspace** when saving to HDF5, so that the full processing chain " +"travels with the data." +msgstr "" +"**sauvegardée dans un fichier d'historique autonome** (``.dlhist``) ou " +"**intégrée à l'espace de travail** lors de l'enregistrement au format " +"HDF5, de sorte que toute la chaîne de traitement accompagne les données." + +msgid "" +"The History Panel after recording a representative session: create three " +"signals (Voigt, Lorentzian, Lorentzian), remove one of them, create a " +"Gaussian signal, compute the average, add Gaussian noise to the result " +"and run a Gaussian fit." +msgstr "" +"Le panneau d'historique après l'enregistrement d'une session " +"représentative : création de trois signaux (Voigt, lorentzien, " +"lorentzien), suppression de l'un d'eux, création d'un signal gaussien, " +"calcul de la moyenne, ajout d'un bruit gaussien au résultat et ajustement" +" par une gaussienne." + +msgid "Object mutations" +msgstr "Mutations d'objets" + +msgid "" +"Besides UI actions and computations, the panel records **mutations**: in-" +"place modifications of existing objects that do not create new ones. " +"Mutations currently cover regions of interest (ROI): defining or editing " +"ROIs graphically or numerically, deleting one or all ROIs, and pasting " +"ROIs each record a single generic mutation entry holding the affected " +"objects and the resulting ROI state." +msgstr "" +"Outre les actions de l'interface et les calculs, le panneau enregistre " +"des **mutations** : des modifications en place d'objets existants qui ne " +"créent pas de nouveaux objets. Les mutations couvrent actuellement les " +"régions d'intérêt (ROI) : la définition ou l'édition de ROI de manière " +"graphique ou numérique, la suppression d'une ou de toutes les ROI et le " +"collage de ROI enregistrent chacun une entrée de mutation générique " +"unique contenant les objets concernés et l'état de ROI résultant." + +msgid "" +"Replaying a mutation re-applies the recorded ROI state to its target " +"objects (an empty state removes the ROIs). In step-by-step mode, the ROI " +"parameters can be reviewed and edited in a dialog before being re-" +"applied; editing them triggers a recompute of the downstream dependent " +"computations." +msgstr "" +"Le rejeu d'une mutation réapplique l'état de ROI enregistré à ses objets " +"cibles (un état vide supprime les ROI). En mode pas à pas, les paramètres" +" des ROI peuvent être examinés et modifiés dans une boîte de dialogue " +"avant d'être réappliqués ; leur modification déclenche un recalcul des " +"calculs dépendants en aval." + +msgid "" +"When a recomputed action re-creates or updates an object, the mutations " +"recorded on that object are re-applied in order, and analyses depending " +"on the mutated object are recomputed. User ROIs present on an output " +"object are preserved by in-place recomputes, unless the recompute itself " +"produces a ROI." +msgstr "" +"Lorsqu'une action recalculée recrée ou met à jour un objet, les mutations" +" enregistrées sur cet objet sont réappliquées dans l'ordre, et les " +"analyses dépendant de l'objet muté sont recalculées. Les ROI définies par" +" l'utilisateur sur un objet de sortie sont préservées par les recalculs " +"en place, sauf si le recalcul produit lui-même une ROI." + +msgid "" +"Mutation entries are saved with sessions (standalone ``.dlhist`` files " +"and HDF5 workspaces) like any other action; history files created with " +"earlier versions of DataLab load unchanged." +msgstr "" +"Les entrées de mutation sont sauvegardées avec les sessions (fichiers " +"``.dlhist`` autonomes et espaces de travail HDF5) comme toute autre " +"action ; les fichiers d'historique créés avec des versions antérieures de" +" DataLab se chargent sans modification." + +msgid "Recording and session lifecycle" +msgstr "Enregistrement et cycle de vie des sessions" + +msgid "" +"Actions are recorded only while **Record mode** is enabled. Turning " +"record mode off preserves existing sessions but does not add new entries." +msgstr "" +"Les actions ne sont enregistrées que lorsque le **Mode d'enregistrement**" +" est activé. Sa désactivation conserve les sessions existantes, mais " +"n'ajoute aucune nouvelle entrée." + +msgid "" +"The Signals and Images panels each have their own active session. New " +"actions are added to the active session of the data panel they concern, " +"so switching between signals and images does not mix their recording " +"contexts." +msgstr "" +"Les panneaux Signaux et Images disposent chacun de leur propre session " +"active. Les nouvelles actions sont ajoutées à la session active du " +"panneau de données concerné ; ainsi, le passage des signaux aux images ne" +" mélange pas leurs contextes d'enregistrement." + +msgid "" +"When a new object is created or a file is loaded into a populated active " +"session, a configurable policy determines whether DataLab asks, starts a " +"new session, or continues the current one. Plugin-created objects use " +"separate policies. An explicit plugin multi-load scope supplies one " +"durable session policy for the whole batch. With ordinary **Ask** " +"behavior, repeated prompts for synchronous additions to the same panel " +"are debounced during the current Qt event-loop turn." +msgstr "" +"Lorsqu'un nouvel objet est créé ou qu'un fichier est chargé dans une " +"session active non vide, une politique configurable détermine si DataLab " +"demande quoi faire, démarre une nouvelle session ou continue dans la " +"session courante. Des politiques distinctes s'appliquent aux objets créés" +" par des plugins. Une portée explicite de chargement multiple par un " +"plugin fournit une politique de session unique et durable pour l'ensemble" +" du lot. Avec le comportement **Demander** ordinaire, un mécanisme anti-" +"rebond évite les demandes répétées pour les ajouts synchrones au même " +"panneau pendant le tour courant de la boucle d'événements Qt." + +msgid "" +"These options are available under ``File > Settings > Processing > " +"History sessions``. See :ref:`history-session-settings` for the complete " +"labels and default values." +msgstr "" +"Ces options sont disponibles sous ``Fichier > Préférences > Traitement > " +"Sessions d'historique``. Voir :ref:`history-session-settings` pour la " +"liste complète des libellés et des valeurs par défaut." + +msgid "Toolbar" +msgstr "Barre d'outils" + +msgid "The toolbar at the top of the panel exposes the following actions:" +msgstr "La barre d'outils en haut du panneau expose les actions suivantes :" + +msgid "" +"|record| **Record mode**: toggle the recording of new actions. When off, " +"no new entry is added to the history (existing sessions are preserved)." +msgstr "" +"|record| **Mode d'enregistrement** : active ou désactive l'enregistrement" +" des nouvelles actions. Lorsqu'il est désactivé, aucune nouvelle entrée " +"n'est ajoutée à l'historique (les sessions existantes sont conservées)." + +msgid "record" +msgstr "record" + +msgid "" +"|new_session| **New session**: start a new active history session for the" +" current data panel." +msgstr "" +"|new_session| **Nouvelle session** : démarre une nouvelle session " +"d'historique active pour le panneau de données actuel." + +msgid "new_session" +msgstr "new_session" + +msgid "" +"|open_history| **Open history file**: load recorded sessions from a " +"standalone ``.dlhist`` file." +msgstr "" +"|open_history| **Ouvrir un fichier d'historique** : charge des sessions " +"enregistrées depuis un fichier ``.dlhist`` autonome." + +msgid "open_history" +msgstr "open_history" + +msgid "" +"|save_history| **Save history file**: save the current recorded sessions " +"to a standalone ``.dlhist`` file." +msgstr "" +"|save_history| **Enregistrer un fichier d'historique** : enregistre les " +"sessions actuellement enregistrées dans un fichier ``.dlhist`` autonome." + +msgid "save_history" +msgstr "save_history" + +msgid "" +"|step_prev| **Previous step**: select the preceding action in the current" +" session (keyboard shortcut: :kbd:`Ctrl+Left`)." +msgstr "" +"|step_prev| **Étape précédente** : sélectionne l'action précédente dans " +"la session courante (raccourci clavier : :kbd:`Ctrl+Gauche`)." + +msgid "step_prev" +msgstr "step_prev" + +msgid "" +"|step_next| **Next step**: select the following action in the current " +"session (keyboard shortcut: :kbd:`Ctrl+Right`)." +msgstr "" +"|step_next| **Étape suivante** : sélectionne l'action suivante dans la " +"session courante (raccourci clavier : :kbd:`Ctrl+Droite`)." + +msgid "step_next" +msgstr "step_next" + +msgid "" +"|replay| **Replay**: recompute the selection in place, silently (no " +"parameter dialogs). Selecting an action replays that action; selecting a " +"session replays all of its actions. A selection spanning several actions " +"or sessions is merged, deduplicated and executed in session order. Each " +"computation action re-runs with its recorded parameters and updates its " +"existing output object(s), keeping the same identifiers so that " +"downstream steps remain valid. Outputs that were deleted from the data " +"panel are re-created under their original identifiers (a typical " +"workflow: delete a bad result, edit its parameters, then replay to " +"regenerate it). Actions whose source objects no longer exist are skipped " +"with a warning, and a failed action blocks its downstream branch. Actions" +" whose parameters were changed (in step-by-step mode or from the " +"**Processing** tab) are marked as outdated; replaying recomputes them " +"and, when parameters were edited, their downstream dependent actions as " +"well. Analysis actions replay by recomputing their results on the source " +"objects: each analysis records which results it stored on the object (its" +" *effects*), so replaying updates exactly those results — previous values" +" are replaced, and if the recompute fails the previous results are " +"restored. Analyses recorded with earlier versions of DataLab replay using" +" their saved state. UI actions are replayed by invoking their recorded " +"method and may reproduce side effects, including creating, importing, or " +"duplicating objects; destructive actions are skipped when their captured " +"targets no longer resolve." +msgstr "" +"|replay| **Rejouer** : recalcule la sélection en place, silencieusement " +"(sans boîtes de dialogue de paramètres). Sélectionner une action rejoue " +"cette action ; sélectionner une session rejoue toutes ses actions. Une " +"sélection couvrant plusieurs actions ou sessions est fusionnée, " +"dédoublonnée et exécutée dans l'ordre des sessions. Chaque action de " +"calcul est réexécutée avec ses paramètres enregistrés et met à jour son " +"ou ses objets de sortie existants, en conservant les mêmes identifiants " +"afin que les étapes en aval restent valides. Les sorties supprimées du " +"panneau de données sont recréées sous leurs identifiants d'origine (flux " +"de travail typique : supprimer un résultat erroné, modifier ses " +"paramètres, puis rejouer pour le régénérer). Les actions dont les objets " +"source n'existent plus sont ignorées avec un avertissement, et une action" +" en échec bloque sa branche en aval. Les actions dont les paramètres ont " +"été modifiés (en mode pas à pas ou depuis l'onglet **Traitement**) sont " +"marquées comme obsolètes ; le rejeu les recalcule et, lorsque les " +"paramètres ont été modifiés, recalcule également leurs actions " +"dépendantes en aval. Les actions d'analyse sont rejouées en recalculant " +"leurs résultats sur les objets source : chaque analyse enregistre les " +"résultats qu'elle a stockés sur l'objet (ses *effets*), de sorte que le " +"rejeu met à jour exactement ces résultats — les valeurs précédentes sont " +"remplacées et, si le recalcul échoue, les résultats précédents sont " +"restaurés. Les analyses enregistrées avec des versions antérieures de " +"DataLab sont rejouées à partir de leur état sauvegardé. Les actions de " +"l'interface sont rejouées en invoquant leur méthode enregistrée et " +"peuvent reproduire des " +"effets, notamment créer, importer ou dupliquer des objets ; les actions " +"destructives sont ignorées lorsque leurs cibles capturées ne peuvent plus" +" être résolues." + +msgid "replay" +msgstr "replay" + +msgid "" +"|step_by_step| **Step-by-step**: replay the same selection one step at a " +"time, opening the parameter dialog for each supported action (object " +"creation, computation, ROI extraction) before recomputing it. Accepted " +"edits propagate to the downstream dependent actions, which are recomputed" +" as well. Cancelling a dialog stops the replay, restores the parameter " +"edits made during that run, and silently recomputes any actions left " +"outdated so the chain stays up to date." +msgstr "" +"|step_by_step| **Pas à pas** : rejoue la même sélection étape par étape, " +"en ouvrant la boîte de dialogue des paramètres pour chaque action prise " +"en charge (création d'objet, calcul, extraction de ROI) avant de la " +"recalculer. Les modifications acceptées se propagent aux actions " +"dépendantes en aval, qui sont également recalculées. L'annulation d'une " +"boîte de dialogue interrompt le rejeu, restaure les modifications de " +"paramètres effectuées durant cette exécution et recalcule silencieusement" +" les actions restées obsolètes afin que la chaîne reste à jour." + +msgid "step_by_step" +msgstr "step_by_step" + +msgid "" +"|duplicate| **Duplicate**: duplicate the processing chain containing each" +" selected action, or the processing chains in each selected session. " +"DataLab clones the required objects and creates independent history " +"sessions." +msgstr "" +"|duplicate| **Dupliquer** : duplique la chaîne de traitement contenant " +"chaque action sélectionnée, ou les chaînes de traitement de chaque " +"session sélectionnée. DataLab clone les objets nécessaires et crée des " +"sessions d'historique indépendantes." + +msgid "duplicate" +msgstr "duplicate" + +msgid "" +"|remove_incompatible| **Remove incompatible**: remove all actions whose " +"workspace state is no longer compatible with the current workspace. A " +"confirmation dialog shows how many actions will be removed." +msgstr "" +"|remove_incompatible| **Supprimer les actions incompatibles** : supprime " +"toutes les actions dont l'état de l'espace de travail n'est plus " +"compatible avec l'espace de travail actuel. Une boîte de dialogue de " +"confirmation indique combien d'actions seront supprimées." + +msgid "remove_incompatible" +msgstr "remove_incompatible" + +msgid "" +"|delete| **Delete**: remove the selected actions or sessions from the " +"history. Removing an intermediate action splices it out and preserves its" +" downstream steps as an independent chain." +msgstr "" +"|delete| **Supprimer** : retire de l'historique les actions ou sessions " +"sélectionnées. La suppression d'une action intermédiaire la retire de la " +"chaîne et conserve ses étapes en aval sous forme de chaîne indépendante." + +msgid "delete" +msgstr "delete" + +msgid "" +"Double-clicking a tree item invokes **Replay** for the current selection," +" with the same in-place recompute semantics documented above." +msgstr "" +"Un double-clic sur un élément de l'arborescence déclenche **Rejouer** " +"pour la sélection courante, avec la même sémantique de recalcul en place " +"que celle documentée ci-dessus." + +msgid "Tree view" +msgstr "Arborescence" + +msgid "The tree view organizes recorded actions into expandable sessions:" +msgstr "" +"L'arborescence organise les actions enregistrées dans des sessions " +"dépliables :" + +msgid "" +"Each top-level row is a **session** associated with the Signals or Images" +" panel. Sessions may be started when recording is enabled, with **New " +"session**, or according to the configured session policy." +msgstr "" +"Chaque ligne de premier niveau est une **session** associée au panneau " +"Signaux ou Images. Les sessions peuvent être démarrées lorsque " +"l'enregistrement est activé, avec **Nouvelle session**, ou selon la " +"politique de session configurée." + +msgid "" +"Each child row is an **action**, with its title, date/time and a " +"description summarising its parameters or resolved call when available. A" +" UI action whose call cannot be resolved may have an empty description." +msgstr "" +"Chaque ligne enfant est une **action**, accompagnée de son titre, de sa " +"date et de son heure, ainsi que d'une description résumant ses paramètres" +" ou l'appel résolu lorsqu'ils sont disponibles. Une action de l'interface" +" dont l'appel ne peut pas être résolu peut avoir une description vide." + +msgid "" +"The selection of one or several rows determines which entries are " +"targeted by the toolbar and context-menu commands. The context menu " +"exposes the same commands as the toolbar." +msgstr "" +"La sélection d'une ou de plusieurs lignes détermine les entrées ciblées " +"par les commandes de la barre d'outils et du menu contextuel. Le menu " +"contextuel propose les mêmes commandes que la barre d'outils." + +msgid "" +"When an action row is selected, its result object is selected in the " +"corresponding data panel when available; otherwise, its existing input " +"objects are selected. DataLab then switches to that data panel." +msgstr "" +"Lorsqu'une ligne d'action est sélectionnée, son objet résultat est " +"sélectionné dans le panneau de données correspondant s'il est disponible " +"; sinon, ses objets d'entrée existants sont sélectionnés. DataLab bascule" +" ensuite vers ce panneau de données." + +msgid "" +"While Record mode is enabled, selecting a session row makes that session " +"active for its data panel." +msgstr "" +"Lorsque le mode d'enregistrement est activé, sélectionner une ligne de " +"session rend cette session active pour son panneau de données." + +msgid "" +"Actions that are not compatible with the current workspace state (for " +"example because a referenced object identifier no longer exists, or " +"because its data array shape changed) are shown with a disabled " +"foreground and an explanatory tooltip. They cannot be replayed until the " +"workspace matches the recorded state again." +msgstr "" +"Les actions qui ne sont pas compatibles avec l'état courant de l'espace " +"de travail (par exemple parce qu'un identifiant d'objet référencé " +"n'existe plus, ou parce que la forme de son tableau a changé) sont " +"affichées avec un texte désactivé et une infobulle explicative. Elles ne " +"peuvent pas être rejouées tant que l'espace de travail ne correspond pas " +"de nouveau à l'état enregistré." + +msgid "Workspace state display" +msgstr "Affichage de l'état de l'espace de travail" + +msgid "" +"Below the action tree, a split-view widget shows the **workspace state** " +"captured at the time of the selected action:" +msgstr "" +"Sous l'arborescence des actions, un widget en vue divisée affiche " +"l'**état de l'espace de travail** tel qu'il était au moment de l'action " +"sélectionnée :" + +msgid "" +"**Left table**: lists the signals that were selected, with their array " +"shape." +msgstr "" +"**Tableau de gauche** : liste les signaux qui étaient sélectionnés, avec " +"la forme de leur tableau." + +msgid "" +"**Right table**: lists the images that were selected, with their " +"dimensions." +msgstr "" +"**Tableau de droite** : liste les images qui étaient sélectionnées, avec " +"leurs dimensions." + +msgid "" +"This information helps the user understand the context in which each " +"action was originally executed and diagnose compatibility issues when " +"replaying the current selection." +msgstr "" +"Ces informations aident l'utilisateur à comprendre le contexte dans " +"lequel chaque action a été exécutée à l'origine et à diagnostiquer les " +"problèmes de compatibilité lors du rejeu de la sélection actuelle." + +msgid "Persistence" +msgstr "Persistance" + +msgid "The history can be persisted in two complementary ways:" +msgstr "L'historique peut être enregistré de deux manières complémentaires :" + +msgid "" +"**Embedded in the workspace**: when the workspace is saved to HDF5 " +"(``File > Save to HDF5 file``), the History Panel content is " +"automatically saved alongside the signals and images. Reloading the " +"workspace restores the recorded sessions." +msgstr "" +"**Intégré à l'espace de travail** : lorsque l'espace de travail est " +"enregistré au format HDF5 (``Fichier > Enregistrer dans un fichier " +"HDF5``), le contenu du panneau d'historique est automatiquement " +"sauvegardé aux côtés des signaux et des images. Le rechargement de " +"l'espace de travail restaure les sessions enregistrées." + +msgid "" +"**Standalone history file** (``.dlhist``): the file embeds both the " +"recorded sessions **and** all objects currently present in both the " +"Signals and Images panels, whether or not an action references them. This" +" makes the file fully self-contained:" +msgstr "" +"**Fichier d'historique autonome** (``.dlhist``) : le fichier embarque à " +"la fois les sessions enregistrées **et** tous les objets actuellement " +"présents dans les panneaux Signaux et Images, qu'une action y fasse " +"référence ou non. Le fichier est ainsi entièrement autonome :" + +msgid "" +"Opening a ``.dlhist`` into a **pristine workspace** (with no data objects" +" and no existing history sessions) restores the saved objects and " +"sessions directly." +msgstr "" +"L'ouverture d'un fichier ``.dlhist`` dans un **espace de travail vierge**" +" (sans objet de données ni session d'historique existante) restaure " +"directement les objets et les sessions enregistrés." + +msgid "" +"If the workspace is **already in use** (it contains any data object or " +"history session), DataLab imports the objects into new signal/image " +"groups, remaps their identifiers to avoid collisions, and appends " +"imported history sessions that reference those fresh identifiers." +msgstr "" +"Si l'espace de travail est **déjà utilisé** (il contient un objet de " +"données ou une session d'historique), DataLab importe les objets dans de " +"nouveaux groupes signal/image, réaffecte leurs identifiants afin d'éviter" +" les collisions et ajoute les sessions d'historique importées qui " +"référencent ces nouveaux identifiants." + +msgid "" +"Replaying a session that depends on external files (e.g. opening a " +"dataset from disk) will only succeed if those files are still available " +"at the same locations as when the session was recorded." +msgstr "" +"Le rejeu d'une session qui dépend de fichiers externes (par exemple " +"l'ouverture d'un jeu de données depuis le disque) ne réussira que si ces " +"fichiers sont toujours disponibles aux mêmes emplacements qu'au moment de" +" l'enregistrement de la session." + +msgid "Chain reconnection on deletion" +msgstr "Reconnexion de la chaîne lors d'une suppression" + +msgid "" +"When a result object is deleted from the **signal or image panel** (not " +"from the History Panel tree), and that object was produced by a recorded " +"processing step, the History Panel automatically reconnects the " +"processing chain:" +msgstr "" +"Lorsqu'un objet résultat est supprimé depuis le **panneau signal ou " +"image** (et non depuis l'arborescence du panneau d'historique), et que " +"cet objet a été produit par une étape de traitement enregistrée, le " +"panneau d'historique reconnecte automatiquement la chaîne de traitement :" + +msgid "" +"All downstream steps that consumed the deleted object are rewired to use " +"the source of the deleted step as their new input." +msgstr "" +"Toutes les étapes aval qui consommaient l'objet supprimé sont recâblées " +"pour utiliser la source de l'étape supprimée comme nouvelle entrée." + +msgid "" +"For ``2_to_1`` operations (e.g. *difference*), the first source is used " +"for reconnection." +msgstr "" +"Pour les opérations ``2_to_1`` (par exemple *différence*), la première " +"source est utilisée pour la reconnexion." + +msgid "" +"If no valid source can be determined (e.g. the source itself was already " +"deleted), a warning is displayed listing the unreconnectable operations, " +"but the deletion is allowed to proceed." +msgstr "" +"Si aucune source valide ne peut être déterminée (par exemple la source " +"elle-même a déjà été supprimée), un avertissement est affiché listant les" +" opérations non reconnectables, mais la suppression est néanmoins " +"autorisée." + +msgid "" +"This behaviour mirrors removing a link from a chain: the adjacent links " +"reconnect to preserve the processing flow." +msgstr "" +"Ce comportement reproduit la suppression d'un maillon d'une chaîne : les " +"maillons adjacents se reconnectent pour préserver le flux de traitement." + +msgid "" +"Reconnection is only triggered by deletions initiated from the " +"signal/image panels. Deleting an action directly from the History Panel " +"tree behaves differently: the selected action is spliced out instead of " +"truncating the session. If downstream steps depend on it, DataLab " +"preserves them as an independent chain by cloning the required " +"intermediate object and reconnecting those steps to the clone. Deleting a" +" session removes that complete session." +msgstr "" +"La reconnexion n'est déclenchée que par les suppressions effectuées " +"depuis les panneaux Signaux ou Images. La suppression d'une action " +"directement depuis l'arborescence du panneau d'historique suit un autre " +"comportement : l'action sélectionnée est retirée de la chaîne au lieu de " +"tronquer la session. Si des étapes en aval en dépendent, DataLab les " +"conserve sous forme de chaîne indépendante en clonant l'objet " +"intermédiaire nécessaire et en reconnectant ces étapes au clone. La " +"suppression d'une session supprime cette session dans son intégralité." + +msgid "Auto-recompute" +msgstr "Recalcul automatique" + +msgid "" +"When a result object is selected in the signal/image panel and it has " +"processing parameters (i.e. was produced by a 1-to-1 computation), a " +"**Processing** tab appears in the Properties panel. Checking **Auto-" +"recompute on edit** in that tab will re-run the computation automatically" +" 300 ms after any parameter modification." +msgstr "" +"Lorsqu'un objet résultat est sélectionné dans le panneau signal/image et " +"qu'il possède des paramètres de traitement (c'est-à-dire qu'il a été " +"produit par un calcul 1-à-1), un onglet **Traitement** apparaît dans le " +"panneau Propriétés. Cocher **Recalcul automatique lors de l'édition** " +"dans cet onglet relancera automatiquement le calcul 300 ms après toute " +"modification d'un paramètre." + +#~ msgid "" +#~ "**Replayed** silently or **step by " +#~ "step**, with an opportunity to edit " +#~ "available parameters. New outputs from " +#~ "computation actions are not added to " +#~ "the data panels. Recorded UI actions " +#~ "are invoked through their own methods" +#~ " and may reproduce their side " +#~ "effects, including creating, importing, or " +#~ "duplicating objects, unless an action " +#~ "has a specific replay guard;" +#~ msgstr "" +#~ "**rejouée** silencieusement ou **pas à " +#~ "pas**, avec la possibilité de modifier" +#~ " les paramètres disponibles. Les nouvelles" +#~ " sorties des actions de calcul ne " +#~ "sont pas ajoutées aux panneaux de " +#~ "données. Les actions de l'interface " +#~ "enregistrées sont invoquées par leurs " +#~ "propres méthodes et peuvent reproduire " +#~ "leurs effets, notamment créer, importer " +#~ "ou dupliquer des objets, sauf lorsqu'une" +#~ " action dispose d'un mécanisme de " +#~ "protection spécifique lors du rejeu ;" + +#~ msgid "" +#~ "|replay| **Replay**: selecting an action " +#~ "replays that action directly; selecting " +#~ "a session replays the whole session. " +#~ "Compute actions restore their captured " +#~ "input selection when the translated " +#~ "object identifiers are compatible and " +#~ "resolvable; UI actions are replayed " +#~ "without restoring their recorded workspace " +#~ "selection. New objects returned by " +#~ "computation actions are not added to " +#~ "the data panels. Recorded UI actions " +#~ "may reproduce side effects, including " +#~ "creating, importing, or duplicating objects," +#~ " unless an action has a specific " +#~ "replay guard." +#~ msgstr "" +#~ "|replay| **Rejouer** : sélectionner une " +#~ "action rejoue directement cette action ;" +#~ " sélectionner une session rejoue toute " +#~ "la session. Les actions de calcul " +#~ "restaurent leur sélection d'entrées capturée" +#~ " lorsque les identifiants d'objets remappés" +#~ " sont compatibles et peuvent être " +#~ "résolus ; les actions de l'interface " +#~ "sont rejouées sans restaurer au " +#~ "préalable leur sélection de l'espace de" +#~ " travail enregistrée. Les nouveaux objets" +#~ " renvoyés par les actions de calcul" +#~ " ne sont pas ajoutés aux panneaux " +#~ "de données. Les actions de l'interface" +#~ " enregistrées peuvent reproduire des " +#~ "effets, notamment créer, importer ou " +#~ "dupliquer des objets, sauf lorsqu'une " +#~ "action dispose d'un mécanisme de " +#~ "protection spécifique lors du rejeu." + +#~ msgid "" +#~ "|step_by_step| **Step-by-step**: replay " +#~ "the selection one step at a time." +#~ " Parameters may be reviewed and " +#~ "edited when the action supports it; " +#~ "supported actions and their dependent " +#~ "branches are then updated or recomputed" +#~ " in place." +#~ msgstr "" +#~ "|step_by_step| **Pas à pas** : rejoue" +#~ " la sélection pas à pas. Les " +#~ "paramètres peuvent être examinés et " +#~ "modifiés lorsque l'action le permet ;" +#~ " les actions prises en charge et " +#~ "leurs branches dépendantes sont alors " +#~ "mises à jour ou recalculées sur " +#~ "place." + +#~ msgid "" +#~ "**UI actions** (creating a new signal," +#~ " removing selected objects, saving the " +#~ "workspace to HDF5, ...), or" +#~ msgstr "" +#~ "d'**actions de l'interface** (création d'un" +#~ " nouveau signal, suppression des objets " +#~ "sélectionnés, enregistrement de l'espace de" +#~ " travail au format HDF5, ...), soit" + +#~ msgid "" +#~ "**computations** (FFT, average, Gaussian fit," +#~ " ...) dispatched by the DataLab " +#~ "processors to Sigima." +#~ msgstr "" +#~ "de **calculs** (FFT, moyenne, ajustement " +#~ "gaussien, ...) dont l'exécution est " +#~ "confiée à Sigima par les processeurs " +#~ "de DataLab." diff --git a/doc/locale/fr/LC_MESSAGES/features/common/overview.po b/doc/locale/fr/LC_MESSAGES/features/common/overview.po index 186725009..dd8d27008 100644 --- a/doc/locale/fr/LC_MESSAGES/features/common/overview.po +++ b/doc/locale/fr/LC_MESSAGES/features/common/overview.po @@ -6,6 +6,9 @@ #, fuzzy msgid "" msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"POT-Creation-Date: 2026-08-05 14:24+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" @@ -25,8 +28,8 @@ msgstr "Concepts de base" msgid "Working with DataLab is very easy. The user interface is intuitive and self-explanatory. The main window is divided into two main areas:" msgstr "Travailler avec DataLab est très simple. L'interface est intuitive et appréhendable facilement. La fenêtre principale est divisée en deux zones principales :" -msgid "The left area shows the list of data sets which are currently loaded in DataLab, distibuted over two tabs: **Signals** and **Images**. The user can switch between the two tabs by clicking on the corresponding tab: this switches the main window to the corresponding panel, as well as the menu and toolbar contents. Below the list of data sets, a **Properties** view shows information about the currently selected data set." -msgstr "La zone de gauche affiche la liste des jeux de données actuellement chargés dans DataLab, répartis sur deux onglets : **Signaux** et **Images**. L'utilisateur peut basculer entre les deux onglets en cliquant sur l'onglet correspondant : cela bascule la fenêtre principale vers le panneau correspondant, ainsi que le contenu du menu et de la barre d'outils. Sous la liste des jeux de données, une vue **Propriétés** affiche des informations sur le jeu de données actuellement sélectionné." +msgid "The left area contains the **Signals** and **Images** data tabs, which list the data sets currently loaded in each panel. Clicking a tab switches the main window to the corresponding panel, as well as the menu and toolbar contents. Below the list of data sets, a **Properties** view shows information about the currently selected data set." +msgstr "La zone de gauche contient les onglets de données **Signaux** et **Images**, qui répertorient les jeux de données actuellement chargés dans chaque panneau. Cliquer sur un onglet fait basculer la fenêtre principale vers le panneau correspondant, ainsi que le contenu du menu et de la barre d'outils. Sous la liste des jeux de données, une vue **Propriétés** affiche des informations sur le jeu de données actuellement sélectionné." msgid "The right area shows the visualization of the currently selected data set. The visualization is updated automatically when the user selects a new data set in the list of data sets." msgstr "La zone de droite affiche la visualisation du jeu de données actuellement sélectionné. La visualisation est mise à jour automatiquement lorsque l'utilisateur sélectionne un nouveau jeu de données dans la liste des jeux de données." @@ -34,14 +37,20 @@ msgstr "La zone de droite affiche la visualisation du jeu de données actuelleme msgid "DataLab main window, at startup." msgstr "Fenêtre principale de DataLab, au démarrage." +msgid "History Panel" +msgstr "Panneau d'historique" + +msgid "The :ref:`historypanel` is an additional dockable panel that records signal and image actions into separate active sessions for each data panel. It supports replay, step-by-step replay, duplication, and compatibility diagnostics. Sessions may be saved in standalone ``.dlhist`` files or with the workspace in HDF5 format." +msgstr "Le :ref:`historypanel` est un panneau ancrable supplémentaire qui enregistre les actions sur les signaux et les images dans des sessions actives distinctes pour chaque panneau de données. Il permet de rejouer les actions, de les rejouer pas à pas, de les dupliquer et de diagnostiquer les incompatibilités. Les sessions peuvent être enregistrées dans des fichiers autonomes ``.dlhist`` ou avec l'espace de travail au format HDF5." + msgid "Internal data model and workspace" msgstr "Modèle de données interne et espace de travail" -msgid "DataLab has its own internal data model, in which data sets are organized around a tree structure. Each panel in the main window corresponds to a branch of the tree. Each data set shown in the panels corresponds to a leaf of the tree. Inside the data set, the data is organized in an object-oriented way, with a set of attributes and methods. The data model is described in more details in the API section (see :mod:`sigima.objects`)." +msgid "DataLab has its own internal data model, in which data sets are organized around a tree structure. Each panel in the main window corresponds to a branch of the tree. Each data set shown in the panels corresponds to a leaf of the tree. Inside the data set, the data is organized in an object-oriented way, with a set of attributes and methods. The data model is described in more detail in the API section (see :mod:`sigima.objects`)." msgstr "DataLab a son propre modèle de données interne, dans lequel les jeux de données sont organisés autour d'une structure arborescente. Chaque panneau de la fenêtre principale correspond à une branche de l'arbre. Chaque jeu de données affiché dans les panneaux correspond à une feuille de l'arbre. À l'intérieur du jeu de données, les données sont organisées de manière orientée objet, avec un ensemble d'attributs et de méthodes. Le modèle de données est décrit plus en détail dans la section API (voir :mod:`sigima.objects`)." -msgid "For each data set (1D signal or 2D image), not only the data itself is stored, but also a set of metadata, which describes the data or the way it has to be displayed. The metadata is stored in a dictionary, which is accessible through the ``metadata`` attribute of the data set (and may also be browsed in the **Properties** view, with the **Metadata** button)." -msgstr "Pour chaque jeu de données (signal 1D ou image 2D), non seulement les données elles-mêmes sont stockées, mais aussi un ensemble de métadonnées, qui décrit les données ou la façon dont elles doivent être affichées. Les métadonnées sont stockées dans un dictionnaire, qui est accessible via l'attribut ``metadata`` du jeu de données (et peuvent également être parcourues dans la vue **Propriétés**, avec le bouton **Métadonnées**)." +msgid "For each signal or image object, DataLab stores not only the data itself but also a set of metadata describing the data and how it should be displayed. The metadata is stored in a dictionary accessible through the object's ``metadata`` attribute (and may also be browsed in the **Properties** view, with the **Metadata** button)." +msgstr "Pour chaque objet signal ou image, DataLab stocke non seulement les données elles-mêmes, mais aussi un ensemble de métadonnées décrivant les données et la manière dont elles doivent être affichées. Ces métadonnées sont stockées dans un dictionnaire accessible par l'attribut ``metadata`` de l'objet (et peuvent également être consultées dans la vue **Propriétés**, à l'aide du bouton **Métadonnées**)." msgid "The DataLab **Workspace** is defined as the collection of all data sets which are currently loaded in DataLab, in both the **Signals** and **Images** panels." msgstr "L'**Espace de travail** de DataLab est défini comme l'ensemble de tous les jeux de données actuellement chargés dans DataLab, dans les panneaux **Signaux** et **Images**." @@ -61,7 +70,7 @@ msgstr "**Enregistrer dans un fichier HDF5** : enregistrer l'espace de travail a msgid "**Browse HDF5 file**: open the :ref:`h5browser` to explore the content of an HDF5 file and import data sets into the workspace." msgstr "**Parcourir un fichier HDF5** : ouvrir le :ref:`h5browser` pour explorer le contenu d'un fichier HDF5 et importer des jeux de données dans l'espace de travail." -msgid "Data sets may also be saved or loaded individually, using data formats such as `.txt` or `.npy` for 1D signals (see :ref:`open_signal` for the list of supported formats), , or `.tiff` or `.dcm` for 2D images (see :ref:`open_image` for the list of supported formats)." +msgid "Data sets may also be saved or loaded individually, using data formats such as `.txt` or `.npy` for 1D signals (see :ref:`open_signal` for the list of supported formats), or `.tiff` or `.dcm` for 2D images (see :ref:`open_image` for the list of supported formats)." msgstr "Les jeux de données peuvent également être enregistrés ou chargés individuellement, en utilisant des formats de données tels que `.txt` ou `.npy` pour les signaux 1D (voir :ref:`open_signal` pour la liste des formats pris en charge), ou `.tiff` ou `.dcm` pour les images 2D (voir :ref:`open_image` pour la liste des formats pris en charge)." msgid "Interactive object creation and processing" @@ -71,13 +80,13 @@ msgid "DataLab provides an interactive workflow for creating objects and adjusti msgstr "DataLab fournit un flux de travail interactif pour créer des objets et ajuster les paramètres de traitement, vous permettant d'affiner les résultats sans créer plusieurs objets." msgid "Interactive object creation" -msgstr "Création d'objets interactifs" +msgstr "Création interactive d'objets" -msgid "When creating a new signal or image using the creation functions (e.g., Gaussian signal, 2D peak image, etc.), DataLab stores the creation parameters in the object's metadata. This enables interactive parameter adjustment after creation:" +msgid "When creating a new signal or image object using the creation functions (e.g., Gaussian signal, 2D peak image, etc.), DataLab stores the creation parameters in the object's metadata. This enables interactive parameter adjustment after creation:" msgstr "Lors de la création d'un nouveau signal ou d'une nouvelle image à l'aide des fonctions de création (par exemple, signal gaussien, image de pic 2D, etc.), DataLab stocke les paramètres de création dans les métadonnées de l'objet. Cela permet d'ajuster les paramètres de manière interactive après la création :" -msgid "Create a signal or image using **Operations** > **Create** menu" -msgstr "Créer un signal ou une image en utilisant le menu **Opérations** > **Créer**" +msgid "Create a signal or image object from the **Create** menu" +msgstr "Créer un objet signal ou image depuis le menu **Création**" msgid "Select the created object in the list" msgstr "Sélectionner l'objet créé dans la liste" @@ -91,7 +100,7 @@ msgstr "Modifier n'importe quel paramètre de création (amplitude, fréquence, msgid "Click **Apply** to regenerate the object with new parameters" msgstr "Cliquer sur **Appliquer** pour régénérer l'objet avec les nouveaux paramètres" -msgid "The object is updated in-place, preserving any subsequent processing or analysis results. This is particularly useful for:" +msgid "The object is updated in place, preserving any subsequent processing or analysis results. This is particularly useful for:" msgstr "L'objet est mis à jour sur place, préservant tous les résultats de traitement ou d'analyse ultérieurs. Cela est particulièrement utile pour :" msgid "Exploring different parameter values without cluttering the workspace" @@ -110,10 +119,10 @@ msgid "Interactive 1-to-1 processing" msgstr "Traitement interactif 1-vers-1" msgid "When applying a 1-to-1 processing operation that has configurable parameters (e.g., Gaussian filter, threshold, morphological operations), DataLab stores the processing metadata, enabling parameter adjustment and re-processing:" -msgstr "Lors de l'application d'une opération de traitement 1-vers-1 qui a des paramètres configurables (par exemple, filtre gaussien, seuil, opérations morphologiques), DataLab stocke les métadonnées de traitement, permettant l'ajustement des paramètres et le re-traitement :" +msgstr "Lors de l'application d'une opération de traitement 1-vers-1 qui a des paramètres configurables (par exemple, filtre gaussien, seuil, opérations morphologiques), DataLab stocke les métadonnées de traitement, permettant l'ajustement des paramètres et le retraitement :" -msgid "Apply a processing operation with parameters (e.g., **Processing** > **Filtering** > **Gaussian filter**)" -msgstr "Appliquer une opération de traitement avec des paramètres (par exemple, **Traitement** > **Filtrage** > **Filtre gaussien**)" +msgid "Apply a processing operation with parameters (e.g., **Processing** > **Noise reduction** > **Gaussian filter**)" +msgstr "Appliquer une opération de traitement avec des paramètres (par exemple, **Traitement** > **Réduction de bruit** > **Filtre gaussien**)" msgid "The result object contains processing metadata (parameters, source object, function name)" msgstr "L'objet résultant contient des métadonnées de traitement (paramètres, objet source, nom de la fonction)" @@ -125,22 +134,22 @@ msgid "A **Processing** tab appears in the Properties panel" msgstr "Un onglet **Traitement** apparaît dans le panneau des propriétés" msgid "Modify processing parameters (e.g., filter sigma value)" -msgstr "Modifier les paramètres de traitement (par exemple, valeur sigma du filtre)" +msgstr "Modifier les paramètres de traitement (par exemple, valeur du sigma du filtre)" msgid "Click **Apply** to re-process with updated parameters" -msgstr "Cliquer sur **Appliquer** pour re-traiter avec les paramètres mis à jour" +msgstr "Cliquer sur **Appliquer** pour retraiter avec les paramètres mis à jour" -msgid "The processed object is updated in-place with the new results. This workflow is ideal for:" +msgid "The processed object is updated in place with the new results. This workflow is ideal for:" msgstr "L'objet traité est mis à jour sur place avec les nouveaux résultats. Ce flux de travail est idéal pour :" -msgid "Iteratively tuning filter parameters while observing results in real-time" -msgstr "Ajustement itératif des paramètres du filtre tout en observant les résultats en temps réel" +msgid "Iteratively tuning filter parameters while observing results in real time" +msgstr "Ajuster de manière itérative les paramètres du filtre tout en observant les résultats en temps réel" msgid "Adjusting threshold values without creating multiple intermediate objects" msgstr "Ajustement des valeurs de seuil sans créer plusieurs objets intermédiaires" -msgid "Experimenting with different morphological structure element sizes" -msgstr "Expérimentation avec différentes tailles d'éléments de structure morphologique" +msgid "Experimenting with different morphological structuring element sizes" +msgstr "Tester différentes tailles d'éléments structurants morphologiques" msgid "Educational demonstrations of parameter effects on processing results" msgstr "Démonstrations éducatives des effets des paramètres sur les résultats du traitement" @@ -155,7 +164,7 @@ msgid "Processing functions without parameters (e.g., absolute value, inverse) w msgstr "Les fonctions de traitement sans paramètres (par exemple, valeur absolue, inverse) fonctionnent comme auparavant" msgid "Source object must still exist for re-processing (error shown if deleted)" -msgstr "L'objet source doit toujours exister pour le re-traitement (erreur affichée s'il est supprimé)" +msgstr "L'objet source doit toujours exister pour le retraitement (erreur affichée s'il est supprimé)" msgid "**Not supported for:**" msgstr "**Non pris en charge pour :**" @@ -175,20 +184,20 @@ msgstr "Exemple de flux de travail" msgid "Here's a typical workflow using interactive processing:" msgstr "Voici un flux de travail typique utilisant le traitement interactif :" -msgid "**Create a test signal**: Operations > Create > Gaussian signal" -msgstr "**Créer un signal de test** : Opérations > Créer > Signal gaussien" +msgid "**Create a test signal**: Create > Gaussian signal" +msgstr "**Créer un signal de test** : Création > Signal gaussien" msgid "Initial parameters: amplitude=1.0, mu=50, sigma=10" msgstr "Paramètres initiaux : amplitude=1.0, mu=50, sigma=10" msgid "**Adjust creation parameters**: In the Creation tab, change sigma to 20, click Apply" -msgstr "**Ajuster les paramètres de création** : Dans l'onglet Création, changer sigma à 20, cliquer sur Appliquer" +msgstr "**Ajuster les paramètres de création** : Dans l'onglet Création, faire passer sigma à 20, cliquer sur Appliquer" msgid "Signal is regenerated with new width" msgstr "Le signal est régénéré avec une nouvelle largeur" -msgid "**Apply Gaussian filter**: Processing > Filtering > Gaussian filter" -msgstr "**Appliquer le filtre gaussien** : Traitement > Filtrage > Filtre gaussien" +msgid "**Apply Gaussian filter**: Processing > Noise reduction > Gaussian filter" +msgstr "**Appliquer le filtre gaussien** : Traitement > Réduction de bruit > Filtre gaussien" msgid "Initial sigma=2.0" msgstr "Sigma initial=2.0" @@ -197,7 +206,7 @@ msgid "**Fine-tune filtering**: In the Processing tab, try sigma=1.0, then sigma msgstr "**Ajuster le filtrage** : Dans l'onglet Traitement, essayer sigma=1.0, puis sigma=5.0" msgid "Each Apply updates the filtered result" -msgstr "Chaque application met à jour le résultat filtré" +msgstr "Chaque clic sur **Appliquer** met à jour le résultat filtré" msgid "Compare different smoothing levels without creating multiple objects" msgstr "Comparer différents niveaux de lissage sans créer plusieurs objets" diff --git a/doc/locale/fr/LC_MESSAGES/features/common/settings.po b/doc/locale/fr/LC_MESSAGES/features/common/settings.po index faa90f9a8..d230b6e42 100644 --- a/doc/locale/fr/LC_MESSAGES/features/common/settings.po +++ b/doc/locale/fr/LC_MESSAGES/features/common/settings.po @@ -5,7 +5,9 @@ # msgid "" msgstr "" -"Language: fr\n" +"Project-Id-Version: PROJECT VERSION\n" +"POT-Creation-Date: 2026-08-05 14:24+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" @@ -20,8 +22,8 @@ msgstr "DataLab, préférences, scientifique, données, analyse, visualisation, msgid "Settings" msgstr "Préférences" -msgid "DataLab provides a comprehensive settings dialog to customize the application behavior, visualization defaults, and I/O operations. The settings are organized into five tabs: General, Processing, Visualization, I/O, and Console." -msgstr "DataLab propose une boîte de dialogue de préférences complète pour personnaliser le comportement de l'application, les paramètres de visualisation par défaut et les opérations d'entrée/sortie. Les préférences sont organisées en cinq onglets : Général, Traitement, Visualisation, Entrée/sortie et Console." +msgid "DataLab provides a comprehensive settings dialog to customize the application behavior, visualization defaults, and I/O operations. The settings are organized into six tabs: General, Processing, Visualization, I/O, AI Assistant, and Console." +msgstr "DataLab propose une boîte de dialogue de préférences complète pour personnaliser le comportement de l'application, les paramètres de visualisation par défaut et les opérations d'entrée/sortie. Les préférences sont organisées en six onglets : Général, Traitement, Visualisation, Entrée/sortie, Assistant IA et Console." msgid "General" msgstr "Général" @@ -93,10 +95,10 @@ msgid "Choose the operation mode for computations taking N inputs:" msgstr "Choisir le mode d'opération pour les calculs prenant N entrées :" msgid "**Single**: single operand mode" -msgstr "**Single** : mode opérande unique" +msgstr "**Single** : mode d'opération avec un seul opérande" msgid "**Pairwise**: pairwise operation mode" -msgstr "**Pairwise** : mode opération par paire" +msgstr "**Pairwise** : mode d'opération par paire" msgid "These operation modes determine how DataLab handles computations involving multiple objects. They apply to two types of operations:" msgstr "Ces modes d'opération déterminent comment DataLab gère les calculs impliquant plusieurs objets. Ils s'appliquent à deux types d'opérations :" @@ -108,7 +110,7 @@ msgid "**N+1→N operations**: Apply an operation between N (≥1) objects and 1 msgstr "**Opérations N+1→N** : Appliquer une opération entre N (≥1) objets et 1 opérande pour produire N sorties (par exemple différence, division)" msgid "**Single operand mode** (default): Operations are applied independently within each group." -msgstr "**Mode opérande unique** (par défaut) : Les opérations sont appliquées indépendamment dans chaque groupe." +msgstr "**Mode d'opération avec un seul opérande** (par défaut) : Les opérations sont appliquées indépendamment dans chaque groupe." #, python-brace-format msgid "For **N→1 operations**: All objects in each group are combined into one result per group. Example with groups G1={A, B} and G2={C, D}, sum operation:" @@ -128,7 +130,7 @@ msgid "In G2: C-R, D-R" msgstr "Dans G2 : C-R, D-R" msgid "**Pairwise operation mode**: Objects from different groups are combined at matching positions (all groups must have the same number of objects)." -msgstr "**Mode opération par paire** : Les objets de différents groupes sont combinés aux positions correspondantes (tous les groupes doivent avoir le même nombre d'objets)." +msgstr "**Mode d'opération par paire** : Les objets de différents groupes sont combinés aux positions correspondantes (tous les groupes doivent avoir le même nombre d'objets)." #, python-brace-format msgid "For **N→1 operations**: Objects at the same position in each group are combined. Example with groups G1={A, B} and G2={C, D}, sum operation:" @@ -168,8 +170,8 @@ msgstr "Activer le décalage FFT pour centrer la composante de fréquence nulle msgid "**Extract multiple ROIs in a single object**" msgstr "**Extraire plusieurs ROI dans un seul objet**" -msgid "When enabled, multiple ROIs (Regions of Interest) are extracted into a single object. When disabled, each ROI is extracted into a separate object." -msgstr "Si le réglage est activé, plusieurs ROI (Régions d'Intérêt) sont extraites dans un seul objet. Lorsque désactivé, chaque ROI est extraite dans un objet séparé." +msgid "When enabled, multiple regions of interest (ROIs) are extracted into a single object. When disabled, each ROI is extracted into a separate object." +msgstr "Lorsque cette option est activée, plusieurs régions d'intérêt (ROI) sont extraites dans un seul objet. Lorsqu'elle est désactivée, chaque ROI est extraite dans un objet distinct." msgid "**Ignore warnings**" msgstr "**Ignorer les avertissements**" @@ -178,10 +180,10 @@ msgid "Suppress warning messages during computations." msgstr "Supprimer les messages d'avertissement pendant les calculs." msgid "**X-array compatibility behavior**" -msgstr "**Comportement de compatibilité des tableaux X**" +msgstr "**Comportement de compatibilité des tableaux des X**" msgid "Choose the behavior when X arrays are incompatible in multi-signal computations:" -msgstr "Choisir le comportement lorsque les tableaux X sont incompatibles dans les calculs multi-signaux :" +msgstr "Choisir le comportement lorsque les tableaux des X sont incompatibles dans les calculs multi-signaux :" msgid "**Ask**: display a confirmation dialog (default)" msgstr "**Demander** : afficher une boîte de dialogue de confirmation (par défaut)" @@ -189,6 +191,60 @@ msgstr "**Demander** : afficher une boîte de dialogue de confirmation (par déf msgid "**Interpolate**: automatically interpolate signals" msgstr "**Interpoler** : interpoler automatiquement les signaux" +msgid "History sessions" +msgstr "Sessions d'historique" + +msgid "These settings control how new inputs are assigned to history sessions. They are evaluated only when **Record mode** is enabled and the target Signals or Images panel has a populated active session. No policy decision is needed when the active session is empty. The two data panels keep separate active sessions; see :ref:`historypanel` for the complete workflow." +msgstr "Ces paramètres déterminent comment les nouvelles entrées sont affectées aux sessions d'historique. Ils ne sont évalués que lorsque le **Mode d'enregistrement** est activé et que le panneau Signaux ou Images cible possède une session active non vide. Aucune décision de politique n'est nécessaire lorsque la session active est vide. Les deux panneaux de données conservent des sessions actives distinctes ; voir :ref:`historypanel` pour le flux de travail complet." + +msgid "**New object or file**" +msgstr "**Nouvel objet ou fichier**" + +msgid "Choose what happens when a new object is created or a file is loaded:" +msgstr "Choisir ce qui se produit lorsqu'un nouvel objet est créé ou qu'un fichier est chargé :" + +msgid "**Ask** (default): ask whether to start a new session" +msgstr "**Demander** (par défaut) : demander s'il faut démarrer une nouvelle session" + +msgid "**Always start a new session**: start a session before recording the input" +msgstr "**Toujours démarrer une nouvelle session** : démarrer une session avant d'enregistrer l'entrée" + +msgid "**Continue in the current session**: append the input to the active session" +msgstr "**Continuer dans la session courante** : ajouter l'entrée à la session active" + +msgid "**Plugin-created object**" +msgstr "**Objet créé par un plugin**" + +msgid "Choose what happens when a plugin adds one object:" +msgstr "Choisir ce qui se produit lorsqu'un plugin ajoute un objet :" + +msgid "**Ask**: ask whether to start a new session" +msgstr "**Demander** : demander s'il faut démarrer une nouvelle session" + +msgid "**Always start a new session**: start a session before recording the object" +msgstr "**Toujours démarrer une nouvelle session** : démarrer une session avant d'enregistrer l'objet" + +msgid "**Continue in the current session** (default): append the object without a modal prompt, so plugin execution is not blocked" +msgstr "**Continuer dans la session courante** (par défaut) : ajouter l'objet sans afficher de boîte de dialogue modale, afin de ne pas bloquer l'exécution du plugin" + +msgid "**Plugin multi-load**" +msgstr "**Chargement multiple par un plugin**" + +msgid "Choose what happens when a plugin explicitly groups several object additions in one multi-load scope:" +msgstr "Choisir ce qui se produit lorsqu'un plugin regroupe explicitement plusieurs ajouts d'objets dans une même portée de chargement multiple :" + +msgid "**Ask once**: ask once whether the whole batch should start a new session" +msgstr "**Demander une seule fois** : demander une seule fois si l'ensemble du lot doit démarrer une nouvelle session" + +msgid "**Start a new session**: start one session for the batch" +msgstr "**Démarrer une nouvelle session** : démarrer une seule session pour le lot" + +msgid "**Continue in the current session** (default): append the whole batch to the active session" +msgstr "**Continuer dans la session courante** (par défaut) : ajouter l'ensemble du lot à la session active" + +msgid "**Ask once** is the UI label for one durable session decision covering the complete explicit plugin multi-load scope, rather than one prompt per object. With ordinary **Ask** behavior, repeated prompts for synchronous additions to the same panel are debounced during the current Qt event-loop turn." +msgstr "**Demander une seule fois** est le libellé de l'interface correspondant à une décision de session unique et durable qui couvre toute la portée explicite de chargement multiple du plugin, plutôt qu'à une demande par objet. Avec le comportement **Demander** ordinaire, un mécanisme anti-rebond évite les demandes répétées pour les ajouts synchrones au même panneau pendant le tour courant de la boucle d'événements Qt." + msgid "Result management" msgstr "Gestion des résultats" diff --git a/doc/locale/fr/LC_MESSAGES/features/image/menu_analysis.po b/doc/locale/fr/LC_MESSAGES/features/image/menu_analysis.po index 7c2c7eb9e..46b4dc7f2 100644 --- a/doc/locale/fr/LC_MESSAGES/features/image/menu_analysis.po +++ b/doc/locale/fr/LC_MESSAGES/features/image/menu_analysis.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/image/menu_create.po b/doc/locale/fr/LC_MESSAGES/features/image/menu_create.po index 1d3cd1694..1b125d319 100644 --- a/doc/locale/fr/LC_MESSAGES/features/image/menu_create.po +++ b/doc/locale/fr/LC_MESSAGES/features/image/menu_create.po @@ -5,7 +5,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/image/menu_edit.po b/doc/locale/fr/LC_MESSAGES/features/image/menu_edit.po index 71f157eb3..434463e48 100644 --- a/doc/locale/fr/LC_MESSAGES/features/image/menu_edit.po +++ b/doc/locale/fr/LC_MESSAGES/features/image/menu_edit.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/image/menu_file.po b/doc/locale/fr/LC_MESSAGES/features/image/menu_file.po index 22a0c646f..fde4a7016 100644 --- a/doc/locale/fr/LC_MESSAGES/features/image/menu_file.po +++ b/doc/locale/fr/LC_MESSAGES/features/image/menu_file.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/image/menu_operations.po b/doc/locale/fr/LC_MESSAGES/features/image/menu_operations.po index 7b1c1a053..20a521342 100644 --- a/doc/locale/fr/LC_MESSAGES/features/image/menu_operations.po +++ b/doc/locale/fr/LC_MESSAGES/features/image/menu_operations.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/image/menu_processing.po b/doc/locale/fr/LC_MESSAGES/features/image/menu_processing.po index 5e0045822..8a648e04f 100644 --- a/doc/locale/fr/LC_MESSAGES/features/image/menu_processing.po +++ b/doc/locale/fr/LC_MESSAGES/features/image/menu_processing.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/image/menu_roi.po b/doc/locale/fr/LC_MESSAGES/features/image/menu_roi.po index 5fca8a428..ca3baf985 100644 --- a/doc/locale/fr/LC_MESSAGES/features/image/menu_roi.po +++ b/doc/locale/fr/LC_MESSAGES/features/image/menu_roi.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/image/menu_view.po b/doc/locale/fr/LC_MESSAGES/features/image/menu_view.po index ce049bd04..125c58e05 100644 --- a/doc/locale/fr/LC_MESSAGES/features/image/menu_view.po +++ b/doc/locale/fr/LC_MESSAGES/features/image/menu_view.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/index.po b/doc/locale/fr/LC_MESSAGES/features/index.po index 874b303f2..e5b33d8df 100644 --- a/doc/locale/fr/LC_MESSAGES/features/index.po +++ b/doc/locale/fr/LC_MESSAGES/features/index.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/signal/menu_analysis.po b/doc/locale/fr/LC_MESSAGES/features/signal/menu_analysis.po index c65674c35..2f659b3ed 100644 --- a/doc/locale/fr/LC_MESSAGES/features/signal/menu_analysis.po +++ b/doc/locale/fr/LC_MESSAGES/features/signal/menu_analysis.po @@ -5,7 +5,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/signal/menu_create.po b/doc/locale/fr/LC_MESSAGES/features/signal/menu_create.po index 4c0742152..04721894c 100644 --- a/doc/locale/fr/LC_MESSAGES/features/signal/menu_create.po +++ b/doc/locale/fr/LC_MESSAGES/features/signal/menu_create.po @@ -5,7 +5,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/signal/menu_edit.po b/doc/locale/fr/LC_MESSAGES/features/signal/menu_edit.po index 8a398543c..801cddb30 100644 --- a/doc/locale/fr/LC_MESSAGES/features/signal/menu_edit.po +++ b/doc/locale/fr/LC_MESSAGES/features/signal/menu_edit.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/signal/menu_file.po b/doc/locale/fr/LC_MESSAGES/features/signal/menu_file.po index 02ae96953..ae55cbe8e 100644 --- a/doc/locale/fr/LC_MESSAGES/features/signal/menu_file.po +++ b/doc/locale/fr/LC_MESSAGES/features/signal/menu_file.po @@ -5,7 +5,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/signal/menu_operations.po b/doc/locale/fr/LC_MESSAGES/features/signal/menu_operations.po index eab89a371..6eeccc184 100644 --- a/doc/locale/fr/LC_MESSAGES/features/signal/menu_operations.po +++ b/doc/locale/fr/LC_MESSAGES/features/signal/menu_operations.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/signal/menu_processing.po b/doc/locale/fr/LC_MESSAGES/features/signal/menu_processing.po index 950d80ac8..2b462ead1 100644 --- a/doc/locale/fr/LC_MESSAGES/features/signal/menu_processing.po +++ b/doc/locale/fr/LC_MESSAGES/features/signal/menu_processing.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/signal/menu_roi.po b/doc/locale/fr/LC_MESSAGES/features/signal/menu_roi.po index c3a7e5788..c227ecdd3 100644 --- a/doc/locale/fr/LC_MESSAGES/features/signal/menu_roi.po +++ b/doc/locale/fr/LC_MESSAGES/features/signal/menu_roi.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/signal/menu_view.po b/doc/locale/fr/LC_MESSAGES/features/signal/menu_view.po index 45b2c8edc..712e6b5c2 100644 --- a/doc/locale/fr/LC_MESSAGES/features/signal/menu_view.po +++ b/doc/locale/fr/LC_MESSAGES/features/signal/menu_view.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/validation/functional.po b/doc/locale/fr/LC_MESSAGES/features/validation/functional.po index f77ce381d..eea31a8c8 100644 --- a/doc/locale/fr/LC_MESSAGES/features/validation/functional.po +++ b/doc/locale/fr/LC_MESSAGES/features/validation/functional.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/features/validation/status.po b/doc/locale/fr/LC_MESSAGES/features/validation/status.po index e0a849268..2f0388c14 100644 --- a/doc/locale/fr/LC_MESSAGES/features/validation/status.po +++ b/doc/locale/fr/LC_MESSAGES/features/validation/status.po @@ -6,14 +6,20 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" +"Project-Id-Version: PROJECT VERSION\n" +"POT-Creation-Date: 2026-08-05 10:58+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -msgid "Validation in DataLab, the open-source scientific data analysis and visualization platform" -msgstr "Validation dans DataLab, la plateforme open-source d'analyse et de visualisation de données scientifiques" +msgid "" +"Validation in DataLab, the open-source scientific data analysis and " +"visualization platform" +msgstr "" +"Validation dans DataLab, la plateforme open-source d'analyse et de " +"visualisation de données scientifiques" msgid "DataLab, scientific, data, analysis, validation, ground-truth, analytical" msgstr "DataLab, scientifique, données, analyse, validation, analytique" @@ -24,8 +30,12 @@ msgstr "Etat de validation de DataLab" msgid "Functional validation" msgstr "Validation fonctionnelle" -msgid "In DataLab, functional validation is based on a classic test strategy (see :ref:`functional_validation`)." -msgstr "Dans DataLab, la validation fonctionnelle est basée sur une stratégie de test classique (voir :ref:`functional_validation`)." +msgid "" +"In DataLab, functional validation is based on a classic test strategy " +"(see :ref:`functional_validation`)." +msgstr "" +"Dans DataLab, la validation fonctionnelle est basée sur une stratégie de " +"test classique (voir :ref:`functional_validation`)." msgid "Package" msgstr "Package" @@ -57,14 +67,44 @@ msgstr "" msgid "Technical validation" msgstr "Validation technique" -msgid "This paragraph provides the validation status of compute functions in DataLab (this is what we call technical validation, see :ref:`scientific_validation`)." -msgstr "Ce paragraphe fournit l'état de validation des fonctions de calcul dans DataLab (c'est ce que nous appelons validation technique, voir :ref:`scientific_validation`)." +msgid "" +"This paragraph provides the validation status of compute functions in " +"DataLab (this is what we call technical validation, see " +":ref:`scientific_validation`)." +msgstr "" +"Ce paragraphe fournit l'état de validation des fonctions de calcul dans " +"DataLab (c'est ce que nous appelons validation technique, voir " +":ref:`scientific_validation`)." -msgid "This is a work in progress: the tables below are updated continuously as new functions are validated or test code is adapted (the tables are generated from the test code). Some functions are already validated but do not appear in the list below yet, while others are still in the validation process." -msgstr "Il s'agit d'un travail en cours : les tableaux ci-dessous sont mis à jour en continu à mesure que de nouvelles fonctions sont validées ou que le code de test est adapté (les tableaux sont générés à partir du code de test). Certaines fonctions sont déjà validées mais n'apparaissent pas encore dans la liste ci-dessous, tandis que d'autres sont encore en cours de validation." +msgid "" +"This is a work in progress: the tables below are updated continuously as " +"new functions are validated or test code is adapted (the tables are " +"generated from the test code). Some functions are already validated but " +"do not appear in the list below yet, while others are still in the " +"validation process." +msgstr "" +"Il s'agit d'un travail en cours : les tableaux ci-dessous sont mis à jour" +" en continu à mesure que de nouvelles fonctions sont validées ou que le " +"code de test est adapté (les tableaux sont générés à partir du code de " +"test). Certaines fonctions sont déjà validées mais n'apparaissent pas " +"encore dans la liste ci-dessous, tandis que d'autres sont encore en cours" +" de validation." -msgid "The validation status must not be confused with the test coverage. The validation status indicates whether the function has been validated against ground-truth data or analytical models. The test coverage indicates the percentage of the code that is executed by the test suite, but it does not necessarily take into account the correctness of the results (DataLab's test coverage is around 90%)." -msgstr "L'état de validation ne doit pas être confondu avec la couverture de test. L'état de validation indique si la fonction a été validée par rapport à des données de référence ou des modèles analytiques. La couverture de test indique le pourcentage du code qui est exécuté par la suite de tests, mais elle ne prend pas nécessairement en compte la justesse des résultats (la couverture de test de DataLab est d'environ 90%)." +msgid "" +"The validation status must not be confused with the test coverage. The " +"validation status indicates whether the function has been validated " +"against ground-truth data or analytical models. The test coverage " +"indicates the percentage of the code that is executed by the test suite, " +"but it does not necessarily take into account the correctness of the " +"results (DataLab's test coverage is around 90%)." +msgstr "" +"L'état de validation ne doit pas être confondu avec la couverture de " +"test. L'état de validation indique si la fonction a été validée par " +"rapport à des données de référence ou des modèles analytiques. La " +"couverture de test indique le pourcentage du code qui est exécuté par la " +"suite de tests, mais elle ne prend pas nécessairement en compte la " +"justesse des résultats (la couverture de test de DataLab est d'environ " +"90%)." msgid "Validation Statistics" msgstr "Statistiques de validation" @@ -105,8 +145,13 @@ msgstr "" msgid "Signal Compute Functions" msgstr "Fonctions de calcul signal" -msgid "The table below shows the validation status of signal compute functions in DataLab. It is automatically generated from the source code." -msgstr "Le tableau ci-dessous montre l'état de validation des fonctions de calcul signal dans DataLab. Il est généré automatiquement à partir du code source." +msgid "" +"The table below shows the validation status of signal compute functions " +"in DataLab. It is automatically generated from the source code." +msgstr "" +"Le tableau ci-dessous montre l'état de validation des fonctions de calcul" +" signal dans DataLab. Il est généré automatiquement à partir du code " +"source." msgid "Validation status of signal compute functions" msgstr "Etat de validation de DataLab" @@ -126,7 +171,9 @@ msgstr "" msgid "Compute absolute value with :py:data:`numpy.absolute`" msgstr "" -msgid "`test_signal_absolute `_" +msgid "" +"`test_signal_absolute `_" msgstr "" msgid ":py:func:`add_gaussian_noise `" @@ -135,7 +182,9 @@ msgstr "" msgid "Add normal noise to the input signal" msgstr "" -msgid "`test_signal_add_gaussian_noise `_" +msgid "" +"`test_signal_add_gaussian_noise `_" msgstr "" msgid ":py:func:`add_poisson_noise `" @@ -144,7 +193,9 @@ msgstr "" msgid "Add Poisson noise to the input signal" msgstr "" -msgid "`test_signal_add_poisson_noise `_" +msgid "" +"`test_signal_add_poisson_noise `_" msgstr "" msgid ":py:func:`add_uniform_noise `" @@ -153,7 +204,9 @@ msgstr "" msgid "Add uniform noise to the input signal" msgstr "" -msgid "`test_signal_add_uniform_noise `_" +msgid "" +"`test_signal_add_uniform_noise `_" msgstr "" msgid ":py:func:`addition `" @@ -162,7 +215,9 @@ msgstr "" msgid "Compute the element-wise sum of multiple signals" msgstr "" -msgid "`test_signal_addition `_" +msgid "" +"`test_signal_addition `_" msgstr "" msgid ":py:func:`addition_constant `" @@ -171,7 +226,9 @@ msgstr "" msgid "Compute the sum of a signal and a constant value" msgstr "" -msgid "`test_signal_addition_constant `_" +msgid "" +"`test_signal_addition_constant `_" msgstr "" msgid ":py:func:`allan_deviation `" @@ -180,7 +237,9 @@ msgstr "" msgid "Compute Allan deviation" msgstr "Calculer la déviation d'Allan" -msgid "`test_signal_allan_deviation `_" +msgid "" +"`test_signal_allan_deviation `_" msgstr "" msgid ":py:func:`allan_variance `" @@ -189,7 +248,9 @@ msgstr "" msgid "Compute Allan variance" msgstr "" -msgid "`test_signal_allan_variance `_" +msgid "" +"`test_signal_allan_variance `_" msgstr "" msgid ":py:func:`apply_window `" @@ -198,7 +259,9 @@ msgstr "" msgid "Compute windowing" msgstr "Calculer la fenêtrage" -msgid "`test_signal_apply_window `_" +msgid "" +"`test_signal_apply_window `_" msgstr "" msgid ":py:func:`arithmetic `" @@ -207,7 +270,9 @@ msgstr "" msgid "Perform an arithmetic operation on two signals" msgstr "" -msgid "`test_signal_arithmetic `_" +msgid "" +"`test_signal_arithmetic `_" msgstr "" msgid ":py:func:`astype `" @@ -216,7 +281,9 @@ msgstr "" msgid "Convert data type" msgstr "" -msgid "`test_signal_astype `_" +msgid "" +"`test_signal_astype `_" msgstr "" msgid ":py:func:`average `" @@ -225,7 +292,9 @@ msgstr "" msgid "Compute the element-wise average of multiple signals" msgstr "" -msgid "`test_signal_average `_" +msgid "" +"`test_signal_average `_" msgstr "" msgid ":py:func:`bandpass `" @@ -234,7 +303,9 @@ msgstr "" msgid "Compute band-pass filter" msgstr "" -msgid "`test_signal_bandpass `_" +msgid "" +"`test_signal_bandpass `_" msgstr "" msgid ":py:func:`bandstop `" @@ -243,7 +314,9 @@ msgstr "" msgid "Compute band-stop filter" msgstr "" -msgid "`test_signal_bandstop `_" +msgid "" +"`test_signal_bandstop `_" msgstr "" msgid ":py:func:`bandwidth_3db `" @@ -252,7 +325,9 @@ msgstr "" msgid "Compute bandwidth at -3 dB" msgstr "" -msgid "`test_signal_bandwidth_3db `_" +msgid "" +"`test_signal_bandwidth_3db `_" msgstr "" msgid ":py:func:`calibration `" @@ -261,7 +336,9 @@ msgstr "" msgid "Compute linear calibration" msgstr "" -msgid "`test_signal_calibration `_" +msgid "" +"`test_signal_calibration `_" msgstr "" msgid ":py:func:`cdf_fit `" @@ -270,7 +347,9 @@ msgstr "" msgid "Compute CDF fit" msgstr "" -msgid "`test_signal_cdf_fit `_" +msgid "" +"`test_signal_cdf_fit `_" msgstr "" msgid ":py:func:`clip `" @@ -279,25 +358,35 @@ msgstr "" msgid "Compute maximum data clipping" msgstr "" -msgid "`test_signal_clip `_" +msgid "" +"`test_signal_clip `_" msgstr "" -msgid ":py:func:`complex_from_magnitude_phase `" +msgid "" +":py:func:`complex_from_magnitude_phase " +"`" msgstr "" msgid "Combine magnitude and phase signals into a complex signal" msgstr "" -msgid "`test_signal_complex_from_magnitude_phase `_" +msgid "" +"`test_signal_complex_from_magnitude_phase `_" msgstr "" -msgid ":py:func:`complex_from_real_imag `" +msgid "" +":py:func:`complex_from_real_imag " +"`" msgstr "" msgid "Combine two real signals into a complex signal using real + i * imag" msgstr "" -msgid "`test_signal_complex_from_real_imag `_" +msgid "" +"`test_signal_complex_from_real_imag `_" msgstr "" msgid ":py:func:`contrast `" @@ -306,7 +395,9 @@ msgstr "" msgid "Compute contrast" msgstr "Calculer le contraste" -msgid "`test_signal_contrast `_" +msgid "" +"`test_signal_contrast `_" msgstr "" msgid ":py:func:`convolution `" @@ -315,7 +406,9 @@ msgstr "" msgid "Compute convolution of two signals" msgstr "" -msgid "`test_signal_convolution `_" +msgid "" +"`test_signal_convolution `_" msgstr "" msgid ":py:func:`deconvolution `" @@ -324,7 +417,9 @@ msgstr "" msgid "Compute deconvolution" msgstr "Calculer la déconvolution" -msgid "`test_signal_deconvolution `_" +msgid "" +"`test_signal_deconvolution `_" msgstr "" msgid ":py:func:`derivative `" @@ -333,7 +428,9 @@ msgstr "" msgid "Compute derivative" msgstr "Calculer la dérivée" -msgid "`test_signal_derivative `_" +msgid "" +"`test_signal_derivative `_" msgstr "" msgid ":py:func:`detrending `" @@ -342,7 +439,9 @@ msgstr "" msgid "Detrend data" msgstr "" -msgid "`test_signal_detrending `_" +msgid "" +"`test_signal_detrending `_" msgstr "" msgid ":py:func:`difference `" @@ -351,7 +450,9 @@ msgstr "" msgid "Compute the element-wise difference between two signals" msgstr "" -msgid "`test_signal_difference `_" +msgid "" +"`test_signal_difference `_" msgstr "" msgid ":py:func:`difference_constant `" @@ -360,7 +461,9 @@ msgstr "" msgid "Compute the difference between a signal and a constant value" msgstr "" -msgid "`test_signal_difference_constant `_" +msgid "" +"`test_signal_difference_constant `_" msgstr "" msgid ":py:func:`division `" @@ -369,7 +472,9 @@ msgstr "" msgid "Compute the element-wise division between two signals" msgstr "" -msgid "`test_signal_division `_" +msgid "" +"`test_signal_division `_" msgstr "" msgid ":py:func:`division_constant `" @@ -378,7 +483,9 @@ msgstr "" msgid "Compute the division of a signal by a constant value" msgstr "" -msgid "`test_signal_division_constant `_" +msgid "" +"`test_signal_division_constant `_" msgstr "" msgid ":py:func:`dynamic_parameters `" @@ -387,7 +494,9 @@ msgstr "" msgid "Compute Dynamic parameters" msgstr "" -msgid "`test_dynamic_parameters `_" +msgid "" +"`test_dynamic_parameters `_" msgstr "" msgid ":py:func:`evaluate_fit `" @@ -396,7 +505,9 @@ msgstr "" msgid "Evaluate fit function from src1 on the x-axis of src2" msgstr "" -msgid "`test_signal_evaluate_fit `_" +msgid "" +"`test_signal_evaluate_fit `_" msgstr "" msgid ":py:func:`exp `" @@ -405,7 +516,9 @@ msgstr "" msgid "Compute exponential with :py:data:`numpy.exp`" msgstr "" -msgid "`test_signal_exp `_" +msgid "" +"`test_signal_exp `_" msgstr "" msgid ":py:func:`exponential_fit `" @@ -414,16 +527,22 @@ msgstr "" msgid "Compute exponential fit" msgstr "Calculer l'ajustement exponentiel" -msgid "`test_signal_exponential_fit `_" +msgid "" +"`test_signal_exponential_fit `_" msgstr "" -msgid ":py:func:`extract_pulse_features `" +msgid "" +":py:func:`extract_pulse_features " +"`" msgstr "" msgid "Extract pulse features" msgstr "" -msgid "`test_signal_extract_pulse_features `_" +msgid "" +"`test_signal_extract_pulse_features `_" msgstr "" msgid ":py:func:`extract_roi `" @@ -432,7 +551,9 @@ msgstr "" msgid "Extract single region of interest from data" msgstr "" -msgid "`test_signal_extract_roi `_" +msgid "" +"`test_signal_extract_roi `_" msgstr "" msgid ":py:func:`extract_rois `" @@ -441,7 +562,9 @@ msgstr "" msgid "Extract multiple regions of interest from data" msgstr "" -msgid "`test_signal_extract_rois `_" +msgid "" +"`test_signal_extract_rois `_" msgstr "" msgid ":py:func:`fft `" @@ -450,13 +573,17 @@ msgstr "" msgid "Compute FFT" msgstr "Calculer la FFT" -msgid "`test_signal_fft `_" +msgid "" +"`test_signal_fft `_" msgstr "" msgid ":py:func:`full_width_at_y `" msgstr "" -msgid "`test_signal_full_width_at_y `_" +msgid "" +"`test_signal_full_width_at_y `_" msgstr "" msgid ":py:func:`fw1e2 `" @@ -465,7 +592,9 @@ msgstr "" msgid "Compute FW at 1/e²" msgstr "" -msgid "`test_signal_fw1e2 `_" +msgid "" +"`test_signal_fw1e2 `_" msgstr "" msgid ":py:func:`fwhm `" @@ -474,7 +603,9 @@ msgstr "" msgid "Compute FWHM" msgstr "Fonctions de calcul" -msgid "`test_signal_fwhm `_" +msgid "" +"`test_signal_fwhm `_" msgstr "" msgid ":py:func:`gaussian_filter `" @@ -483,7 +614,9 @@ msgstr "" msgid "Compute gaussian filter" msgstr "" -msgid "`test_signal_gaussian_filter `_" +msgid "" +"`test_signal_gaussian_filter `_" msgstr "" msgid ":py:func:`gaussian_fit `" @@ -492,7 +625,9 @@ msgstr "" msgid "Compute Gaussian fit" msgstr "Calculer la déviation d'Allan" -msgid "`test_signal_gaussian_fit `_" +msgid "" +"`test_signal_gaussian_fit `_" msgstr "" msgid ":py:func:`hadamard_variance `" @@ -501,7 +636,9 @@ msgstr "" msgid "Compute Hadamard variance" msgstr "" -msgid "`test_signal_hadamard_variance `_" +msgid "" +"`test_signal_hadamard_variance `_" msgstr "" msgid ":py:func:`highpass `" @@ -510,7 +647,9 @@ msgstr "" msgid "Compute high-pass filter" msgstr "" -msgid "`test_signal_highpass `_" +msgid "" +"`test_signal_highpass `_" msgstr "" msgid ":py:func:`histogram `" @@ -519,7 +658,9 @@ msgstr "" msgid "Compute histogram" msgstr "Calculer l'histogramme" -msgid "`test_signal_histogram `_" +msgid "" +"`test_signal_histogram `_" msgstr "" msgid ":py:func:`ifft `" @@ -528,7 +669,9 @@ msgstr "" msgid "Compute the inverse FFT" msgstr "" -msgid "`test_signal_ifft `_" +msgid "" +"`test_signal_ifft `_" msgstr "" msgid ":py:func:`imag `" @@ -537,7 +680,9 @@ msgstr "" msgid "Compute imaginary part" msgstr "" -msgid "`test_signal_imag `_" +msgid "" +"`test_signal_imag `_" msgstr "" msgid ":py:func:`integral `" @@ -546,7 +691,9 @@ msgstr "" msgid "Compute integral" msgstr "Calculer l'intégrale" -msgid "`test_signal_integral `_" +msgid "" +"`test_signal_integral `_" msgstr "" msgid ":py:func:`interpolate `" @@ -555,7 +702,9 @@ msgstr "" msgid "Interpolate data" msgstr "" -msgid "`test_signal_interpolate `_" +msgid "" +"`test_signal_interpolate `_" msgstr "" msgid ":py:func:`inverse `" @@ -564,7 +713,9 @@ msgstr "" msgid "Compute the element-wise inverse of a signal" msgstr "" -msgid "`test_signal_inverse `_" +msgid "" +"`test_signal_inverse `_" msgstr "" msgid ":py:func:`linear_fit `" @@ -573,7 +724,9 @@ msgstr "" msgid "Compute linear fit" msgstr "Calculer l'ajustement linéaire" -msgid "`test_signal_linear_fit `_" +msgid "" +"`test_signal_linear_fit `_" msgstr "" msgid ":py:func:`log10 `" @@ -582,7 +735,9 @@ msgstr "" msgid "Compute Log10 with :py:data:`numpy.log10`" msgstr "" -msgid "`test_signal_log10 `_" +msgid "" +"`test_signal_log10 `_" msgstr "" msgid ":py:func:`lorentzian_fit `" @@ -591,7 +746,9 @@ msgstr "" msgid "Compute Lorentzian fit" msgstr "Calculer l'ajustement lorentzien" -msgid "`test_signal_lorentzian_fit `_" +msgid "" +"`test_signal_lorentzian_fit `_" msgstr "" msgid ":py:func:`lowpass `" @@ -600,7 +757,9 @@ msgstr "" msgid "Compute low-pass filter" msgstr "" -msgid "`test_signal_lowpass `_" +msgid "" +"`test_signal_lowpass `_" msgstr "" msgid ":py:func:`magnitude_spectrum `" @@ -609,16 +768,22 @@ msgstr "" msgid "Compute magnitude spectrum" msgstr "" -msgid "`test_signal_magnitude_spectrum `_" +msgid "" +"`test_signal_magnitude_spectrum `_" msgstr "" -msgid ":py:func:`modified_allan_variance `" +msgid "" +":py:func:`modified_allan_variance " +"`" msgstr "" msgid "Compute Modified Allan variance" msgstr "" -msgid "`test_signal_modified_allan_variance `_" +msgid "" +"`test_signal_modified_allan_variance `_" msgstr "" msgid ":py:func:`moving_average `" @@ -627,7 +792,9 @@ msgstr "" msgid "Compute moving average" msgstr "" -msgid "`test_signal_moving_average `_" +msgid "" +"`test_signal_moving_average `_" msgstr "" msgid ":py:func:`moving_median `" @@ -636,7 +803,9 @@ msgstr "" msgid "Compute moving median" msgstr "" -msgid "`test_signal_moving_median `_" +msgid "" +"`test_signal_moving_median `_" msgstr "" msgid ":py:func:`normalize `" @@ -645,25 +814,35 @@ msgstr "" msgid "Normalize data" msgstr "" -msgid "`test_signal_normalize `_" +msgid "" +"`test_signal_normalize `_" msgstr "" msgid ":py:func:`offset_correction `" msgstr "" -msgid "Correct offset: subtract the mean value of the signal in the specified range" +msgid "" +"Correct offset: subtract the mean value of the signal in the specified " +"range" msgstr "" -msgid "`test_signal_offset_correction `_" +msgid "" +"`test_signal_offset_correction `_" msgstr "" -msgid ":py:func:`overlapping_allan_variance `" +msgid "" +":py:func:`overlapping_allan_variance " +"`" msgstr "" msgid "Compute Overlapping Allan variance" msgstr "" -msgid "`test_signal_overlapping_allan_variance `_" +msgid "" +"`test_signal_overlapping_allan_variance `_" msgstr "" msgid ":py:func:`peak_detection `" @@ -672,7 +851,9 @@ msgstr "" msgid "Peak detection" msgstr "" -msgid "`test_signal_peak_detection `_" +msgid "" +"`test_signal_peak_detection `_" msgstr "" msgid ":py:func:`phase `" @@ -681,7 +862,9 @@ msgstr "" msgid "Compute the phase (argument) of a complex signal" msgstr "" -msgid "`test_signal_phase `_" +msgid "" +"`test_signal_phase `_" msgstr "" msgid ":py:func:`phase_spectrum `" @@ -690,16 +873,22 @@ msgstr "" msgid "Compute phase spectrum" msgstr "" -msgid "`test_signal_phase_spectrum `_" +msgid "" +"`test_signal_phase_spectrum `_" msgstr "" -msgid ":py:func:`piecewiseexponential_fit `" +msgid "" +":py:func:`piecewiseexponential_fit " +"`" msgstr "" msgid "Compute piecewise exponential fit (raise-decay)" msgstr "Calculer l'ajustement exponentiel par morceaux (augmentation-décroissance)" -msgid "`test_signal_piecewiseexponential_fit `_" +msgid "" +"`test_signal_piecewiseexponential_fit `_" msgstr "" msgid ":py:func:`planckian_fit `" @@ -708,7 +897,9 @@ msgstr "" msgid "Compute Planckian fit" msgstr "Calculer l'ajustement de Planck" -msgid "`test_signal_planckian_fit `_" +msgid "" +"`test_signal_planckian_fit `_" msgstr "" msgid ":py:func:`polynomial_fit `" @@ -717,7 +908,9 @@ msgstr "" msgid "Compute polynomial fit" msgstr "Calculer l'ajustement polynomial" -msgid "`test_polynomial_fit `_" +msgid "" +"`test_polynomial_fit `_" msgstr "" msgid ":py:func:`power `" @@ -726,7 +919,9 @@ msgstr "" msgid "Compute power with :py:data:`numpy.power`" msgstr "" -msgid "`test_signal_power `_" +msgid "" +"`test_signal_power `_" msgstr "" msgid ":py:func:`product `" @@ -735,7 +930,9 @@ msgstr "" msgid "Compute the element-wise product of multiple signals" msgstr "" -msgid "`test_signal_product `_" +msgid "" +"`test_signal_product `_" msgstr "" msgid ":py:func:`product_constant `" @@ -744,7 +941,9 @@ msgstr "" msgid "Compute the product of a signal and a constant value" msgstr "" -msgid "`test_signal_product_constant `_" +msgid "" +"`test_signal_product_constant `_" msgstr "" msgid ":py:func:`psd `" @@ -753,7 +952,9 @@ msgstr "" msgid "Compute power spectral density" msgstr "" -msgid "`test_signal_psd `_" +msgid "" +"`test_signal_psd `_" msgstr "" msgid ":py:func:`quadratic_difference `" @@ -762,7 +963,9 @@ msgstr "" msgid "Compute the normalized difference between two signals" msgstr "" -msgid "`test_signal_quadratic_difference `_" +msgid "" +"`test_signal_quadratic_difference `_" msgstr "" msgid ":py:func:`real `" @@ -771,7 +974,9 @@ msgstr "" msgid "Compute real part" msgstr "Calculer la partie réelle" -msgid "`test_signal_real `_" +msgid "" +"`test_signal_real `_" msgstr "" msgid ":py:func:`replace_x_by_other_y `" @@ -780,7 +985,9 @@ msgstr "" msgid "Create a new signal using Y from src1 and Y from src2 as X coordinates" msgstr "" -msgid "`test_replace_x_by_other_y `_" +msgid "" +"`test_replace_x_by_other_y `_" msgstr "" msgid ":py:func:`resampling `" @@ -789,7 +996,9 @@ msgstr "" msgid "Resample data" msgstr "" -msgid "`test_signal_resampling `_" +msgid "" +"`test_signal_resampling `_" msgstr "" msgid ":py:func:`reverse_x `" @@ -798,7 +1007,9 @@ msgstr "" msgid "Reverse x-axis" msgstr "" -msgid "`test_signal_reverse_x `_" +msgid "" +"`test_signal_reverse_x `_" msgstr "" msgid ":py:func:`sampling_rate_period `" @@ -807,7 +1018,9 @@ msgstr "" msgid "Compute sampling rate and period" msgstr "" -msgid "`test_signal_sampling_rate_period `_" +msgid "" +"`test_signal_sampling_rate_period `_" msgstr "" msgid ":py:func:`sigmoid_fit `" @@ -816,7 +1029,9 @@ msgstr "" msgid "Compute sigmoid fit" msgstr "Calculer l'ajustement sigmoïde" -msgid "`test_signal_sigmoid_fit `_" +msgid "" +"`test_signal_sigmoid_fit `_" msgstr "" msgid ":py:func:`signals_to_image `" @@ -825,7 +1040,9 @@ msgstr "" msgid "Combine multiple signals into an image" msgstr "" -msgid "`test_signal_signals_to_image `_" +msgid "" +"`test_signal_signals_to_image `_" msgstr "" msgid ":py:func:`sinusoidal_fit `" @@ -834,7 +1051,9 @@ msgstr "" msgid "Compute sinusoidal fit" msgstr "Calculer l'ajustement sinusoïdal" -msgid "`test_sinusoidal_fit `_" +msgid "" +"`test_sinusoidal_fit `_" msgstr "" msgid ":py:func:`sqrt `" @@ -843,7 +1062,9 @@ msgstr "" msgid "Compute square root with :py:data:`numpy.sqrt`" msgstr "" -msgid "`test_signal_sqrt `_" +msgid "" +"`test_signal_sqrt `_" msgstr "" msgid ":py:func:`standard_deviation `" @@ -852,7 +1073,9 @@ msgstr "" msgid "Compute the element-wise standard deviation of multiple signals" msgstr "" -msgid "`test_signal_standard_deviation `_" +msgid "" +"`test_signal_standard_deviation `_" msgstr "" msgid ":py:func:`stats `" @@ -861,7 +1084,9 @@ msgstr "" msgid "Compute statistics on a signal" msgstr "" -msgid "`test_signal_stats_unit `_" +msgid "" +"`test_signal_stats_unit `_" msgstr "" msgid ":py:func:`time_deviation `" @@ -870,7 +1095,9 @@ msgstr "" msgid "Compute Time Deviation (TDEV)" msgstr "" -msgid "`test_signal_time_deviation `_" +msgid "" +"`test_signal_time_deviation `_" msgstr "" msgid ":py:func:`to_cartesian `" @@ -879,7 +1106,9 @@ msgstr "" msgid "Convert polar coordinates to Cartesian coordinates" msgstr "" -msgid "`test_signal_to_cartesian `_" +msgid "" +"`test_signal_to_cartesian `_" msgstr "" msgid ":py:func:`to_polar `" @@ -888,7 +1117,9 @@ msgstr "" msgid "Convert Cartesian coordinates to polar coordinates" msgstr "" -msgid "`test_signal_to_polar `_" +msgid "" +"`test_signal_to_polar `_" msgstr "" msgid ":py:func:`total_variance `" @@ -897,7 +1128,9 @@ msgstr "" msgid "Compute Total variance" msgstr "" -msgid "`test_signal_total_variance `_" +msgid "" +"`test_signal_total_variance `_" msgstr "" msgid ":py:func:`transpose `" @@ -906,7 +1139,9 @@ msgstr "" msgid "Transpose signal (swap X and Y axes)" msgstr "" -msgid "`test_signal_transpose `_" +msgid "" +"`test_signal_transpose `_" msgstr "" msgid ":py:func:`twohalfgaussian_fit `" @@ -915,7 +1150,9 @@ msgstr "" msgid "Compute two-half-Gaussian fit" msgstr "" -msgid "`test_signal_twohalfgaussian_fit `_" +msgid "" +"`test_signal_twohalfgaussian_fit `_" msgstr "" msgid ":py:func:`voigt_fit `" @@ -924,7 +1161,9 @@ msgstr "" msgid "Compute Voigt fit" msgstr "Calculer l'ajustement de Voigt" -msgid "`test_signal_voigt_fit `_" +msgid "" +"`test_signal_voigt_fit `_" msgstr "" msgid ":py:func:`wiener `" @@ -933,19 +1172,25 @@ msgstr "" msgid "Compute Wiener filter" msgstr "" -msgid "`test_signal_wiener `_" +msgid "" +"`test_signal_wiener `_" msgstr "" msgid ":py:func:`x_at_minmax `" msgstr "" -msgid "`test_signal_x_at_minmax `_" +msgid "" +"`test_signal_x_at_minmax `_" msgstr "" msgid ":py:func:`x_at_y `" msgstr "" -msgid "`test_signal_x_at_y `_" +msgid "" +"`test_signal_x_at_y `_" msgstr "" msgid ":py:func:`xy_mode `" @@ -954,13 +1199,17 @@ msgstr "" msgid "Simulate the X-Y mode of an oscilloscope" msgstr "" -msgid "`test_signal_xy_mode `_" +msgid "" +"`test_signal_xy_mode `_" msgstr "" msgid ":py:func:`y_at_x `" msgstr "" -msgid "`test_signal_y_at_x `_" +msgid "" +"`test_signal_y_at_x `_" msgstr "" msgid ":py:func:`zero_padding `" @@ -969,14 +1218,21 @@ msgstr "" msgid "Compute zero padding" msgstr "" -msgid "`test_signal_zero_padding `_" +msgid "" +"`test_signal_zero_padding `_" msgstr "" msgid "Image Compute Functions" msgstr "Fonctions de calcul image" -msgid "The table below shows the validation status of image compute functions in DataLab. It is automatically generated from the source code." -msgstr "Le tableau ci-dessous montre l'état de validation des fonctions de calcul image dans DataLab. Il est généré automatiquement à partir du code source." +msgid "" +"The table below shows the validation status of image compute functions in" +" DataLab. It is automatically generated from the source code." +msgstr "" +"Le tableau ci-dessous montre l'état de validation des fonctions de calcul" +" image dans DataLab. Il est généré automatiquement à partir du code " +"source." msgid "Validation status of image compute functions" msgstr "Etat de validation des fonctions de calcul image" @@ -984,7 +1240,9 @@ msgstr "Etat de validation des fonctions de calcul image" msgid ":py:func:`absolute `" msgstr "" -msgid "`test_image_absolute `_" +msgid "" +"`test_image_absolute `_" msgstr "" msgid ":py:func:`add_gaussian_noise `" @@ -993,7 +1251,9 @@ msgstr "" msgid "Add Gaussian (normal) noise to the input image" msgstr "" -msgid "`test_image_add_gaussian_noise `_" +msgid "" +"`test_image_add_gaussian_noise `_" msgstr "" msgid ":py:func:`add_poisson_noise `" @@ -1002,7 +1262,9 @@ msgstr "" msgid "Add Poisson noise to the input image" msgstr "" -msgid "`test_image_add_poisson_noise `_" +msgid "" +"`test_image_add_poisson_noise `_" msgstr "" msgid ":py:func:`add_uniform_noise `" @@ -1011,7 +1273,9 @@ msgstr "" msgid "Add uniform noise to the input image" msgstr "" -msgid "`test_image_add_uniform_noise `_" +msgid "" +"`test_image_add_uniform_noise `_" msgstr "" msgid ":py:func:`addition `" @@ -1020,7 +1284,9 @@ msgstr "" msgid "Add images in the list and return the result image object" msgstr "" -msgid "`test_image_addition `_" +msgid "" +"`test_image_addition `_" msgstr "" msgid ":py:func:`addition_constant `" @@ -1029,7 +1295,9 @@ msgstr "" msgid "Add **dst** and a constant value and return the new result image object" msgstr "" -msgid "`test_image_addition_constant `_" +msgid "" +"`test_image_addition_constant `_" msgstr "" msgid ":py:func:`adjust_gamma `" @@ -1038,7 +1306,9 @@ msgstr "" msgid "Gamma correction" msgstr "" -msgid "`test_adjust_gamma `_" +msgid "" +"`test_adjust_gamma `_" msgstr "" msgid ":py:func:`adjust_log `" @@ -1047,7 +1317,9 @@ msgstr "" msgid "Compute log correction" msgstr "Calculer l'ajustement logarithmique" -msgid "`test_adjust_log `_" +msgid "" +"`test_adjust_log `_" msgstr "" msgid ":py:func:`adjust_sigmoid `" @@ -1056,7 +1328,9 @@ msgstr "" msgid "Compute sigmoid correction" msgstr "Calculer l'ajustement sigmoïde" -msgid "`test_adjust_sigmoid `_" +msgid "" +"`test_adjust_sigmoid `_" msgstr "" msgid ":py:func:`arithmetic `" @@ -1065,7 +1339,9 @@ msgstr "" msgid "Compute arithmetic operation on two images" msgstr "" -msgid "`test_image_arithmetic `_" +msgid "" +"`test_image_arithmetic `_" msgstr "" msgid ":py:func:`astype `" @@ -1074,16 +1350,22 @@ msgstr "" msgid "Convert image data type" msgstr "" -msgid "`test_image_astype `_" +msgid "" +"`test_image_astype `_" msgstr "" msgid ":py:func:`average `" msgstr "" -msgid "Compute the average of images in the list and return the result image object" +msgid "" +"Compute the average of images in the list and return the result image " +"object" msgstr "" -msgid "`test_image_average `_" +msgid "" +"`test_image_average `_" msgstr "" msgid ":py:func:`average_profile `" @@ -1092,7 +1374,9 @@ msgstr "" msgid "Compute horizontal or vertical average profile" msgstr "" -msgid "`test_average_profile `_" +msgid "" +"`test_average_profile `_" msgstr "" msgid ":py:func:`binning `" @@ -1101,7 +1385,9 @@ msgstr "" msgid "Binning: image pixel binning (or aggregation)" msgstr "" -msgid "`test_binning `_" +msgid "" +"`test_binning `_" msgstr "" msgid ":py:func:`black_tophat `" @@ -1110,7 +1396,9 @@ msgstr "" msgid "Compute Black Top-Hat" msgstr "" -msgid "`test_black_tophat `_" +msgid "" +"`test_black_tophat `_" msgstr "" msgid ":py:func:`blob_dog `" @@ -1119,7 +1407,9 @@ msgstr "" msgid "Compute blobs using Difference of Gaussian method" msgstr "" -msgid "`test_image_blob_dog `_" +msgid "" +"`test_image_blob_dog `_" msgstr "" msgid ":py:func:`blob_doh `" @@ -1128,7 +1418,9 @@ msgstr "" msgid "Compute blobs using Determinant of Hessian method" msgstr "" -msgid "`test_image_blob_doh `_" +msgid "" +"`test_image_blob_doh `_" msgstr "" msgid ":py:func:`blob_log `" @@ -1137,7 +1429,9 @@ msgstr "" msgid "Compute blobs using Laplacian of Gaussian method" msgstr "" -msgid "`test_image_blob_log `_" +msgid "" +"`test_image_blob_log `_" msgstr "" msgid ":py:func:`blob_opencv `" @@ -1146,7 +1440,9 @@ msgstr "" msgid "Compute blobs using OpenCV" msgstr "" -msgid "`test_image_blob_opencv `_" +msgid "" +"`test_image_blob_opencv `_" msgstr "" msgid ":py:func:`butterworth `" @@ -1155,7 +1451,9 @@ msgstr "" msgid "Compute Butterworth filter" msgstr "" -msgid "`test_butterworth `_" +msgid "" +"`test_butterworth `_" msgstr "" msgid ":py:func:`calibration `" @@ -1164,7 +1462,9 @@ msgstr "" msgid "Compute polynomial calibration" msgstr "Calculer l'étalonnage polynomial" -msgid "`test_image_calibration `_" +msgid "" +"`test_image_calibration `_" msgstr "" msgid ":py:func:`canny `" @@ -1173,7 +1473,9 @@ msgstr "" msgid "Compute Canny filter" msgstr "" -msgid "`test_canny `_" +msgid "" +"`test_canny `_" msgstr "" msgid ":py:func:`centroid `" @@ -1182,7 +1484,9 @@ msgstr "" msgid "Compute centroid" msgstr "" -msgid "`test_image_centroid `_" +msgid "" +"`test_image_centroid `_" msgstr "" msgid ":py:func:`clip `" @@ -1191,7 +1495,9 @@ msgstr "" msgid "Apply clipping" msgstr "" -msgid "`test_image_clip `_" +msgid "" +"`test_image_clip `_" msgstr "" msgid ":py:func:`closing `" @@ -1200,25 +1506,35 @@ msgstr "" msgid "Compute morphological closing" msgstr "" -msgid "`test_closing `_" +msgid "" +"`test_closing `_" msgstr "" -msgid ":py:func:`complex_from_magnitude_phase `" +msgid "" +":py:func:`complex_from_magnitude_phase " +"`" msgstr "" msgid "Combine magnitude and phase images into a complex image" msgstr "" -msgid "`test_image_complex_from_magnitude_phase `_" +msgid "" +"`test_image_complex_from_magnitude_phase `_" msgstr "" -msgid ":py:func:`complex_from_real_imag `" +msgid "" +":py:func:`complex_from_real_imag " +"`" msgstr "" msgid "Combine two real images into a complex image using real + i * imag" msgstr "" -msgid "`test_image_complex_from_real_imag `_" +msgid "" +"`test_image_complex_from_real_imag `_" msgstr "" msgid ":py:func:`contour_shape `" @@ -1228,7 +1544,9 @@ msgstr "" msgid "Compute contour shape" msgstr "Calculer le contraste" -msgid "`test_contour_shape `_" +msgid "" +"`test_contour_shape `_" msgstr "" msgid ":py:func:`convolution `" @@ -1237,7 +1555,9 @@ msgstr "" msgid "Convolve an image with a kernel" msgstr "" -msgid "`test_image_convolution `_" +msgid "" +"`test_image_convolution `_" msgstr "" msgid ":py:func:`deconvolution `" @@ -1246,7 +1566,9 @@ msgstr "" msgid "Deconvolve a kernel from an image using Fast Fourier Transform (FFT)" msgstr "" -msgid "`test_image_deconvolution `_" +msgid "" +"`test_image_deconvolution `_" msgstr "" msgid ":py:func:`denoise_bilateral `" @@ -1255,7 +1577,9 @@ msgstr "" msgid "Compute bilateral filter denoising" msgstr "" -msgid "`test_denoise_bilateral `_" +msgid "" +"`test_denoise_bilateral `_" msgstr "" msgid ":py:func:`denoise_tophat `" @@ -1264,7 +1588,9 @@ msgstr "" msgid "Denoise using White Top-Hat" msgstr "" -msgid "`test_denoise_tophat `_" +msgid "" +"`test_denoise_tophat `_" msgstr "" msgid ":py:func:`denoise_tv `" @@ -1273,7 +1599,9 @@ msgstr "" msgid "Compute Total Variation denoising" msgstr "" -msgid "`test_denoise_tv `_" +msgid "" +"`test_denoise_tv `_" msgstr "" msgid ":py:func:`denoise_wavelet `" @@ -1282,7 +1610,9 @@ msgstr "" msgid "Compute Wavelet denoising" msgstr "" -msgid "`test_denoise_wavelet `_" +msgid "" +"`test_denoise_wavelet `_" msgstr "" msgid ":py:func:`difference `" @@ -1291,16 +1621,22 @@ msgstr "" msgid "Compute difference between two images" msgstr "" -msgid "`test_image_difference `_" +msgid "" +"`test_image_difference `_" msgstr "" msgid ":py:func:`difference_constant `" msgstr "" -msgid "Subtract a constant value from an image and return the new result image object" +msgid "" +"Subtract a constant value from an image and return the new result image " +"object" msgstr "" -msgid "`test_image_difference_constant `_" +msgid "" +"`test_image_difference_constant `_" msgstr "" msgid ":py:func:`dilation `" @@ -1309,7 +1645,9 @@ msgstr "" msgid "Compute Dilation" msgstr "Calculer la dilatation" -msgid "`test_dilation `_" +msgid "" +"`test_dilation `_" msgstr "" msgid ":py:func:`division `" @@ -1318,7 +1656,9 @@ msgstr "" msgid "Compute division between two images" msgstr "" -msgid "`test_image_division `_" +msgid "" +"`test_image_division `_" msgstr "" msgid ":py:func:`division_constant `" @@ -1327,7 +1667,9 @@ msgstr "" msgid "Divide an image by a constant value and return the new result image object" msgstr "" -msgid "`test_image_division_constant `_" +msgid "" +"`test_image_division_constant `_" msgstr "" msgid ":py:func:`enclosing_circle `" @@ -1336,7 +1678,9 @@ msgstr "" msgid "Compute minimum enclosing circle" msgstr "" -msgid "`test_image_enclosing_circle `_" +msgid "" +"`test_image_enclosing_circle `_" msgstr "" msgid ":py:func:`equalize_adapthist `" @@ -1345,7 +1689,9 @@ msgstr "" msgid "Adaptive histogram equalization" msgstr "" -msgid "`test_equalize_adapthist `_" +msgid "" +"`test_equalize_adapthist `_" msgstr "" msgid ":py:func:`equalize_hist `" @@ -1354,7 +1700,9 @@ msgstr "" msgid "Histogram equalization" msgstr "" -msgid "`test_equalize_hist `_" +msgid "" +"`test_equalize_hist `_" msgstr "" msgid ":py:func:`erase `" @@ -1363,7 +1711,9 @@ msgstr "" msgid "Erase an area of the image using the mean value of the image" msgstr "" -msgid "`test_erase `_" +msgid "" +"`test_erase `_" msgstr "" msgid ":py:func:`erosion `" @@ -1372,13 +1722,17 @@ msgstr "" msgid "Compute Erosion" msgstr "Calculer l'érosion" -msgid "`test_erosion `_" +msgid "" +"`test_erosion `_" msgstr "" msgid ":py:func:`exp `" msgstr "" -msgid "`test_image_exp `_" +msgid "" +"`test_image_exp `_" msgstr "" msgid ":py:func:`extract_roi `" @@ -1387,13 +1741,17 @@ msgstr "" msgid "Extract single ROI" msgstr "" -msgid "`test_image_extract_roi `_" +msgid "" +"`test_image_extract_roi `_" msgstr "" msgid ":py:func:`extract_rois `" msgstr "" -msgid "`test_image_extract_rois `_" +msgid "" +"`test_image_extract_rois `_" msgstr "" msgid ":py:func:`farid `" @@ -1402,7 +1760,9 @@ msgstr "" msgid "Compute Farid filter" msgstr "" -msgid "`test_farid `_" +msgid "" +"`test_farid `_" msgstr "" msgid ":py:func:`farid_h `" @@ -1411,7 +1771,9 @@ msgstr "" msgid "Compute horizontal Farid filter" msgstr "" -msgid "`test_farid_h `_" +msgid "" +"`test_farid_h `_" msgstr "" msgid ":py:func:`farid_v `" @@ -1420,13 +1782,17 @@ msgstr "" msgid "Compute vertical Farid filter" msgstr "" -msgid "`test_farid_v `_" +msgid "" +"`test_farid_v `_" msgstr "" msgid ":py:func:`fft `" msgstr "" -msgid "`test_image_fft `_" +msgid "" +"`test_image_fft `_" msgstr "" msgid ":py:func:`flatfield `" @@ -1435,7 +1801,9 @@ msgstr "" msgid "Compute flat field correction" msgstr "Calculer la correction de champ plat" -msgid "`test_flatfield `_" +msgid "" +"`test_flatfield `_" msgstr "" msgid ":py:func:`fliph `" @@ -1444,7 +1812,9 @@ msgstr "" msgid "Flip data horizontally" msgstr "" -msgid "`test_image_fliph `_" +msgid "" +"`test_image_fliph `_" msgstr "" msgid ":py:func:`flipv `" @@ -1453,13 +1823,17 @@ msgstr "" msgid "Flip data vertically" msgstr "" -msgid "`test_image_flipv `_" +msgid "" +"`test_image_flipv `_" msgstr "" msgid ":py:func:`gaussian_filter `" msgstr "" -msgid "`test_image_gaussian_filter `_" +msgid "" +"`test_image_gaussian_filter `_" msgstr "" msgid ":py:func:`gaussian_freq_filter `" @@ -1468,7 +1842,9 @@ msgstr "" msgid "Apply a Gaussian filter in the frequency domain" msgstr "" -msgid "`test_gaussian_freq_filter `_" +msgid "" +"`test_gaussian_freq_filter `_" msgstr "" msgid ":py:func:`histogram `" @@ -1477,16 +1853,22 @@ msgstr "" msgid "Compute histogram of the image data," msgstr "" -msgid "`test_image_histogram `_" +msgid "" +"`test_image_histogram `_" msgstr "" msgid ":py:func:`horizontal_projection `" msgstr "" -msgid "Compute the sum of pixel intensities along each col. (projection on the x-axis)" +msgid "" +"Compute the sum of pixel intensities along each col. (projection on the " +"x-axis)" msgstr "" -msgid "`test_image_horizontal_projection `_" +msgid "" +"`test_image_horizontal_projection `_" msgstr "" msgid ":py:func:`hough_circle_peaks `" @@ -1495,7 +1877,9 @@ msgstr "" msgid "Compute Hough circles" msgstr "" -msgid "`test_image_hough_circle_peaks `_" +msgid "" +"`test_image_hough_circle_peaks `_" msgstr "" msgid ":py:func:`ifft `" @@ -1504,13 +1888,17 @@ msgstr "" msgid "Compute inverse FFT" msgstr "" -msgid "`test_image_ifft `_" +msgid "" +"`test_image_ifft `_" msgstr "" msgid ":py:func:`imag `" msgstr "" -msgid "`test_image_imag `_" +msgid "" +"`test_image_imag `_" msgstr "" msgid ":py:func:`inverse `" @@ -1519,7 +1907,9 @@ msgstr "" msgid "Compute the inverse of an image and return the new result image object" msgstr "" -msgid "`test_image_inverse `_" +msgid "" +"`test_image_inverse `_" msgstr "" msgid ":py:func:`laplace `" @@ -1528,7 +1918,9 @@ msgstr "" msgid "Compute Laplace filter" msgstr "" -msgid "`test_laplace `_" +msgid "" +"`test_laplace `_" msgstr "" msgid ":py:func:`line_profile `" @@ -1537,7 +1929,9 @@ msgstr "" msgid "Compute horizontal or vertical profile" msgstr "" -msgid "`test_line_profile `_" +msgid "" +"`test_line_profile `_" msgstr "" msgid ":py:func:`log10 `" @@ -1546,7 +1940,9 @@ msgstr "" msgid "Compute log10 with :py:data:`numpy.log10`" msgstr "" -msgid "`test_image_log10 `_" +msgid "" +"`test_image_log10 `_" msgstr "" msgid ":py:func:`log10_z_plus_n `" @@ -1555,31 +1951,41 @@ msgstr "" msgid "Compute log10(z+n) with :py:data:`numpy.log10`" msgstr "" -msgid "`test_image_log10_z_plus_n `_" +msgid "" +"`test_image_log10_z_plus_n `_" msgstr "" msgid ":py:func:`magnitude_spectrum `" msgstr "" -msgid "`test_image_magnitude_spectrum `_" +msgid "" +"`test_image_magnitude_spectrum `_" msgstr "" msgid ":py:func:`moving_average `" msgstr "" -msgid "`test_image_moving_average `_" +msgid "" +"`test_image_moving_average `_" msgstr "" msgid ":py:func:`moving_median `" msgstr "" -msgid "`test_image_moving_median `_" +msgid "" +"`test_image_moving_median `_" msgstr "" msgid ":py:func:`normalize `" msgstr "" -msgid "`test_image_normalize `_" +msgid "" +"`test_image_normalize `_" msgstr "" msgid ":py:func:`offset_correction `" @@ -1588,7 +1994,9 @@ msgstr "" msgid "Apply offset correction" msgstr "" -msgid "`test_image_offset_correction `_" +msgid "" +"`test_image_offset_correction `_" msgstr "" msgid ":py:func:`opening `" @@ -1597,7 +2005,9 @@ msgstr "" msgid "Compute morphological opening" msgstr "" -msgid "`test_opening `_" +msgid "" +"`test_opening `_" msgstr "" msgid ":py:func:`peak_detection `" @@ -1606,7 +2016,9 @@ msgstr "" msgid "Compute 2D peak detection" msgstr "" -msgid "`test_image_peak_detection `_" +msgid "" +"`test_image_peak_detection `_" msgstr "" msgid ":py:func:`phase `" @@ -1615,13 +2027,17 @@ msgstr "" msgid "Compute the phase (argument) of a complex image" msgstr "" -msgid "`test_image_phase `_" +msgid "" +"`test_image_phase `_" msgstr "" msgid ":py:func:`phase_spectrum `" msgstr "" -msgid "`test_image_phase_spectrum `_" +msgid "" +"`test_image_phase_spectrum `_" msgstr "" msgid ":py:func:`prewitt `" @@ -1630,7 +2046,9 @@ msgstr "" msgid "Compute Prewitt filter" msgstr "" -msgid "`test_prewitt `_" +msgid "" +"`test_prewitt `_" msgstr "" msgid ":py:func:`prewitt_h `" @@ -1639,7 +2057,9 @@ msgstr "" msgid "Compute horizontal Prewitt filter" msgstr "" -msgid "`test_prewitt_h `_" +msgid "" +"`test_prewitt_h `_" msgstr "" msgid ":py:func:`prewitt_v `" @@ -1648,7 +2068,9 @@ msgstr "" msgid "Compute vertical Prewitt filter" msgstr "" -msgid "`test_prewitt_v `_" +msgid "" +"`test_prewitt_v `_" msgstr "" msgid ":py:func:`product `" @@ -1657,22 +2079,30 @@ msgstr "" msgid "Multiply images in the list and return the result image object" msgstr "" -msgid "`test_image_product `_" +msgid "" +"`test_image_product `_" msgstr "" msgid ":py:func:`product_constant `" msgstr "" -msgid "Multiply **dst** by a constant value and return the new result image object" +msgid "" +"Multiply **dst** by a constant value and return the new result image " +"object" msgstr "" -msgid "`test_image_product_constant `_" +msgid "" +"`test_image_product_constant `_" msgstr "" msgid ":py:func:`psd `" msgstr "" -msgid "`test_image_psd `_" +msgid "" +"`test_image_psd `_" msgstr "" msgid ":py:func:`quadratic_difference `" @@ -1681,7 +2111,9 @@ msgstr "" msgid "Compute quadratic difference between two images" msgstr "" -msgid "`test_image_quadratic_difference `_" +msgid "" +"`test_image_quadratic_difference `_" msgstr "" msgid ":py:func:`radial_profile `" @@ -1690,13 +2122,17 @@ msgstr "" msgid "Compute radial profile around the centroid" msgstr "" -msgid "`test_radial_profile `_" +msgid "" +"`test_radial_profile `_" msgstr "" msgid ":py:func:`real `" msgstr "" -msgid "`test_image_real `_" +msgid "" +"`test_image_real `_" msgstr "" msgid ":py:func:`resampling `" @@ -1705,7 +2141,9 @@ msgstr "" msgid "Resample image to new coordinate grid using interpolation" msgstr "" -msgid "`test_image_resampling `_" +msgid "" +"`test_image_resampling `_" msgstr "" msgid ":py:func:`rescale_intensity `" @@ -1714,7 +2152,9 @@ msgstr "" msgid "Rescale image intensity levels" msgstr "" -msgid "`test_rescale_intensity `_" +msgid "" +"`test_rescale_intensity `_" msgstr "" msgid ":py:func:`resize `" @@ -1723,7 +2163,9 @@ msgstr "" msgid "Zooming function" msgstr "Fonction de zoom" -msgid "`test_image_resize `_" +msgid "" +"`test_image_resize `_" msgstr "" msgid ":py:func:`roberts `" @@ -1732,7 +2174,9 @@ msgstr "" msgid "Compute Roberts filter" msgstr "" -msgid "`test_roberts `_" +msgid "" +"`test_roberts `_" msgstr "" msgid ":py:func:`rotate `" @@ -1741,7 +2185,9 @@ msgstr "" msgid "Rotate data" msgstr "" -msgid "`test_image_rotate `_" +msgid "" +"`test_image_rotate `_" msgstr "" msgid ":py:func:`rotate270 `" @@ -1750,7 +2196,9 @@ msgstr "" msgid "Rotate data 270°" msgstr "" -msgid "`test_image_rotate270 `_" +msgid "" +"`test_image_rotate270 `_" msgstr "" msgid ":py:func:`rotate90 `" @@ -1759,7 +2207,9 @@ msgstr "" msgid "Rotate data 90°" msgstr "" -msgid "`test_image_rotate90 `_" +msgid "" +"`test_image_rotate90 `_" msgstr "" msgid ":py:func:`scharr `" @@ -1768,7 +2218,9 @@ msgstr "" msgid "Compute Scharr filter" msgstr "" -msgid "`test_scharr `_" +msgid "" +"`test_scharr `_" msgstr "" msgid ":py:func:`scharr_h `" @@ -1777,7 +2229,9 @@ msgstr "" msgid "Compute horizontal Scharr filter" msgstr "" -msgid "`test_scharr_h `_" +msgid "" +"`test_scharr_h `_" msgstr "" msgid ":py:func:`scharr_v `" @@ -1786,7 +2240,9 @@ msgstr "" msgid "Compute vertical Scharr filter" msgstr "" -msgid "`test_scharr_v `_" +msgid "" +"`test_scharr_v `_" msgstr "" msgid ":py:func:`segment_profile `" @@ -1795,7 +2251,9 @@ msgstr "" msgid "Compute segment profile" msgstr "" -msgid "`test_segment_profile `_" +msgid "" +"`test_segment_profile `_" msgstr "" msgid ":py:func:`set_uniform_coords `" @@ -1804,7 +2262,9 @@ msgstr "" msgid "Convert image to uniform coordinate system" msgstr "" -msgid "`test_set_uniform_coords `_" +msgid "" +"`test_set_uniform_coords `_" msgstr "" msgid ":py:func:`sobel `" @@ -1813,7 +2273,9 @@ msgstr "" msgid "Compute Sobel filter" msgstr "" -msgid "`test_sobel `_" +msgid "" +"`test_sobel `_" msgstr "" msgid ":py:func:`sobel_h `" @@ -1822,7 +2284,9 @@ msgstr "" msgid "Compute horizontal Sobel filter" msgstr "" -msgid "`test_sobel_h `_" +msgid "" +"`test_sobel_h `_" msgstr "" msgid ":py:func:`sobel_v `" @@ -1831,7 +2295,9 @@ msgstr "" msgid "Compute vertical Sobel filter" msgstr "" -msgid "`test_sobel_v `_" +msgid "" +"`test_sobel_v `_" msgstr "" msgid ":py:func:`standard_deviation `" @@ -1840,7 +2306,9 @@ msgstr "" msgid "Compute the element-wise standard deviation of multiple images" msgstr "" -msgid "`test_image_standard_deviation `_" +msgid "" +"`test_image_standard_deviation `_" msgstr "" msgid ":py:func:`stats `" @@ -1849,7 +2317,9 @@ msgstr "" msgid "Compute statistics on an image" msgstr "" -msgid "`test_image_stats_unit `_" +msgid "" +"`test_image_stats_unit `_" msgstr "" msgid ":py:func:`threshold `" @@ -1858,7 +2328,9 @@ msgstr "" msgid "Compute the threshold, using one of the available algorithms" msgstr "" -msgid "`test_threshold `_" +msgid "" +"`test_threshold `_" msgstr "" msgid ":py:func:`threshold_isodata `" @@ -1867,7 +2339,9 @@ msgstr "" msgid "Compute the threshold using the Isodata algorithm with default parameters" msgstr "" -msgid "`test_threshold_isodata `_" +msgid "" +"`test_threshold_isodata `_" msgstr "" msgid ":py:func:`threshold_li `" @@ -1876,7 +2350,9 @@ msgstr "" msgid "Compute the threshold using the Li algorithm with default parameters" msgstr "" -msgid "`test_threshold_li `_" +msgid "" +"`test_threshold_li `_" msgstr "" msgid ":py:func:`threshold_mean `" @@ -1885,7 +2361,9 @@ msgstr "" msgid "Compute the threshold using the Mean algorithm" msgstr "" -msgid "`test_threshold_mean `_" +msgid "" +"`test_threshold_mean `_" msgstr "" msgid ":py:func:`threshold_minimum `" @@ -1894,7 +2372,9 @@ msgstr "" msgid "Compute the threshold using the Minimum algorithm with default parameters" msgstr "" -msgid "`test_threshold_minimum `_" +msgid "" +"`test_threshold_minimum `_" msgstr "" msgid ":py:func:`threshold_otsu `" @@ -1903,7 +2383,9 @@ msgstr "" msgid "Compute the threshold using the Otsu algorithm with default parameters" msgstr "" -msgid "`test_threshold_otsu `_" +msgid "" +"`test_threshold_otsu `_" msgstr "" msgid ":py:func:`threshold_triangle `" @@ -1912,7 +2394,9 @@ msgstr "" msgid "Compute the threshold using the Triangle algorithm with default parameters" msgstr "" -msgid "`test_threshold_triangle `_" +msgid "" +"`test_threshold_triangle `_" msgstr "" msgid ":py:func:`threshold_yen `" @@ -1921,7 +2405,9 @@ msgstr "" msgid "Compute the threshold using the Yen algorithm with default parameters" msgstr "" -msgid "`test_threshold_yen `_" +msgid "" +"`test_threshold_yen `_" msgstr "" msgid ":py:func:`translate `" @@ -1930,7 +2416,9 @@ msgstr "" msgid "Translate data" msgstr "" -msgid "`test_image_translate `_" +msgid "" +"`test_image_translate `_" msgstr "" msgid ":py:func:`transpose `" @@ -1939,16 +2427,22 @@ msgstr "" msgid "Transpose image" msgstr "" -msgid "`test_image_transpose `_" +msgid "" +"`test_image_transpose `_" msgstr "" msgid ":py:func:`vertical_projection `" msgstr "" -msgid "Compute the sum of pixel intensities along each row (projection on the y-axis)" +msgid "" +"Compute the sum of pixel intensities along each row (projection on the " +"y-axis)" msgstr "" -msgid "`test_image_vertical_projection `_" +msgid "" +"`test_image_vertical_projection `_" msgstr "" msgid ":py:func:`white_tophat `" @@ -1957,13 +2451,17 @@ msgstr "" msgid "Compute White Top-Hat" msgstr "" -msgid "`test_white_tophat `_" +msgid "" +"`test_white_tophat `_" msgstr "" msgid ":py:func:`wiener `" msgstr "" -msgid "`test_image_wiener `_" +msgid "" +"`test_image_wiener `_" msgstr "" msgid ":py:func:`zero_padding `" @@ -1972,6 +2470,1073 @@ msgstr "" msgid "Zero-padding: add zeros to image borders" msgstr "" -msgid "`test_image_zero_padding `_" -msgstr "" - +msgid "" +"`test_image_zero_padding `_" +msgstr "" + +#~ msgid "" +#~ "`test_signal_absolute `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_add_gaussian_noise `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_add_poisson_noise `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_add_uniform_noise `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_addition `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_addition_constant `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_allan_deviation `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_allan_variance `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_apply_window `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_arithmetic `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_astype `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_average `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_bandpass `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_bandstop `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_bandwidth_3db `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_calibration `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_cdf_fit `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_clip `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_complex_from_magnitude_phase `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_complex_from_real_imag `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_contrast `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_convolution `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_deconvolution `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_derivative `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_detrending `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_difference `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_difference_constant `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_division `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_division_constant `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_dynamic_parameters `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_evaluate_fit `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_exp `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_exponential_fit `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_extract_pulse_features `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_extract_roi `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_extract_rois `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_fft `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_full_width_at_y `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_fw1e2 `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_fwhm `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_gaussian_filter `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_gaussian_fit `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_hadamard_variance `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_highpass `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_histogram `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_ifft `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_imag `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_integral `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_interpolate `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_inverse `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_linear_fit `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_log10 `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_lorentzian_fit `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_lowpass `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_magnitude_spectrum `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_modified_allan_variance `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_moving_average `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_moving_median `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_normalize `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_offset_correction `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_overlapping_allan_variance `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_peak_detection `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_phase `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_phase_spectrum `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_piecewiseexponential_fit `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_planckian_fit `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_polynomial_fit `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_power `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_product `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_product_constant `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_psd `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_quadratic_difference `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_real `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_replace_x_by_other_y `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_resampling `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_reverse_x `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_sampling_rate_period `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_sigmoid_fit `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_signals_to_image `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_sinusoidal_fit `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_sqrt `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_standard_deviation `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_stats_unit `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_time_deviation `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_to_cartesian `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_to_polar `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_total_variance `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_transpose `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_twohalfgaussian_fit `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_voigt_fit `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_wiener `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_x_at_minmax `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_x_at_y `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_xy_mode `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_y_at_x `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_signal_zero_padding `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_absolute `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_add_gaussian_noise `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_add_poisson_noise `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_add_uniform_noise `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_addition `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_addition_constant `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_adjust_gamma `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_adjust_log `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_adjust_sigmoid `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_arithmetic `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_astype `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_average `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_average_profile `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_binning `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_black_tophat `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_blob_dog `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_blob_doh `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_blob_log `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_blob_opencv `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_butterworth `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_calibration `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_canny `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_centroid `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_clip `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_closing `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_complex_from_magnitude_phase `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_complex_from_real_imag `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_contour_shape `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_convolution `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_deconvolution `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_denoise_bilateral `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_denoise_tophat `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_denoise_tv `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_denoise_wavelet `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_difference `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_difference_constant `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_dilation `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_division `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_division_constant `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_enclosing_circle `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_equalize_adapthist `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_equalize_hist `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_erase `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_erosion `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_exp `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_extract_roi `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_extract_rois `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_farid `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_farid_h `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_farid_v `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_fft `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_flatfield `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_fliph `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_flipv `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_gaussian_filter `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_gaussian_freq_filter `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_histogram `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_horizontal_projection `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_hough_circle_peaks `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_ifft `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_imag `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_inverse `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_laplace `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_line_profile `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_log10 `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_log10_z_plus_n `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_magnitude_spectrum `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_moving_average `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_moving_median `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_normalize `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_offset_correction `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_opening `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_peak_detection `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_phase `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_phase_spectrum `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_prewitt `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_prewitt_h `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_prewitt_v `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_product `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_product_constant `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_psd `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_quadratic_difference `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_radial_profile `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_real `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_resampling `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_rescale_intensity `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_resize `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_roberts `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_rotate `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_rotate270 `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_rotate90 `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_scharr `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_scharr_h `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_scharr_v `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_segment_profile `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_set_uniform_coords `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_sobel `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_sobel_h `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_sobel_v `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_standard_deviation `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_stats_unit `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_threshold `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_threshold_isodata `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_threshold_li `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_threshold_mean `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_threshold_minimum `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_threshold_otsu `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_threshold_triangle `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_threshold_yen `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_translate `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_transpose `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_vertical_projection `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_white_tophat `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_wiener `_" +#~ msgstr "" + +#~ msgid "" +#~ "`test_image_zero_padding `_" +#~ msgstr "" diff --git a/doc/locale/fr/LC_MESSAGES/features/validation/technical.po b/doc/locale/fr/LC_MESSAGES/features/validation/technical.po index 516321245..36d0fa8c3 100644 --- a/doc/locale/fr/LC_MESSAGES/features/validation/technical.po +++ b/doc/locale/fr/LC_MESSAGES/features/validation/technical.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/intro/ecosystem.po b/doc/locale/fr/LC_MESSAGES/intro/ecosystem.po index 79fc42401..13d8ee60a 100644 --- a/doc/locale/fr/LC_MESSAGES/intro/ecosystem.po +++ b/doc/locale/fr/LC_MESSAGES/intro/ecosystem.po @@ -4,7 +4,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/intro/installation.po b/doc/locale/fr/LC_MESSAGES/intro/installation.po index 65787966b..b628a925c 100644 --- a/doc/locale/fr/LC_MESSAGES/intro/installation.po +++ b/doc/locale/fr/LC_MESSAGES/intro/installation.po @@ -6,15 +6,24 @@ #, fuzzy msgid "" msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"POT-Creation-Date: 2026-08-05 10:58+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -msgid "How to install DataLab, the open-source data analysis and visualization platform" -msgstr "Comment installer DataLab, la plateforme d'analyse et de visualisation de données open-source" +msgid "" +"How to install DataLab, the open-source data analysis and visualization " +"platform" +msgstr "" +"Comment installer DataLab, la plateforme d'analyse et de visualisation de" +" données open-source" msgid "DataLab, installation, install, pip, wheel, source, Windows, Linux, macOS" -msgstr "DataLab, installation, installer, pip, wheel, source, Windows, Linux, macOS" +msgstr "" +"DataLab, installation, installer, pip, wheel, source, Windows, Linux, " +"macOS" msgid "Installation" msgstr "Installation" @@ -29,28 +38,58 @@ msgid "|download_link1|" msgstr "|download_link1|" msgid "**Important notice for users upgrading from DataLab v0.20 or earlier:**" -msgstr "**Note importante pour les utilisateurs effectuant une mise à niveau depuis DataLab v0.20 ou une version antérieure :**" +msgstr "" +"**Note importante pour les utilisateurs effectuant une mise à niveau " +"depuis DataLab v0.20 ou une version antérieure :**" -msgid "DataLab v1.0 introduces **breaking changes** that are **not backward compatible** with v0.20." -msgstr "DataLab v1.0 introduit des **changements majeurs** qui ne sont **pas rétrocompatibles** avec v0.20." +msgid "" +"DataLab v1.0 introduces **breaking changes** that are **not backward " +"compatible** with v0.20." +msgstr "" +"DataLab v1.0 introduit des **changements majeurs** qui ne sont **pas " +"rétrocompatibles** avec v0.20." msgid "**Plugins** developed for v0.20 **must be updated** to work with v1.0" -msgstr "**Les plugins** développés pour v0.20 **doivent être mis à jour** pour fonctionner avec v1.0." +msgstr "" +"**Les plugins** développés pour v0.20 **doivent être mis à jour** pour " +"fonctionner avec v1.0." msgid "**API changes** affect custom code integrations" -msgstr "**Les changements d'API** affectent les intégrations de code personnalisées." +msgstr "" +"**Les changements d'API** affectent les intégrations de code " +"personnalisées." -msgid "For detailed migration information, see the :ref:`migration guide `." -msgstr "Pour des informations détaillées sur la migration, voir le :ref:`guide de migration `." +msgid "" +"For detailed migration information, see the :ref:`migration guide " +"`." +msgstr "" +"Pour des informations détaillées sur la migration, voir le :ref:`guide de" +" migration `." -msgid "This section provides information on how to install DataLab on your system. Once installed, you can start DataLab by running the ``datalab`` command in a terminal, or by clicking on the DataLab shortcut in the Start menu (on Windows)." -msgstr "Cette section fournit des informations sur l'installation de DataLab sur votre système. Une fois installé, vous pouvez démarrer DataLab en exécutant la commande ``datalab`` dans un terminal, ou en cliquant sur le raccourci DataLab dans le menu Démarrer (sous Windows)." +msgid "" +"This section provides information on how to install DataLab on your " +"system. Once installed, you can start DataLab by running the ``datalab`` " +"command in a terminal, or by clicking on the DataLab shortcut in the " +"Start menu (on Windows)." +msgstr "" +"Cette section fournit des informations sur l'installation de DataLab sur " +"votre système. Une fois installé, vous pouvez démarrer DataLab en " +"exécutant la commande ``datalab`` dans un terminal, ou en cliquant sur le" +" raccourci DataLab dans le menu Démarrer (sous Windows)." -msgid "For more details on how to execute DataLab and its command-line options, see :ref:`ref-to-command-line-features`." -msgstr "Pour plus de détails sur l'exécution de DataLab et ses options en ligne de commande, voir :ref:`ref-to-command-line-features`." +msgid "" +"For more details on how to execute DataLab and its command-line options, " +"see :ref:`ref-to-command-line-features`." +msgstr "" +"Pour plus de détails sur l'exécution de DataLab et ses options en ligne " +"de commande, voir :ref:`ref-to-command-line-features`." -msgid "For installation on systems without internet access, see the :ref:`offline installation guide `." -msgstr "Pour l'installation sur des systèmes sans accès à Internet, voir le :ref:`guide d'installation hors ligne `." +msgid "" +"For installation on systems without internet access, see the " +":ref:`offline installation guide `." +msgstr "" +"Pour l'installation sur des systèmes sans accès à Internet, voir le " +":ref:`guide d'installation hors ligne `." msgid "How to install" msgstr "Modes d'installation" @@ -61,65 +100,150 @@ msgstr "DataLab est disponible sous plusieurs formes :" msgid "As a :ref:`install_conda`." msgstr "En tant que :ref:`install_conda`." -msgid ":bdg-info-line:`GNU/Linux` :bdg-info-line:`macOS` As a :ref:`install_nixos`, for users of the `NixOS `_ ecosystem." -msgstr ":bdg-info-line:`GNU/Linux` :bdg-info-line:`macOS` En tant que :ref:`install_nixos`, pour les utilisateurs de l'écosystème `NixOS `_." +msgid "" +":bdg-info-line:`GNU/Linux` :bdg-info-line:`macOS` As a " +":ref:`install_nixos`, for users of the `NixOS `_ " +"ecosystem." +msgstr "" +":bdg-info-line:`GNU/Linux` :bdg-info-line:`macOS` En tant que " +":ref:`install_nixos`, pour les utilisateurs de l'écosystème `NixOS " +"`_." msgid "As a Python package, which can be installed using the :ref:`install_pip`." msgstr "Un paquet Python qui peut être installé à l'aide du :ref:`install_pip`." -msgid ":bdg-info-line:`Windows` As a stand-alone application, which does not require any Python distribution to be installed. Just run the :ref:`install_aioinstaller` and you're good to go!" -msgstr ":bdg-info-line:`Windows` En tant qu'application autonome, qui ne nécessite pas d'installation de Python. Il suffit d'exécuter l':ref:`install_aioinstaller` et le tour est joué !" +msgid "" +":bdg-info-line:`Windows` As a stand-alone application, which does not " +"require any Python distribution to be installed. Just run the " +":ref:`install_aioinstaller` and you're good to go!" +msgstr "" +":bdg-info-line:`Windows` En tant qu'application autonome, qui ne " +"nécessite pas d'installation de Python. Il suffit d'exécuter " +"l':ref:`install_aioinstaller` et le tour est joué !" -msgid ":bdg-info-line:`Windows` Within a ready-to-use :ref:`install_winpython`, based on `WinPython `_." -msgstr ":bdg-info-line:`Windows` Dans une distribution prête à l'emploi :ref:`install_winpython`, basée sur `WinPython `_." +msgid "" +":bdg-info-line:`Windows` Within a ready-to-use :ref:`install_winpython`, " +"based on `WinPython `_." +msgstr "" +":bdg-info-line:`Windows` Dans une distribution prête à l'emploi " +":ref:`install_winpython`, basée sur `WinPython " +"`_." -msgid "As a precompiled :ref:`install_wheel`, which can be installed using ``pip``." -msgstr "En tant que :ref:`install_wheel` précompilé, qui peut être installé à l'aide de ``pip``." +msgid "" +"As a precompiled :ref:`install_wheel`, which can be installed using " +"``pip``." +msgstr "" +"En tant que :ref:`install_wheel` précompilé, qui peut être installé à " +"l'aide de ``pip``." -msgid "As a :ref:`install_source`, which can be installed using ``pip`` or manually." -msgstr "En tant que :ref:`install_source`, qui peut être installé à l'aide de ``pip`` ou manuellement." +msgid "" +"As a :ref:`install_source`, which can be installed using ``pip`` or " +"manually." +msgstr "" +"En tant que :ref:`install_source`, qui peut être installé à l'aide de " +"``pip`` ou manuellement." -msgid "Impatient to try the next version of DataLab? You can also install the latest development version of DataLab from the master branch of the Git repository. See :ref:`install_development` for more information." -msgstr "Impatient d'essayer la prochaine version de DataLab ? Vous pouvez également installer la dernière version de développement de DataLab à partir de la branche principale du dépôt Git. Voir :ref:`install_development` pour plus d'informations." +msgid "" +"Impatient to try the next version of DataLab? You can also install the " +"latest development version of DataLab from the master branch of the Git " +"repository. See :ref:`install_development` for more information." +msgstr "" +"Impatient d'essayer la prochaine version de DataLab ? Vous pouvez " +"également installer la dernière version de développement de DataLab à " +"partir de la branche principale du dépôt Git. Voir " +":ref:`install_development` pour plus d'informations." msgid "Conda package" msgstr "Paquet Conda" -msgid ":octicon:`info;1em;sd-text-info` :bdg-info-line:`GNU/Linux` :bdg-info-line:`Windows` :bdg-info-line:`macOS`" +msgid "" +":octicon:`info;1em;sd-text-info` :bdg-info-line:`GNU/Linux` :bdg-info-" +"line:`Windows` :bdg-info-line:`macOS`" msgstr "" -msgid "To install ``datalab`` package from the `conda-forge` channel (https://anaconda.org/conda-forge/datalab), run the following command:" -msgstr "Pour installer le paquet ``datalab`` depuis le canal ``conda-forge`` (https://anaconda.org/conda-forge/datalab), exécutez la commande suivante :" +msgid "" +"To install ``datalab`` package from the `conda-forge` channel " +"(https://anaconda.org/conda-forge/datalab), run the following command:" +msgstr "" +"Pour installer le paquet ``datalab`` depuis le canal ``conda-forge`` " +"(https://anaconda.org/conda-forge/datalab), exécutez la commande suivante" +" :" msgid "NixOS package" msgstr "Paquet NixOS" -msgid ":octicon:`info;1em;sd-text-info` :bdg-info-line:`GNU/Linux` :bdg-info-line:`macOS`" +msgid "" +":octicon:`info;1em;sd-text-info` :bdg-info-line:`GNU/Linux` :bdg-info-" +"line:`macOS`" msgstr "" -msgid "DataLab is packaged for `NixOS `_ and distributed through the `NGI Forge `_, the software distribution system for projects funded by the `Next Generation Internet (NGI) `_ initiative. This packaging was contributed by the `Nix@NGI team `_, an NLnet partner, as part of DataLab's funding through the NGI0 Commons Fund." -msgstr "DataLab est packagé pour `NixOS `_ et distribué via la `NGI Forge `_, le système de distribution logicielle des projets financés par l'initiative `Next Generation Internet (NGI) `_. Ce packaging a été réalisé par l'`équipe Nix@NGI `_, partenaire de NLnet, dans le cadre du financement de DataLab par le NGI0 Commons Fund." +msgid "" +"DataLab is packaged for `NixOS `_ and distributed " +"through the `NGI Forge `_, the " +"software distribution system for projects funded by the `Next Generation " +"Internet (NGI) `_ initiative. This packaging was " +"contributed by the `Nix@NGI team " +"`_, an NLnet partner, as part of " +"DataLab's funding through the NGI0 Commons Fund." +msgstr "" +"DataLab est packagé pour `NixOS `_ et distribué via " +"la `NGI Forge `_, le système de " +"distribution logicielle des projets financés par l'initiative `Next " +"Generation Internet (NGI) `_. Ce packaging a été " +"réalisé par l'`équipe Nix@NGI `_," +" partenaire de NLnet, dans le cadre du financement de DataLab par le NGI0" +" Commons Fund." -msgid "On any system with `Nix `_ installed, enter the Nix shell that provides DataLab (the ready-to-copy command is available on the `NGI Forge page `_), then start the desktop application:" -msgstr "Sur tout système où `Nix `_ est installé, entrez dans le shell Nix qui fournit DataLab (la commande prête à copier est disponible sur la `page NGI Forge `_), puis démarrez l'application de bureau :" +msgid "" +"On any system with `Nix `_ installed, enter the Nix " +"shell that provides DataLab (the ready-to-copy command is available on " +"the `NGI Forge page `_), then start " +"the desktop application:" +msgstr "" +"Sur tout système où `Nix `_ est installé, entrez dans " +"le shell Nix qui fournit DataLab (la commande prête à copier est " +"disponible sur la `page NGI Forge `_)," +" puis démarrez l'application de bureau :" msgid "Or, for an automated demonstration of some of DataLab's features:" -msgstr "Ou, pour une démonstration automatisée de certaines fonctionnalités de DataLab :" +msgstr "" +"Ou, pour une démonstration automatisée de certaines fonctionnalités de " +"DataLab :" msgid "Package manager ``pip``" msgstr "Gestionnaire de paquets ``pip``" -msgid "DataLab's package ``datalab-platform`` is available on the Python Package Index (PyPI) on the following URL: https://pypi.python.org/pypi/datalab-platform." -msgstr "Le paquet ``datalab-platform`` de DataLab est disponible sur le Python Package Index (PyPI) à l'adresse suivante : https://pypi.python.org/pypi/datalab-platform." +msgid "" +"DataLab's package ``datalab-platform`` is available on the Python Package" +" Index (PyPI) on the following URL: https://pypi.python.org/pypi/datalab-" +"platform." +msgstr "" +"Le paquet ``datalab-platform`` de DataLab est disponible sur le Python " +"Package Index (PyPI) à l'adresse suivante : https://pypi.python.org/pypi" +"/datalab-platform." -msgid "Installing DataLab from PyPI with Qt is as simple as running this command (you may need to use ``pip3`` instead of ``pip`` on some systems):" -msgstr "L'installation de DataLab depuis PyPI avec Qt est aussi simple que d'exécuter cette commande (vous devrez peut-être utiliser ``pip3`` au lieu de ``pip`` sur certains systèmes) :" +msgid "" +"Installing DataLab from PyPI with Qt is as simple as running this command" +" (you may need to use ``pip3`` instead of ``pip`` on some systems):" +msgstr "" +"L'installation de DataLab depuis PyPI avec Qt est aussi simple que " +"d'exécuter cette commande (vous devrez peut-être utiliser ``pip3`` au " +"lieu de ``pip`` sur certains systèmes) :" -msgid "Or, if you prefer, you can install DataLab without the Qt library (not recommended):" -msgstr "Ou, si vous préférez, vous pouvez installer DataLab sans la bibliothèque Qt (non recommandé) :" +msgid "" +"Or, if you prefer, you can install DataLab without the Qt library (not " +"recommended):" +msgstr "" +"Ou, si vous préférez, vous pouvez installer DataLab sans la bibliothèque " +"Qt (non recommandé) :" -msgid "If you already have a previous version of DataLab installed, you can upgrade it by running the same command with the ``--upgrade`` option:" -msgstr "Si vous avez déjà une version antérieure de DataLab installée, vous pouvez la mettre à jour en exécutant la même commande avec l'option ``--upgrade`` :" +msgid "" +"If you already have a previous version of DataLab installed, you can " +"upgrade it by running the same command with the ``--upgrade`` option:" +msgstr "" +"Si vous avez déjà une version antérieure de DataLab installée, vous " +"pouvez la mettre à jour en exécutant la même commande avec l'option " +"``--upgrade`` :" msgid "All-in-one installer" msgstr "Installeur tout-en-un" @@ -127,80 +251,199 @@ msgstr "Installeur tout-en-un" msgid ":octicon:`info;1em;sd-text-info` :bdg-info-line:`Windows`" msgstr "" -msgid "DataLab is available as a stand-alone application for Windows, which does not require any Python distribution to be installed. Just run the installer and you're good to go!" -msgstr "DataLab est disponible sous la forme d'une application autonome pour Windows qui ne nécessite pas d'installation de Python. Il suffit d'exécuter l'installeur et vous êtes prêt à partir !" +msgid "" +"DataLab is available as a stand-alone application for Windows, which does" +" not require any Python distribution to be installed. Just run the " +"installer and you're good to go!" +msgstr "" +"DataLab est disponible sous la forme d'une application autonome pour " +"Windows qui ne nécessite pas d'installation de Python. Il suffit " +"d'exécuter l'installeur et vous êtes prêt à partir !" msgid "DataLab all-in-one installer for Windows" msgstr "Installeur tout-en-un de DataLab pour Windows" -msgid "The installer package is available in the `Releases`_ section. It supports automatic uninstall and upgrade feature (no need to uninstall DataLab before runinng the installer of another version of the application)." -msgstr "Le paquet d'installation est disponible dans la section `Releases`_. Il prend en charge la désinstallation et la mise à jour automatiques (pas besoin de désinstaller DataLab avant d'exécuter l'installeur d'une autre version de l'application)." +msgid "" +"The installer package is available in the `Releases`_ section. It " +"supports automatic uninstall and upgrade feature (no need to uninstall " +"DataLab before runinng the installer of another version of the " +"application)." +msgstr "" +"Le paquet d'installation est disponible dans la section `Releases`_. Il " +"prend en charge la désinstallation et la mise à jour automatiques (pas " +"besoin de désinstaller DataLab avant d'exécuter l'installeur d'une autre " +"version de l'application)." msgid "DataLab Windows installer is available for Windows 7 SP1, 8, 10 and 11." -msgstr "L'installeur Windows de DataLab est disponible pour Windows 7 SP1, 8, 10 et 11." +msgstr "" +"L'installeur Windows de DataLab est disponible pour Windows 7 SP1, 8, 10 " +"et 11." -msgid ":octicon:`alert;1em;sd-text-warning` On Windows 7 SP1, before running DataLab (or any other Python 3 application), you must install Microsoft Update `KB2533623` (`Windows6.1-KB2533623-x64.msu`) and also may need to install `Microsoft Visual C++ 2015-2022 Redistribuable package `_." -msgstr ":octicon:`alert;1em;sd-text-warning` Sur Windows 7 SP1, avant d'exécuter DataLab (ou toute autre application Python 3), vous devez installer la mise à jour Microsoft `KB2533623` (`Windows6.1-KB2533623-x64.msu`) et vous devrez peut-être également installer le `package redistribuable Microsoft Visual C++ 2015-2022 `_." +msgid "" +":octicon:`alert;1em;sd-text-warning` On Windows 7 SP1, before running " +"DataLab (or any other Python 3 application), you must install Microsoft " +"Update `KB2533623` (`Windows6.1-KB2533623-x64.msu`) and also may need to " +"install `Microsoft Visual C++ 2015-2022 Redistribuable package " +"`_." +msgstr "" +":octicon:`alert;1em;sd-text-warning` Sur Windows 7 SP1, avant d'exécuter " +"DataLab (ou toute autre application Python 3), vous devez installer la " +"mise à jour Microsoft `KB2533623` (`Windows6.1-KB2533623-x64.msu`) et " +"vous devrez peut-être également installer le `package redistribuable " +"Microsoft Visual C++ 2015-2022 `_." msgid "Python distribution" msgstr "Distribution Python" -msgid "DataLab is also available within a ready-to-use Python distribution, based on `WinPython `_. This distribution is called `DataLab-WinPython `_ and is available in the `DataLab-WinPython Releases `_ section." -msgstr "DataLab est également disponible dans une distribution Python prête à l'emploi, basée sur `WinPython `_. Cette distribution s'appelle `DataLab-WinPython `_ et est disponible dans la section `DataLab-WinPython Releases `_." +msgid "" +"DataLab is also available within a ready-to-use Python distribution, " +"based on `WinPython `_. This distribution " +"is called `DataLab-WinPython `_ and is " +"available in the `DataLab-WinPython Releases `_ section." +msgstr "" +"DataLab est également disponible dans une distribution Python prête à " +"l'emploi, basée sur `WinPython `_. Cette " +"distribution s'appelle `DataLab-WinPython `_ et est" +" disponible dans la section `DataLab-WinPython Releases " +"`_." -msgid "DataLab-WinPython is a ready-to-use Python distribution including the DataLab platform." -msgstr "DataLab-WinPython est une distribution Python prête à l'emploi incluant la plateforme DataLab." +msgid "" +"DataLab-WinPython is a ready-to-use Python distribution including the " +"DataLab platform." +msgstr "" +"DataLab-WinPython est une distribution Python prête à l'emploi incluant " +"la plateforme DataLab." -msgid "The main difference with the all-in-one installer is that you can use the Python distribution for other purposes than running DataLab, and you may also extend it with additional packages. On the downside, it is also *much bigger* than the all-in-one installer because it includes a full Python distribution." -msgstr "La principale différence avec l'installeur tout-en-un est que vous pouvez utiliser la distribution Python à d'autres fins que l'exécution de DataLab, et vous pouvez également l'étendre avec des paquets supplémentaires. En revanche, elle est également *beaucoup plus volumineuse* que l'installeur tout-en-un car elle inclut une distribution Python complète." +msgid "" +"The main difference with the all-in-one installer is that you can use the" +" Python distribution for other purposes than running DataLab, and you may" +" also extend it with additional packages. On the downside, it is also " +"*much bigger* than the all-in-one installer because it includes a full " +"Python distribution." +msgstr "" +"La principale différence avec l'installeur tout-en-un est que vous pouvez" +" utiliser la distribution Python à d'autres fins que l'exécution de " +"DataLab, et vous pouvez également l'étendre avec des paquets " +"supplémentaires. En revanche, elle est également *beaucoup plus " +"volumineuse* que l'installeur tout-en-un car elle inclut une distribution" +" Python complète." -msgid "DataLab-WinPython includes `Spyder `_, a powerful IDE for scientific programming in Python, as well as `Jupyter Notebook `_ for interactive computing." -msgstr "DataLab-WinPython inclut `Spyder `_, un IDE puissant pour la programmation scientifique en Python, ainsi que `Jupyter Notebook `_ pour le calcul interactif." +msgid "" +"DataLab-WinPython includes `Spyder `_, a " +"powerful IDE for scientific programming in Python, as well as `Jupyter " +"Notebook `_ for interactive computing." +msgstr "" +"DataLab-WinPython inclut `Spyder `_, un IDE " +"puissant pour la programmation scientifique en Python, ainsi que `Jupyter" +" Notebook `_ pour le calcul interactif." msgid "DataLab-WinPython Control Panel" msgstr "Panneau de contrôle DataLab-WinPython" -msgid "Whereas the all-in-one installer provides a monolithic package that guarantees the compatibility of all its components because it cannot be modified by the user, the WinPython distribution is more flexible and thus can be broken by a bad manipulation of the Python distribution by the user. This should be taken into account when choosing the installation method." -msgstr "Alors que l'installeur tout-en-un fournit un paquet monolithique qui garantit la compatibilité de tous ses composants car il ne peut pas être modifié par l'utilisateur, la distribution WinPython est plus flexible et peut donc être endommagée par une mauvaise manipulation de la distribution Python par l'utilisateur. Cela doit être pris en compte lors du choix de la méthode d'installation." +msgid "" +"Whereas the all-in-one installer provides a monolithic package that " +"guarantees the compatibility of all its components because it cannot be " +"modified by the user, the WinPython distribution is more flexible and " +"thus can be broken by a bad manipulation of the Python distribution by " +"the user. This should be taken into account when choosing the " +"installation method." +msgstr "" +"Alors que l'installeur tout-en-un fournit un paquet monolithique qui " +"garantit la compatibilité de tous ses composants car il ne peut pas être " +"modifié par l'utilisateur, la distribution WinPython est plus flexible et" +" peut donc être endommagée par une mauvaise manipulation de la " +"distribution Python par l'utilisateur. Cela doit être pris en compte lors" +" du choix de la méthode d'installation." msgid "Wheel package" msgstr "Paquet Wheel" -msgid "On any operating system, using pip and the Wheel package is the easiest way to install DataLab on an existing Python distribution:" -msgstr "Sur n'importe quel système d'exploitation, l'utilisation de pip et du paquet Wheel est le moyen le plus simple d'installer DataLab sur une distribution Python existante :" +msgid "" +"On any operating system, using pip and the Wheel package is the easiest " +"way to install DataLab on an existing Python distribution:" +msgstr "" +"Sur n'importe quel système d'exploitation, l'utilisation de pip et du " +"paquet Wheel est le moyen le plus simple d'installer DataLab sur une " +"distribution Python existante :" msgid "Source package" msgstr "Paquet source" -msgid "Installing DataLab directly from the source package may be done using ``pip``:" -msgstr "L'installation de DataLab directement depuis le paquet source peut être effectuée à l'aide de ``pip`` :" +msgid "" +"Installing DataLab directly from the source package may be done using " +"``pip``:" +msgstr "" +"L'installation de DataLab directement depuis le paquet source peut être " +"effectuée à l'aide de ``pip`` :" -msgid "Or, if you prefer, you can install it manually by running the following command from the root directory of the source package:" -msgstr "Ou, si vous préférez, vous pouvez l'installer manuellement en exécutant la commande suivante depuis le répertoire racine du paquet source :" +msgid "" +"Or, if you prefer, you can install it manually by running the following " +"command from the root directory of the source package:" +msgstr "" +"Ou, si vous préférez, vous pouvez l'installer manuellement en exécutant " +"la commande suivante depuis le répertoire racine du paquet source :" -msgid "Finally, you can also build your own Wheel package and install it using ``pip``, by running the following command from the root directory of the source package (this requires the ``build`` and ``wheel`` packages to be installed):" -msgstr "Enfin, vous pouvez également créer votre propre paquet Wheel et l'installer à l'aide de ``pip``, en exécutant la commande suivante depuis le répertoire racine du paquet source (cela nécessite que les paquets ``build`` et ``wheel`` soient installés) :" +msgid "" +"Finally, you can also build your own Wheel package and install it using " +"``pip``, by running the following command from the root directory of the " +"source package (this requires the ``build`` and ``wheel`` packages to be " +"installed):" +msgstr "" +"Enfin, vous pouvez également créer votre propre paquet Wheel et " +"l'installer à l'aide de ``pip``, en exécutant la commande suivante depuis" +" le répertoire racine du paquet source (cela nécessite que les paquets " +"``build`` et ``wheel`` soient installés) :" msgid "Development version" msgstr "Version de développement" -msgid "If you want to try the latest development version of DataLab, you can install it directly from the master branch of the Git repository." -msgstr "Si vous souhaitez essayer la dernière version de développement de DataLab, vous pouvez l'installer directement depuis la branche principale du dépôt Git." +msgid "" +"If you want to try the latest development version of DataLab, you can " +"install it directly from the master branch of the Git repository." +msgstr "" +"Si vous souhaitez essayer la dernière version de développement de " +"DataLab, vous pouvez l'installer directement depuis la branche principale" +" du dépôt Git." -msgid "The first time you install DataLab from the Git repository, enter the following command:" -msgstr "La première fois que vous installez DataLab depuis le dépôt Git, entrez la commande suivante :" +msgid "" +"The first time you install DataLab from the Git repository, enter the " +"following command:" +msgstr "" +"La première fois que vous installez DataLab depuis le dépôt Git, entrez " +"la commande suivante :" -msgid "Then, if at some point you want to upgrade to the latest version of DataLab, just run the same command with options to force the reinstall of the package without handling dependencies (because it would reinstall all dependencies):" -msgstr "Ensuite, si vous souhaitez à un moment donné passer à la dernière version de DataLab, exécutez simplement la même commande avec les options pour forcer la réinstallation du paquet sans gérer les dépendances (car cela réinstallerait toutes les dépendances) :" +msgid "" +"Then, if at some point you want to upgrade to the latest version of " +"DataLab, just run the same command with options to force the reinstall of" +" the package without handling dependencies (because it would reinstall " +"all dependencies):" +msgstr "" +"Ensuite, si vous souhaitez à un moment donné passer à la dernière version" +" de DataLab, exécutez simplement la même commande avec les options pour " +"forcer la réinstallation du paquet sans gérer les dépendances (car cela " +"réinstallerait toutes les dépendances) :" -msgid "If dependencies have changed, you may need to execute the same command as above, but without the ``--no-deps`` option." -msgstr "Si les dépendances ont changé, vous devrez peut-être exécuter la même commande que ci-dessus, mais sans l'option ``--no-deps``." +msgid "" +"If dependencies have changed, you may need to execute the same command as" +" above, but without the ``--no-deps`` option." +msgstr "" +"Si les dépendances ont changé, vous devrez peut-être exécuter la même " +"commande que ci-dessus, mais sans l'option ``--no-deps``." msgid "Dependencies" msgstr "Dépendances" -msgid "The DataLab all-in-one installer already include all those required libraries as well as Python itself." -msgstr "L'installeur tout-en-un de DataLab inclut déjà toutes ces dépendances ainsi que Python lui-même." +msgid "" +"The DataLab all-in-one installer already include all those required " +"libraries as well as Python itself." +msgstr "" +"L'installeur tout-en-un de DataLab inclut déjà toutes ces dépendances " +"ainsi que Python lui-même." msgid "The `datalab-platform` package requires the following Python modules:" msgstr "Le paquet `datalab-platform` nécessite les modules Python suivants :" @@ -230,7 +473,9 @@ msgid ">= 3.14.4" msgstr "" msgid "Automatic GUI generation for easy dataset editing and display" -msgstr "Génération automatique d'interfaces graphiques pour l'édition et l'affichage de jeux de données" +msgstr "" +"Génération automatique d'interfaces graphiques pour l'édition et " +"l'affichage de jeux de données" msgid "PlotPy" msgstr "" @@ -244,10 +489,12 @@ msgstr "Outils de tracé de courbes et d'images pour les applications Python/Qt" msgid "Sigima" msgstr "" -msgid ">= 1.1.4" +msgid ">= 1.1.6" msgstr "" -msgid "Scientific computing engine for 1D signals and 2D images, part of the DataLab open-source platform." +msgid "" +"Scientific computing engine for 1D signals and 2D images, part of the " +"DataLab open-source platform." msgstr "" msgid "NumPy" @@ -319,7 +566,9 @@ msgstr "" msgid ">= 0.110.0" msgstr "" -msgid "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +msgid "" +"FastAPI framework, high performance, easy to learn, fast to code, ready " +"for production" msgstr "" msgid "uvicorn[standard]" @@ -413,12 +662,16 @@ msgid "myst_parser" msgstr "" msgid "An extended [CommonMark](https://spec.commonmark.org/) compliant parser," -msgstr "Un parseur étendu compatible avec [CommonMark](https://spec.commonmark.org/)" +msgstr "" +"Un parseur étendu compatible avec " +"[CommonMark](https://spec.commonmark.org/)" msgid "sphinx_design" msgstr "" -msgid "A sphinx extension for designing beautiful, view size responsive web components." +msgid "" +"A sphinx extension for designing beautiful, view size responsive web " +"components." msgstr "Extension sphinx pour la conception de composants web réactifs." msgid "sphinx-copybutton" @@ -459,3 +712,6 @@ msgstr "" msgid "Python 3.11 and PyQt5 are the reference for production release" msgstr "Python 3.11 et PyQt5 sont les références pour la version de production" + +#~ msgid ">= 1.1.4" +#~ msgstr "" diff --git a/doc/locale/fr/LC_MESSAGES/intro/installation_offline.po b/doc/locale/fr/LC_MESSAGES/intro/installation_offline.po index 225ca4d7e..9c327bb60 100644 --- a/doc/locale/fr/LC_MESSAGES/intro/installation_offline.po +++ b/doc/locale/fr/LC_MESSAGES/intro/installation_offline.po @@ -5,7 +5,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/intro/introduction.po b/doc/locale/fr/LC_MESSAGES/intro/introduction.po index 26a9b6124..efe3461a0 100644 --- a/doc/locale/fr/LC_MESSAGES/intro/introduction.po +++ b/doc/locale/fr/LC_MESSAGES/intro/introduction.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/intro/tutorials/blobs.po b/doc/locale/fr/LC_MESSAGES/intro/tutorials/blobs.po index 71ba5ca1f..c79ee87b5 100644 --- a/doc/locale/fr/LC_MESSAGES/intro/tutorials/blobs.po +++ b/doc/locale/fr/LC_MESSAGES/intro/tutorials/blobs.po @@ -5,7 +5,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/intro/tutorials/custom_func.po b/doc/locale/fr/LC_MESSAGES/intro/tutorials/custom_func.po index ffd303e65..daceac357 100644 --- a/doc/locale/fr/LC_MESSAGES/intro/tutorials/custom_func.po +++ b/doc/locale/fr/LC_MESSAGES/intro/tutorials/custom_func.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/intro/tutorials/fabry_perot.po b/doc/locale/fr/LC_MESSAGES/intro/tutorials/fabry_perot.po index 72afa3980..f4d2ef42d 100644 --- a/doc/locale/fr/LC_MESSAGES/intro/tutorials/fabry_perot.po +++ b/doc/locale/fr/LC_MESSAGES/intro/tutorials/fabry_perot.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/intro/tutorials/index.po b/doc/locale/fr/LC_MESSAGES/intro/tutorials/index.po index 5f10f2609..7ad10e5ef 100644 --- a/doc/locale/fr/LC_MESSAGES/intro/tutorials/index.po +++ b/doc/locale/fr/LC_MESSAGES/intro/tutorials/index.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/intro/tutorials/laser_beam.po b/doc/locale/fr/LC_MESSAGES/intro/tutorials/laser_beam.po index 1e59e49e0..b110839f2 100644 --- a/doc/locale/fr/LC_MESSAGES/intro/tutorials/laser_beam.po +++ b/doc/locale/fr/LC_MESSAGES/intro/tutorials/laser_beam.po @@ -6,23 +6,39 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" +"Project-Id-Version: PROJECT VERSION\n" +"POT-Creation-Date: 2026-08-05 14:07+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -msgid "Tutorial on how to measure the size of a laser beam using DataLab, the open-source scientific analysis and visualization platform" -msgstr "Tutoriel: mesurer la taille d'un faisceau laser avec DataLab, la plateforme d'analyse et de visualisation scientifique open-source" +msgid "" +"Tutorial on how to measure the size of a laser beam using DataLab, the " +"open-source scientific analysis and visualization platform" +msgstr "" +"Tutoriel: mesurer la taille d'un faisceau laser avec DataLab, la " +"plateforme d'analyse et de visualisation scientifique open-source" -msgid "DataLab, tutorial, laser beam, size, FWHM, Gaussian fit, radial profile, line profile, thresholding, centroid, image statistics, linear calibration, HDF5, workspace" -msgstr "DataLab, tutoriel, faisceau laser, taille, FWHM, ajustement gaussien, profil radial, profil de ligne, seuillage, centre de gravité, statistiques de l'image, étalonnage linéaire, HDF5, espace de travail" +msgid "" +"DataLab, tutorial, laser beam, size, FWHM, Gaussian fit, radial profile, " +"line profile, thresholding, centroid, image statistics, linear " +"calibration, HDF5, workspace" +msgstr "" +"DataLab, tutoriel, faisceau laser, taille, FWHM, ajustement gaussien, " +"profil radial, profil de ligne, seuillage, centre de gravité, " +"statistiques de l'image, étalonnage linéaire, HDF5, espace de travail" msgid ":octicon:`book;1em;sd-text-info` Measuring Laser Beam Size" msgstr ":octicon:`book;1em;sd-text-info` Mesurer la taille d'un faisceau laser" -msgid "This example shows how to measure the size of a laser beam along the propagation axis using DataLab:" -msgstr "Cet exemple montre comment mesurer la taille d'un faisceau laser le long de l'axe de propagation en utilisant DataLab :" +msgid "" +"This example shows how to measure the size of a laser beam along the " +"propagation axis using DataLab:" +msgstr "" +"Cet exemple montre comment mesurer la taille d'un faisceau laser le long " +"de l'axe de propagation en utilisant DataLab :" msgid "Load all the images in a folder" msgstr "Ouvrir toutes les images d'un dossier" @@ -45,26 +61,60 @@ msgstr "Essayer une autre méthode : extraire le profil d'intensité radial" msgid "Compute the FWHM of the radial intensity profile" msgstr "Calculer la FWHM du profil d'intensité radial" -msgid "Perform the same analysis on a stack of images and on the resulting profiles" -msgstr "Effectuer la même analyse sur une pile d'images et sur les profils résultants" +msgid "" +"Perform the same analysis on a stack of images and on the resulting " +"profiles" +msgstr "" +"Effectuer la même analyse sur une pile d'images et sur les profils " +"résultants" -msgid "Plot the beam size as a function of the position along the propagation axis" -msgstr "Tracer la taille du faisceau en fonction de la position le long de l'axe de propagation" +msgid "" +"Plot the beam size as a function of the position along the propagation " +"axis" +msgstr "" +"Tracer la taille du faisceau en fonction de la position le long de l'axe " +"de propagation" msgid "Load the images" msgstr "Charger les images" -msgid "The images used in this tutorial \"TEM00_z_*.jpg\" are available in the tutorial data folder of DataLab's installation directory (``/data/tutorials/``)." -msgstr "Les images utilisées dans ce tutoriel \"TEM00_z_*.jpg\" sont disponibles dans le dossier de données du tutoriel du répertoire d'installation de DataLab (``/data/tutorials/``)." +msgid "" +"The images used in this tutorial \"TEM00_z_*.jpg\" are available in the " +"tutorial data folder of DataLab's installation directory (``/data/tutorials/``)." +msgstr "" +"Les images utilisées dans ce tutoriel \"TEM00_z_*.jpg\" sont disponibles " +"dans le dossier de données du tutoriel du répertoire d'installation de " +"DataLab (``/data/tutorials/``)." -msgid "Alternatively, you can download them from :download:`here ` and extract to the folder of your choice." -msgstr "Alternativement, vous pouvez les télécharger depuis :download:`ici ` et les extraire dans le dossier de votre choix." +msgid "" +"Alternatively, you can download them from :download:`here " +"` and extract to the folder of your choice." +msgstr "" +"Alternativement, vous pouvez les télécharger depuis :download:`ici " +"` et les extraire dans le dossier de votre " +"choix." -msgid "Alternatively, they can be downloaded from the online documentation at https://datalab-platform.com." -msgstr "Alternativement, elles peuvent être téléchargées depuis la documentation en ligne à l'adresse https://datalab-platform.com." +msgid "" +"Alternatively, they can be downloaded from the online documentation at " +"https://datalab-platform.com." +msgstr "" +"Alternativement, elles peuvent être téléchargées depuis la documentation " +"en ligne à l'adresse https://datalab-platform.com." -msgid "First, we open DataLab and load the images. When working with multiple images, the most efficient approach is to use the \"Open from directory...\" option from the \"File\" menu (or click the |fileopen_dir| button in the toolbar). This feature loads all images from a selected directory at once." -msgstr "Tout d'abord, nous ouvrons DataLab et chargeons les images. Lorsque vous travaillez avec plusieurs images, l'approche la plus efficace consiste à utiliser l'option \"Ouvrir depuis un répertoire...\" du menu \"Fichier\" (ou cliquer sur le bouton |fileopen_dir| dans la barre d'outils). Cette fonction charge toutes les images d'un répertoire sélectionné en une seule fois." +msgid "" +"First, we open DataLab and load the images. When working with multiple " +"images, the most efficient approach is to use the \"Open from " +"directory...\" option from the \"File\" menu (or click the |fileopen_dir|" +" button in the toolbar). This feature loads all images from a selected " +"directory at once." +msgstr "" +"Tout d'abord, nous ouvrons DataLab et chargeons les images. Lorsque vous " +"travaillez avec plusieurs images, l'approche la plus efficace consiste à " +"utiliser l'option \"Ouvrir depuis un répertoire...\" du menu \"Fichier\" " +"(ou cliquer sur le bouton |fileopen_dir| dans la barre d'outils). Cette " +"fonction charge toutes les images d'un répertoire sélectionné en une " +"seule fois." msgid "fileopen_dir" msgstr "fileopen_dir" @@ -75,20 +125,46 @@ msgstr "Le menu \"Fichier > Ouvrir une image...\"" msgid "Select the folder containing the images and click \"Choose\"." msgstr "Sélectionnez le dossier contenant les images et cliquez sur \"Choisir\"." -msgid "The images are now loaded in the \"Image panel\". You can zoom in and out by right-clicking and dragging the mouse vertically. To pan the image, use the middle mouse button while dragging." -msgstr "Les images sont maintenant chargées dans le panneau \"Images\". Vous pouvez zoomer en cliquant avec le bouton droit et en faisant glisser la souris verticalement. Pour déplacer l'image, utilisez le bouton du milieu de la souris tout en faisant glisser." +msgid "" +"The images are now loaded in the \"Image panel\". You can zoom in and out" +" by right-clicking and dragging the mouse vertically. To pan the image, " +"use the middle mouse button while dragging." +msgstr "" +"Les images sont maintenant chargées dans le panneau \"Images\". Vous " +"pouvez zoomer en cliquant avec le bouton droit et en faisant glisser la " +"souris verticalement. Pour déplacer l'image, utilisez le bouton du milieu" +" de la souris tout en faisant glisser." -msgid "Zoom in and out with the right mouse button. Pan the image with the middle mouse button." -msgstr "Zoomer avec le bouton droit de la souris. Déplacer l'image avec le bouton du milieu de la souris." +msgid "" +"Zoom in and out with the right mouse button. Pan the image with the " +"middle mouse button." +msgstr "" +"Zoomer avec le bouton droit de la souris. Déplacer l'image avec le bouton" +" du milieu de la souris." -msgid "To view multiple images simultaneously, select the image group and choose \"View images side-by-side\" from the \"View\" menu." -msgstr "Pour voir plusieurs images simultanément, sélectionnez le groupe d'images et choisissez \"Afficher les images côte à côte\" dans le menu \"Affichage\"." +msgid "" +"To view multiple images simultaneously, select the image group and choose" +" \"View images side-by-side\" from the \"View\" menu." +msgstr "" +"Pour voir plusieurs images simultanément, sélectionnez le groupe d'images" +" et choisissez \"Afficher les images côte à côte\" dans le menu " +"\"Affichage\"." msgid "Viewing images side by side." msgstr "Affichage des images côte à côte." -msgid "The \"Processing > Geometry\" menu includes a \"Distribute on a grid\" option |distribute_on_grid|. This feature repositions the images by applying offsets to their X and Y coordinates, arranging them in a grid layout for side-by-side viewing. Note that this operation modifies the image coordinates." -msgstr "Le menu \"Traitement > Géométrie\" inclut une option \"Distribuer sur une grille\" |distribute_on_grid|. Cette fonction repositionne les images en appliquant des décalages à leurs coordonnées X et Y, les organisant dans une disposition en grille pour une visualisation côte à côte. Notez que cette opération modifie les coordonnées de l'image." +msgid "" +"The \"Processing > Geometry\" menu includes a \"Distribute on a grid\" " +"option |distribute_on_grid|. This feature repositions the images by " +"applying offsets to their X and Y coordinates, arranging them in a grid " +"layout for side-by-side viewing. Note that this operation modifies the " +"image coordinates." +msgstr "" +"Le menu \"Traitement > Géométrie\" inclut une option \"Distribuer sur une" +" grille\" |distribute_on_grid|. Cette fonction repositionne les images en" +" appliquant des décalages à leurs coordonnées X et Y, les organisant dans" +" une disposition en grille pour une visualisation côte à côte. Notez que " +"cette opération modifie les coordonnées de l'image." msgid "distribute_on_grid" msgstr "distribute_on_grid" @@ -96,92 +172,222 @@ msgstr "distribute_on_grid" msgid "Images distributed on 4 columns grid." msgstr "Images distribuées sur une grille de 4 colonnes." -msgid "To restore the original image positions, use the \"Reset image positions\" option |reset_positions| from the \"Processing > Geometry\" menu. Note that this operation sets all image origins to match the first image's origin, which means any initial differences in image origins will be lost." -msgstr "Pour restaurer les positions d'image d'origine, utilisez l'option \"Réinitialiser les positions des images\" |reset_positions| du menu \"Traitement > Géométrie\". Notez que cette opération définit toutes les origines d'image pour qu'elles correspondent à l'origine de la première image, ce qui signifie que toutes les différences initiales dans les origines d'image seront perdues." +msgid "" +"To restore the original image positions, use the \"Reset image " +"positions\" option |reset_positions| from the \"Processing > Geometry\" " +"menu. Note that this operation sets all image origins to match the first " +"image's origin, which means any initial differences in image origins will" +" be lost." +msgstr "" +"Pour restaurer les positions d'image d'origine, utilisez l'option " +"\"Réinitialiser les positions des images\" |reset_positions| du menu " +"\"Traitement > Géométrie\". Notez que cette opération définit toutes les " +"origines d'image pour qu'elles correspondent à l'origine de la première " +"image, ce qui signifie que toutes les différences initiales dans les " +"origines d'image seront perdues." msgid "reset_positions" msgstr "reset_positions" +msgid "Both the \"Distribute on a grid\" and \"Reset image positions\" options modify the images in place, without creating new images. For this reason, they are not registered in the History Panel, which only lists newly created objects." +msgstr "Les options \"Distribuer sur une grille\" et \"Réinitialiser les positions des images\" modifient les images sur place, sans créer de nouvelles images. Pour cette raison, elles ne sont pas enregistrées dans le panneau Historique, qui ne répertorie que les objets nouvellement créés." + msgid "Remove background noise" msgstr "Supprimer le bruit de fond" -msgid "When we select one of the images, we notice the presence of background noise, making it beneficial to apply a threshold to the images. Several methods are available to estimate the background noise level." -msgstr "Lorsque nous sélectionnons l'une des images, nous remarquons la présence de bruit de fond, ce qui rend utile l'application d'un seuillage aux images. Plusieurs méthodes sont disponibles pour estimer le niveau de bruit de fond." +msgid "" +"When we select one of the images, we notice the presence of background " +"noise, making it beneficial to apply a threshold to the images. Several " +"methods are available to estimate the background noise level." +msgstr "" +"Lorsque nous sélectionnons l'une des images, nous remarquons la présence " +"de bruit de fond, ce qui rend utile l'application d'un seuillage aux " +"images. Plusieurs méthodes sont disponibles pour estimer le niveau de " +"bruit de fond." -msgid "One approach utilizes the \"Cross section\" tool, which is provided by the `PlotPy ` library that DataLab uses for signal and image visualization. Select an image from the \"Image panel\", choose the corresponding image in the visualization panel, and activate the \"Cross section\" tool |cross_section| from the vertical toolbar on the left side of the visualization panel. This reveals that the background noise level is approximately 30 lsb." -msgstr "Une approche utilise l'outil \"Profil rectiligne\", qui est fourni par la bibliothèque `PlotPy ` que DataLab utilise pour la visualisation de signaux et d'images. Sélectionnez une image dans le panneau \"Images\", choisissez l'image correspondante dans le panneau de visualisation, et activez l'outil \"Profil rectiligne\" |cross_section| depuis la barre d'outils verticale sur le côté gauche du panneau de visualisation. Cela révèle que le niveau de bruit de fond est d'environ 30 lsb." +msgid "One approach utilizes the \"Cross section\" tool, which is provided by the `PlotPy `__ library that DataLab uses for signal and image visualization. Select an image from the \"Image panel\", choose the corresponding image in the visualization panel, and activate the \"Cross section\" tool |cross_section| from the vertical toolbar on the left side of the visualization panel. This reveals that the background noise level is approximately 30 lsb." +msgstr "Une approche utilise l'outil \"Profil rectiligne\", qui est fourni par la bibliothèque `PlotPy `__ que DataLab utilise pour la visualisation de signaux et d'images. Sélectionnez une image dans le panneau \"Images\", choisissez l'image correspondante dans le panneau de visualisation, et activez l'outil \"Profil rectiligne\" |cross_section| depuis la barre d'outils verticale sur le côté gauche du panneau de visualisation. Cela révèle que le niveau de bruit de fond est d'environ 30 lsb." msgid "cross_section" msgstr "cross_section" -msgid "An image from the \"Image panel\". To display the curve marker, select the profile curve and right-click to open the context menu, then choose \"Markers > Bound to active item\"." -msgstr "Une image du panneau \"Images\". Pour afficher le marqueur de courbe, sélectionnez la courbe de profil et cliquez avec le bouton droit pour ouvrir le menu contextuel, puis choisissez \"Marqueurs > Lié à l'élément actif\"." +msgid "" +"An image from the \"Image panel\". To display the curve marker, select " +"the profile curve and right-click to open the context menu, then choose " +"\"Markers > Bound to active item\"." +msgstr "" +"Une image du panneau \"Images\". Pour afficher le marqueur de courbe, " +"sélectionnez la courbe de profil et cliquez avec le bouton droit pour " +"ouvrir le menu contextuel, puis choisissez \"Marqueurs > Lié à l'élément " +"actif\"." -msgid "Another method for measuring background noise, also provided by `PlotPy `, involves using the \"Image statistics\" tool |imagestats| from the vertical toolbar on the left side of the visualization panel. This tool displays statistical information for a rectangular region that you define by dragging the mouse across the image. This analysis confirms that the background noise level is approximately 30 lsb." -msgstr "Une autre méthode pour mesurer le bruit de fond, également fournie par `PlotPy `, consiste à utiliser l'outil \"Statistiques de l'image\" |imagestats| de la barre d'outils verticale sur le côté gauche du panneau de visualisation. Cet outil affiche des informations statistiques pour une région rectangulaire que vous définissez en faisant glisser la souris sur l'image. Cette analyse confirme que le niveau de bruit de fond est d'environ 30 lsb." +msgid "Another method for measuring background noise, also provided by `PlotPy `__, involves using the \"Image statistics\" tool |imagestats| from the vertical toolbar on the left side of the visualization panel. This tool displays statistical information for a rectangular region that you define by dragging the mouse across the image. This analysis confirms that the background noise level is approximately 30 lsb." +msgstr "Une autre méthode pour mesurer le bruit de fond, également fournie par `PlotPy `__, consiste à utiliser l'outil \"Statistiques de l'image\" |imagestats| de la barre d'outils verticale sur le côté gauche du panneau de visualisation. Cet outil affiche des informations statistiques pour une région rectangulaire que vous définissez en faisant glisser la souris sur l'image. Cette analyse confirme que le niveau de bruit de fond est d'environ 30 lsb." msgid "imagestats" msgstr "imagestats" msgid "The \"Image statistics\" tool |imagestats| in the vertical toolbar." -msgstr "L'outil \"Statistiques de l'image\" |imagestats| dans la barre d'outils verticale." +msgstr "" +"L'outil \"Statistiques de l'image\" |imagestats| dans la barre d'outils " +"verticale." -msgid "Note that these tools are not persistent: the analysis results disappear when you select another image, they are intended to provide a fast insight on the image data." -msgstr "Notez que ces outils ne sont pas persistants : les résultats d'analyse disparaissent lorsque vous sélectionnez une autre image, ils sont destinés à fournir un aperçu rapide des données d'image." +msgid "" +"Note that these tools are not persistent: the analysis results disappear " +"when you select another image, they are intended to provide a fast " +"insight on the image data." +msgstr "" +"Notez que ces outils ne sont pas persistants : les résultats d'analyse " +"disparaissent lorsque vous sélectionnez une autre image, ils sont " +"destinés à fournir un aperçu rapide des données d'image." -msgid "Now we can clip the image at 35 lsb to remove the background noise using the \"Processing > Level Adjustment > Clipping...\" menu." -msgstr "Nous pouvons maintenant écrêter l'image à 35 lsb pour supprimer le bruit de fond en utilisant le menu \"Traitement > Ajustement de niveau > Écrêtage...\"." +msgid "" +"Now we can clip the image at 35 lsb to remove the background noise using " +"the \"Processing > Level Adjustment > Clipping...\" menu." +msgstr "" +"Nous pouvons maintenant écrêter l'image à 35 lsb pour supprimer le bruit " +"de fond en utilisant le menu \"Traitement > Ajustement de niveau > " +"Écrêtage...\"." -msgid "The two original and the clipped images are displayed side by side in the \"Image view\" (using the \"Distribute on a grid\" feature seen previously)." -msgstr "Les deux images d'origine et l'image écrêtée sont affichées côte à côte dans la \"Vue Image\" (en utilisant la fonction \"Distribuer sur une grille\" vue précédemment)." +msgid "" +"The two original and the clipped images are displayed side by side in the" +" \"Image view\" (using the \"Distribute on a grid\" feature seen " +"previously)." +msgstr "" +"Les deux images d'origine et l'image écrêtée sont affichées côte à côte " +"dans la \"Vue Image\" (en utilisant la fonction \"Distribuer sur une " +"grille\" vue précédemment)." msgid "Beam size measurement" msgstr "Mesure de la taille du faisceau" -msgid "We can now compute the centroid of the beam—that is, the position of its center of mass. To do this, select \"Analysis > Centroid\" from the menu." -msgstr "Nous pouvons maintenant calculer le centroïde du faisceau—c'est-à-dire la position de son centre de masse. Pour ce faire, sélectionnez \"Analyse > Centroïde\" dans le menu." +msgid "" +"We can now compute the centroid of the beam—that is, the position of its " +"center of mass. To do this, select \"Analysis > Centroid\" from the menu." +msgstr "" +"Nous pouvons maintenant calculer le centroïde du faisceau—c'est-à-dire la" +" position de son centre de masse. Pour ce faire, sélectionnez \"Analyse >" +" Centroïde\" dans le menu." msgid "The centroid position is displayed on the image." msgstr "La position du centroïde est affichée sur l'image." -msgid "Next, we can extract a line profile along the horizontal axis using \"Analysis > Intensity profiles > Line profile\". Set the row position to the previously computed centroid position (i.e., 668) using the \"Set Parameters\" button. See :ref:`tutorial_fabry_perot` for more details on intensity profile extraction." -msgstr "Ensuite, nous pouvons extraire un profil de ligne le long de l'axe horizontal en utilisant \"Opérations > Profils d'intensité > Profil rectiligne\". Définissez la position de la ligne sur la position du centroïde calculée précédemment (c'est-à-dire 668) en utilisant le bouton \"Définir les paramètres\". Voir :ref:`tutorial_fabry_perot` pour plus de détails sur l'extraction de profil d'intensité." +msgid "" +"Next, we can extract a line profile along the horizontal axis using " +"\"Analysis > Intensity profiles > Line profile\". Set the row position to" +" the previously computed centroid position (i.e., 668) using the \"Set " +"Parameters\" button. See :ref:`tutorial_fabry_perot` for more details on " +"intensity profile extraction." +msgstr "" +"Ensuite, nous pouvons extraire un profil de ligne le long de l'axe " +"horizontal en utilisant \"Opérations > Profils d'intensité > Profil " +"rectiligne\". Définissez la position de la ligne sur la position du " +"centroïde calculée précédemment (c'est-à-dire 668) en utilisant le bouton" +" \"Définir les paramètres\". Voir :ref:`tutorial_fabry_perot` pour plus " +"de détails sur l'extraction de profil d'intensité." msgid "The intensity profile will be displayed in the \"Signal panel\". We can then fit the profile to a Gaussian function using \"Processing > Fitting > Gaussian fit\". Here we have selected both signals for comparison." msgstr "Le profil d'intensité sera affiché dans le panneau \"Signaux\". Nous pouvons ensuite ajuster le profil à une fonction gaussienne en utilisant \"Traitement > Ajustement > Ajustement gaussien\". Ici, nous avons sélectionné les deux signaux pour comparaison." -msgid "The intensity profile fitted to a Gaussian function. Here both signals are selected." -msgstr "Le profil d'intensité ajusté à une fonction gaussienne. Ici, les deux signaux sont sélectionnés." +msgid "If history recording is enabled, creating a new signal starts a new history session, separate from the session containing the image operations. Since extracting the intensity profile is an operation performed on an image, the new session starts with the Gaussian fit applied to the resulting signal." +msgstr "Si l'enregistrement de l'historique est activé, la création d'un nouveau signal démarre une nouvelle session d'historique, distincte de celle contenant les opérations sur les images. Comme l'extraction du profil d'intensité est une opération effectuée sur une image, la nouvelle session commence avec l'ajustement gaussien appliqué au signal obtenu." + +msgid "The image-processing actions are recorded in the first session, while the Gaussian fit starts a separate session for the resulting signal." +msgstr "Les actions de traitement d'image sont enregistrées dans la première session, tandis que l'ajustement gaussien démarre une session distincte pour le signal obtenu." -msgid "Now let's explore another method to compute the FWHM. Returning to the intensity profile signal, we can directly compute the FWHM using \"Analysis > Full width at half maximum\". You can choose the estimation method based on your curve characteristics and optionally specify the interval to consider for this computation. Once complete, the results window will display the FWHM value, which is stored in the metadata and shown on the curve." -msgstr "Explorons maintenant une autre méthode pour calculer la FWHM. En revenant au signal de profil d'intensité, nous pouvons calculer directement la FWHM en utilisant \"Analyse > Largeur à mi-hauteur\". Vous pouvez choisir la méthode d'estimation en fonction des caractéristiques de votre courbe et spécifier éventuellement l'intervalle à considérer pour ce calcul. Une fois terminé, la fenêtre de résultats affichera la valeur FWHM, qui est stockée dans les métadonnées et affichée sur la courbe." +msgid "" +"The intensity profile fitted to a Gaussian function. Here both signals " +"are selected." +msgstr "" +"Le profil d'intensité ajusté à une fonction gaussienne. Ici, les deux " +"signaux sont sélectionnés." + +msgid "" +"Now let's explore another method to compute the FWHM. Returning to the " +"intensity profile signal, we can directly compute the FWHM using " +"\"Analysis > Full width at half maximum\". You can choose the estimation " +"method based on your curve characteristics and optionally specify the " +"interval to consider for this computation. Once complete, the results " +"window will display the FWHM value, which is stored in the metadata and " +"shown on the curve." +msgstr "" +"Explorons maintenant une autre méthode pour calculer la FWHM. En revenant" +" au signal de profil d'intensité, nous pouvons calculer directement la " +"FWHM en utilisant \"Analyse > Largeur à mi-hauteur\". Vous pouvez choisir" +" la méthode d'estimation en fonction des caractéristiques de votre courbe" +" et spécifier éventuellement l'intervalle à considérer pour ce calcul. " +"Une fois terminé, la fenêtre de résultats affichera la valeur FWHM, qui " +"est stockée dans les métadonnées et affichée sur la courbe." msgid "The popup that allows to choose the method to estimate the FWHM." -msgstr "La fenêtre contextuelle qui permet de choisir la méthode pour estimer la FWHM." +msgstr "" +"La fenêtre contextuelle qui permet de choisir la méthode pour estimer la " +"FWHM." msgid "The FWHM result shown in the popup and over the curve." -msgstr "Le résultat de la FWHM affiché dans la fenêtre contextuelle et sur la courbe." +msgstr "" +"Le résultat de la FWHM affiché dans la fenêtre contextuelle et sur la " +"courbe." -msgid "Let's also try another method to measure the beam size by returning to the image." -msgstr "Essayons également une autre méthode pour mesurer la taille du faisceau en revenant à l'image." +msgid "" +"Let's also try another method to measure the beam size by returning to " +"the image." +msgstr "" +"Essayons également une autre méthode pour mesurer la taille du faisceau " +"en revenant à l'image." -msgid "From the \"Image panel\", we can extract the radial intensity profile using \"Analysis > Intensity profiles > Radial profile\". The radial intensity profile can be computed around the centroid position, the center of the image, or a user-defined position, depending on your data. For our data, which appears to have radial symmetry with a center not necessarily identical to the image center, the best option is the centroid position." -msgstr "Depuis le panneau \"Images\", nous pouvons extraire le profil d'intensité radial en utilisant \"Opérations > Profils d'intensité > Profil radial\". Le profil d'intensité radial peut être calculé autour de la position du centroïde, du centre de l'image ou d'une position définie par l'utilisateur, selon vos données. Pour nos données, qui semblent avoir une symétrie radiale avec un centre pas nécessairement identique au centre de l'image, la meilleure option est la position du centroïde." +msgid "" +"From the \"Image panel\", we can extract the radial intensity profile " +"using \"Analysis > Intensity profiles > Radial profile\". The radial " +"intensity profile can be computed around the centroid position, the " +"center of the image, or a user-defined position, depending on your data. " +"For our data, which appears to have radial symmetry with a center not " +"necessarily identical to the image center, the best option is the " +"centroid position." +msgstr "" +"Depuis le panneau \"Images\", nous pouvons extraire le profil d'intensité" +" radial en utilisant \"Opérations > Profils d'intensité > Profil " +"radial\". Le profil d'intensité radial peut être calculé autour de la " +"position du centroïde, du centre de l'image ou d'une position définie par" +" l'utilisateur, selon vos données. Pour nos données, qui semblent avoir " +"une symétrie radiale avec un centre pas nécessairement identique au " +"centre de l'image, la meilleure option est la position du centroïde." msgid "The options available for the Radial Profile computation." msgstr "Les options disponibles pour le calcul du profil radial." -msgid "The radial intensity profile displayed in the \"Signal panel\". It is smoother than the line profile, because it is computed from a larger number of pixels, thus averaging the noise." -msgstr "Le profil d'intensité radial affiché dans le panneau \"Signaux\". Il est plus lisse que le profil de ligne, car il est calculé à partir d'un plus grand nombre de pixels, ce qui permet de moyenner le bruit." +msgid "" +"The radial intensity profile displayed in the \"Signal panel\". It is " +"smoother than the line profile, because it is computed from a larger " +"number of pixels, thus averaging the noise." +msgstr "" +"Le profil d'intensité radial affiché dans le panneau \"Signaux\". Il est " +"plus lisse que le profil de ligne, car il est calculé à partir d'un plus " +"grand nombre de pixels, ce qui permet de moyenner le bruit." msgid "Apply these operations to all the images" msgstr "Appliquer ces opérations à toutes les images" -msgid "All the operations and computations performed on a single image can be applied to all images in the \"Image panel\"." -msgstr "Toutes les opérations et calculs effectués sur une seule image peuvent être appliqués à toutes les images du panneau \"Images\"." +msgid "" +"All the operations and computations performed on a single image can be " +"applied to all images in the \"Image panel\"." +msgstr "" +"Toutes les opérations et calculs effectués sur une seule image peuvent " +"être appliqués à toutes les images du panneau \"Images\"." -msgid "To begin, clean the \"Signal panel\" using \"Edit > Delete all\" or the |delete_all| button in the toolbar. Also remove intermediate results from the \"Image panel\" by selecting the images created during prototyping and deleting them individually using \"Edit > Remove\" or the |delete| button." -msgstr "Pour commencer, nettoyez le panneau \"Signaux\" en utilisant \"Édition > Tout supprimer\" ou le bouton |delete_all| dans la barre d'outils. Supprimez également les résultats intermédiaires du panneau \"Images\" en sélectionnant les images créées lors du prototypage et en les supprimant individuellement en utilisant \"Édition > Supprimer\" ou le bouton |delete|." +msgid "" +"To begin, clean the \"Signal panel\" using \"Edit > Delete all\" or the " +"|delete_all| button in the toolbar. Also remove intermediate results from" +" the \"Image panel\" by selecting the images created during prototyping " +"and deleting them individually using \"Edit > Remove\" or the |delete| " +"button." +msgstr "" +"Pour commencer, nettoyez le panneau \"Signaux\" en utilisant \"Édition > " +"Tout supprimer\" ou le bouton |delete_all| dans la barre d'outils. " +"Supprimez également les résultats intermédiaires du panneau \"Images\" en" +" sélectionnant les images créées lors du prototypage et en les supprimant" +" individuellement en utilisant \"Édition > Supprimer\" ou le bouton " +"|delete|." msgid "delete_all" msgstr "delete_all" @@ -189,56 +395,129 @@ msgstr "delete_all" msgid "delete" msgstr "delete" -msgid "Next, select all images in the \"Image panel\" (individually or by selecting the entire group \"g001\")." -msgstr "Ensuite, sélectionnez toutes les images dans le panneau \"Images\" (individuellement ou en sélectionnant l'ensemble du groupe \"g001\")." +msgid "" +"Next, select all images in the \"Image panel\" (individually or by " +"selecting the entire group \"g001\")." +msgstr "" +"Ensuite, sélectionnez toutes les images dans le panneau \"Images\" " +"(individuellement ou en sélectionnant l'ensemble du groupe \"g001\")." -msgid "Apply the clipping operation to all images, then extract the radial intensity profiles for all images (after selecting the entire group \"g002\"—it should be automatically selected if you had \"g001\" selected before applying the threshold)." -msgstr "Appliquez l'opération d'écrêtage à toutes les images, puis extrayez les profils d'intensité radiaux pour toutes les images (après avoir sélectionné l'ensemble du groupe \"g002\"—il devrait être automatiquement sélectionné si vous aviez \"g001\" sélectionné avant d'appliquer le seuil)." +msgid "" +"Apply the clipping operation to all images, then extract the radial " +"intensity profiles for all images (after selecting the entire group " +"\"g002\"—it should be automatically selected if you had \"g001\" selected" +" before applying the threshold)." +msgstr "" +"Appliquez l'opération d'écrêtage à toutes les images, puis extrayez les " +"profils d'intensité radiaux pour toutes les images (après avoir " +"sélectionné l'ensemble du groupe \"g002\"—il devrait être automatiquement" +" sélectionné si vous aviez \"g001\" sélectionné avant d'appliquer le " +"seuil)." msgid "The clipping applies to all images of the group." msgstr "L'écrêtage s'applique à toutes les images du groupe." msgid "The \"Signal panel\" now contains all the radial intensity profiles." -msgstr "Le panneau \"Signaux\" contient maintenant tous les profils d'intensité radiaux." +msgstr "" +"Le panneau \"Signaux\" contient maintenant tous les profils d'intensité " +"radiaux." -msgid "We can now compute the FWHM for all radial intensity profiles. The \"Results\" dialog will display the FWHM values for all profiles." -msgstr "Nous pouvons maintenant calculer la FWHM pour tous les profils d'intensité radiaux. La boîte de dialogue \"Résultats\" affichera les valeurs FWHM pour tous les profils." +msgid "" +"We can now compute the FWHM for all radial intensity profiles. The " +"\"Results\" dialog will display the FWHM values for all profiles." +msgstr "" +"Nous pouvons maintenant calculer la FWHM pour tous les profils " +"d'intensité radiaux. La boîte de dialogue \"Résultats\" affichera les " +"valeurs FWHM pour tous les profils." msgid "The \"Results\" dialog displays the FWHM values for all the profiles." -msgstr "La boîte de dialogue \"Résultats\" affiche les valeurs FWHM pour tous les profils." +msgstr "" +"La boîte de dialogue \"Résultats\" affiche les valeurs FWHM pour tous les" +" profils." -msgid "To display the analysis results again, select \"Show results\" |show_results| from the \"Analysis\" menu, or click the \"Show results\" |show_results| button below the image list:" -msgstr "Pour afficher à nouveau les résultats d'analyse, sélectionnez \"Afficher les résultats\" |show_results| dans le menu \"Analyse\", ou cliquez sur le bouton \"Afficher les résultats\" |show_results| en dessous de la liste des images :" +msgid "" +"To display the analysis results again, select \"Show results\" " +"|show_results| from the \"Analysis\" menu, or click the \"Show results\" " +"|show_results| button below the image list:" +msgstr "" +"Pour afficher à nouveau les résultats d'analyse, sélectionnez \"Afficher " +"les résultats\" |show_results| dans le menu \"Analyse\", ou cliquez sur " +"le bouton \"Afficher les résultats\" |show_results| en dessous de la " +"liste des images :" msgid "show_results" msgstr "show_results" -msgid "Finally, we can plot the beam size as a function of position along the propagation axis using the \"Plot results\" feature |plot_results| from the \"Analysis\" menu. This feature allows you to plot result datasets by selecting the x and y axes from the available result columns. Here, we will plot the FWHM values (`L`) as a function of the image index (`Indices`)." -msgstr "Enfin, nous pouvons tracer la taille du faisceau en fonction de la position le long de l'axe de propagation en utilisant la fonction \"Tracer les résultats\" |plot_results| du menu \"Analyse\". Cette fonction vous permet de tracer des ensembles de données de résultats en sélectionnant les axes x et y parmi les colonnes de résultats disponibles. Ici, nous allons tracer les valeurs FWHM (`L`) en fonction de l'index de l'image (`Indices`)." +msgid "" +"Finally, we can plot the beam size as a function of position along the " +"propagation axis using the \"Plot results\" feature |plot_results| from " +"the \"Analysis\" menu. This feature allows you to plot result datasets by" +" selecting the x and y axes from the available result columns. Here, we " +"will plot the FWHM values (`L`) as a function of the image index " +"(`Indices`)." +msgstr "" +"Enfin, nous pouvons tracer la taille du faisceau en fonction de la " +"position le long de l'axe de propagation en utilisant la fonction " +"\"Tracer les résultats\" |plot_results| du menu \"Analyse\". Cette " +"fonction vous permet de tracer des ensembles de données de résultats en " +"sélectionnant les axes x et y parmi les colonnes de résultats " +"disponibles. Ici, nous allons tracer les valeurs FWHM (`L`) en fonction " +"de l'index de l'image (`Indices`)." msgid "plot_results" msgstr "plot_results" msgid "The \"Plot results\" feature |plot_results| in the \"Analysis\" menu." -msgstr "La fonction \"Tracer les résultats\" |plot_results| dans le menu \"Analyse\"." +msgstr "" +"La fonction \"Tracer les résultats\" |plot_results| dans le menu " +"\"Analyse\"." -msgid "The beam size as a function of position along the propagation axis (the position is in arbitrary units—the image index)." -msgstr "La taille du faisceau en fonction de la position le long de l'axe de propagation (la position est en unités arbitraires—l'index de l'image)." +msgid "" +"The beam size as a function of position along the propagation axis (the " +"position is in arbitrary units—the image index)." +msgstr "" +"La taille du faisceau en fonction de la position le long de l'axe de " +"propagation (la position est en unités arbitraires—l'index de l'image)." +#, python-brace-format msgid "We can also calibrate the X and Y axes using \"Processing > Linear calibration\". Here we set the X axis to the position in mm (entering the title and unit in the \"Properties\" group box) using the formula: :math:`X[\\textrm{mm}] = 0.5 \\cdot i + 10` where :math:`i` is the image index." msgstr "Nous pouvons également étalonner les axes X et Y en utilisant \"Traitement > Étalonnage linéaire\". Ici, nous définissons l'axe X sur la position en mm (en entrant le titre et l'unité dans le groupe \"Propriétés\") en utilisant la formule : :math:`X[\\textrm{mm}] = 0.5 \\cdot i + 10` où :math:`i` est l'index de l'image." -msgid "The calibrated beam size as a function of the position along the propagation axis (the position is now in mm)." -msgstr "La taille du faisceau étalonné en fonction de la position le long de l'axe de propagation (la position est maintenant en mm)." +msgid "" +"The calibrated beam size as a function of the position along the " +"propagation axis (the position is now in mm)." +msgstr "" +"La taille du faisceau étalonné en fonction de la position le long de " +"l'axe de propagation (la position est maintenant en mm)." -msgid "Finally, we can save the workspace to a file |filesave_h5|. The workspace contains all the images and signals that were loaded or processed in DataLab. It also contains the analysis results, the visualization settings (colormaps, contrast, etc.), the metadata, and the annotations." -msgstr "Enfin, nous pouvons enregistrer l'espace de travail dans un fichier |filesave_h5|. L'espace de travail contient toutes les images et signaux qui ont été chargés ou traités dans DataLab. Il contient également les résultats d'analyse, les paramètres de visualisation (cartes de couleurs, contraste, etc.), les métadonnées et les annotations." +msgid "" +"Finally, we can save the workspace to a file |filesave_h5|. The workspace" +" contains all the images and signals that were loaded or processed in " +"DataLab. It also contains the analysis results, the visualization " +"settings (colormaps, contrast, etc.), the metadata, and the annotations." +msgstr "" +"Enfin, nous pouvons enregistrer l'espace de travail dans un fichier " +"|filesave_h5|. L'espace de travail contient toutes les images et signaux " +"qui ont été chargés ou traités dans DataLab. Il contient également les " +"résultats d'analyse, les paramètres de visualisation (cartes de couleurs," +" contraste, etc.), les métadonnées et les annotations." msgid "filesave_h5" msgstr "filesave_h5" -msgid "If you want to load the workspace again, you can use the \"File > Open HDF5 file...\" (or the |fileopen_h5| button in the toolbar) to load the whole workspace, or the \"File > Browse HDF5 file...\" (or the |h5browser| button in the toolbar) to load only a selection of data sets from the workspace." -msgstr "Si vous souhaitez charger à nouveau l'espace de travail, vous pouvez utiliser \"Fichier > Ouvrir un fichier HDF5...\" (ou le bouton |fileopen_h5| dans la barre d'outils) pour charger l'ensemble de l'espace de travail, ou \"Fichier > Parcourir un fichier HDF5...\" (ou le bouton |h5browser| dans la barre d'outils) pour charger uniquement une sélection d'ensembles de données de l'espace de travail." +msgid "" +"If you want to load the workspace again, you can use the \"File > Open " +"HDF5 file...\" (or the |fileopen_h5| button in the toolbar) to load the " +"whole workspace, or the \"File > Browse HDF5 file...\" (or the " +"|h5browser| button in the toolbar) to load only a selection of data sets " +"from the workspace." +msgstr "" +"Si vous souhaitez charger à nouveau l'espace de travail, vous pouvez " +"utiliser \"Fichier > Ouvrir un fichier HDF5...\" (ou le bouton " +"|fileopen_h5| dans la barre d'outils) pour charger l'ensemble de l'espace" +" de travail, ou \"Fichier > Parcourir un fichier HDF5...\" (ou le bouton " +"|h5browser| dans la barre d'outils) pour charger uniquement une sélection" +" d'ensembles de données de l'espace de travail." msgid "fileopen_h5" msgstr "fileopen_h5" diff --git a/doc/locale/fr/LC_MESSAGES/intro/tutorials/spectrum.po b/doc/locale/fr/LC_MESSAGES/intro/tutorials/spectrum.po index 99fc44952..494ae77a9 100644 --- a/doc/locale/fr/LC_MESSAGES/intro/tutorials/spectrum.po +++ b/doc/locale/fr/LC_MESSAGES/intro/tutorials/spectrum.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/intro/tutorials/videos/p1_quick_demo.po b/doc/locale/fr/LC_MESSAGES/intro/tutorials/videos/p1_quick_demo.po index 9668c8c94..12ccc3a74 100644 --- a/doc/locale/fr/LC_MESSAGES/intro/tutorials/videos/p1_quick_demo.po +++ b/doc/locale/fr/LC_MESSAGES/intro/tutorials/videos/p1_quick_demo.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/intro/tutorials/videos/p2_extensibility.po b/doc/locale/fr/LC_MESSAGES/intro/tutorials/videos/p2_extensibility.po index 986ab4c39..3089c39ee 100644 --- a/doc/locale/fr/LC_MESSAGES/intro/tutorials/videos/p2_extensibility.po +++ b/doc/locale/fr/LC_MESSAGES/intro/tutorials/videos/p2_extensibility.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/intro/tutorials/work_with_spyder.po b/doc/locale/fr/LC_MESSAGES/intro/tutorials/work_with_spyder.po index b66115a04..d7c304eda 100644 --- a/doc/locale/fr/LC_MESSAGES/intro/tutorials/work_with_spyder.po +++ b/doc/locale/fr/LC_MESSAGES/intro/tutorials/work_with_spyder.po @@ -5,7 +5,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/outreach/euroscipy2026.po b/doc/locale/fr/LC_MESSAGES/outreach/euroscipy2026.po index 8a5227bfe..33c7515e5 100644 --- a/doc/locale/fr/LC_MESSAGES/outreach/euroscipy2026.po +++ b/doc/locale/fr/LC_MESSAGES/outreach/euroscipy2026.po @@ -5,7 +5,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/outreach/index.po b/doc/locale/fr/LC_MESSAGES/outreach/index.po index e47a45573..7138150c4 100644 --- a/doc/locale/fr/LC_MESSAGES/outreach/index.po +++ b/doc/locale/fr/LC_MESSAGES/outreach/index.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/outreach/osxp2024.po b/doc/locale/fr/LC_MESSAGES/outreach/osxp2024.po index 9e121924b..e7a06f47a 100644 --- a/doc/locale/fr/LC_MESSAGES/outreach/osxp2024.po +++ b/doc/locale/fr/LC_MESSAGES/outreach/osxp2024.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/outreach/osxp2025.po b/doc/locale/fr/LC_MESSAGES/outreach/osxp2025.po index 12ddac530..e8224eb80 100644 --- a/doc/locale/fr/LC_MESSAGES/outreach/osxp2025.po +++ b/doc/locale/fr/LC_MESSAGES/outreach/osxp2025.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/outreach/pydata2024.po b/doc/locale/fr/LC_MESSAGES/outreach/pydata2024.po index 04f4d7e88..44c34ed08 100644 --- a/doc/locale/fr/LC_MESSAGES/outreach/pydata2024.po +++ b/doc/locale/fr/LC_MESSAGES/outreach/pydata2024.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/outreach/scipy2024.po b/doc/locale/fr/LC_MESSAGES/outreach/scipy2024.po index a641e6e04..90af1aee9 100644 --- a/doc/locale/fr/LC_MESSAGES/outreach/scipy2024.po +++ b/doc/locale/fr/LC_MESSAGES/outreach/scipy2024.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/index.po b/doc/locale/fr/LC_MESSAGES/release_notes/index.po index 5e28f5547..d1aafccd0 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/index.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/index.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_0.09.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.09.po index f3bf580db..3db508e7e 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_0.09.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.09.po @@ -5,7 +5,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_0.10.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.10.po index c600f9bab..3919df97a 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_0.10.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.10.po @@ -5,7 +5,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_0.11.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.11.po index 45119e08f..bf103e1ff 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_0.11.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.11.po @@ -5,7 +5,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_0.12.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.12.po index 28ef44dbe..37ba98922 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_0.12.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.12.po @@ -5,7 +5,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_0.14.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.14.po index 650d4681a..2c817c25c 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_0.14.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.14.po @@ -5,7 +5,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_0.15.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.15.po index 7ac711178..d54560ab5 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_0.15.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.15.po @@ -5,7 +5,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_0.16.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.16.po index 209b83849..b94b2a7f5 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_0.16.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.16.po @@ -5,7 +5,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_0.17.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.17.po index 5b7ef9e70..8f6cb4757 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_0.17.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.17.po @@ -5,7 +5,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_0.18.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.18.po index 2337c0d79..de811141d 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_0.18.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.18.po @@ -5,7 +5,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_0.19.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.19.po index 75e386b67..e756f7954 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_0.19.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.19.po @@ -5,7 +5,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_0.20.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.20.po index 2eff53cea..495e0ae46 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_0.20.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.20.po @@ -5,7 +5,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.00.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.00.po index e90f5e4d4..1547a64e5 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.00.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.00.po @@ -5,7 +5,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.01.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.01.po index c932ae655..05a7fd2a2 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.01.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.01.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.02.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.02.po index 6582434b4..6fff33a16 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.02.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.02.po @@ -6,7 +6,6 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po index 03c276f8c..31222a321 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po @@ -5,7 +5,6 @@ # msgid "" msgstr "" -"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" @@ -23,17 +22,40 @@ msgstr "✨ Nouvelles fonctionnalités" msgid "**Third-party plugin discovery via environment variable:**" msgstr "**Découverte des plugins tiers via une variable d'environnement :**" -msgid "Added support for the `DATALAB_PLUGINS` environment variable, allowing one or more directories to be specified as additional plugin search paths" -msgstr "Ajout de la prise en charge de la variable d'environnement `DATALAB_PLUGINS`, permettant de spécifier un ou plusieurs répertoires comme chemins de recherche supplémentaires pour les plugins" +msgid "" +"Added support for the `DATALAB_PLUGINS` environment variable, allowing " +"one or more directories to be specified as additional plugin search paths" +msgstr "" +"Ajout de la prise en charge de la variable d'environnement " +"`DATALAB_PLUGINS`, permettant de spécifier un ou plusieurs répertoires " +"comme chemins de recherche supplémentaires pour les plugins" -msgid "Multiple directories can be listed using the OS path separator (`;` on Windows, `:` on Linux/macOS), following the same convention as `PYTHONPATH`" -msgstr "Plusieurs répertoires peuvent être listés en utilisant le séparateur de chemin du système d'exploitation (`;` sous Windows, `:` sous Linux/macOS), selon la même convention que `PYTHONPATH`" +msgid "" +"Multiple directories can be listed using the OS path separator (`;` on " +"Windows, `:` on Linux/macOS), following the same convention as " +"`PYTHONPATH`" +msgstr "" +"Plusieurs répertoires peuvent être listés en utilisant le séparateur de " +"chemin du système d'exploitation (`;` sous Windows, `:` sous " +"Linux/macOS), selon la même convention que `PYTHONPATH`" -msgid "Listed directories are appended to the existing plugin search paths at startup and are picked up automatically by the plugin discovery mechanism" -msgstr "Les répertoires listés sont ajoutés aux chemins de recherche existants au démarrage et sont automatiquement pris en compte par le mécanisme de découverte des plugins" +msgid "" +"Listed directories are appended to the existing plugin search paths at " +"startup and are picked up automatically by the plugin discovery mechanism" +msgstr "" +"Les répertoires listés sont ajoutés aux chemins de recherche existants au" +" démarrage et sont automatiquement pris en compte par le mécanisme de " +"découverte des plugins" -msgid "Non-existent directories are silently skipped (a warning is recorded in the log file), so a stale environment variable on another machine will not prevent DataLab from starting" -msgstr "Les répertoires inexistants sont ignorés silencieusement (un avertissement est consigné dans le fichier journal), ainsi une variable d'environnement obsolète sur une autre machine n'empêchera pas le démarrage de DataLab" +msgid "" +"Non-existent directories are silently skipped (a warning is recorded in " +"the log file), so a stale environment variable on another machine will " +"not prevent DataLab from starting" +msgstr "" +"Les répertoires inexistants sont ignorés silencieusement (un " +"avertissement est consigné dans le fichier journal), ainsi une variable " +"d'environnement obsolète sur une autre machine n'empêchera pas le " +"démarrage de DataLab" msgid "**Replace special values processing (signal and image):**" msgstr "**Traitement de remplacement des valeurs spéciales (signal et image) :**" @@ -59,6 +81,28 @@ msgstr "Lorsqu'une stratégie de voisinage est sélectionnée, un **aperçu en d msgid "Integer images are handled explicitly: because `NaN` and infinite values cannot exist in integer data, the dialog explains that the operation is not applicable and prevents accidental processing, while preserving the original image data type without unnecessary float conversion" msgstr "Les images entières sont traitées explicitement : comme les valeurs `NaN` et infinies ne peuvent pas exister dans les données entières, la boîte de dialogue explique que l'opération n'est pas applicable et empêche un traitement accidentel, tout en préservant le type de données d'image d'origine sans conversion inutile en flottant" +msgid "**History Panel sessions:**" +msgstr "**Sessions du panneau d'historique :**" + +msgid "" +"Added serialized and replayable history sessions with workspace-state " +"validation" +msgstr "" +"Ajout de sessions d'historique sérialisées et rejouables, avec validation " +"de l'état de l'espace de travail" + +msgid "" +"Added `.dlhist` import/export support and separated reset sessions from " +"regular history sessions" +msgstr "" +"Ajout de la prise en charge de l'import/export `.dlhist` et séparation des " +"sessions de réinitialisation des sessions d'historique ordinaires" + +msgid "Improved replay compatibility reporting for clearer user feedback" +msgstr "" +"Amélioration du rapport de compatibilité de relecture pour fournir un retour " +"utilisateur plus clair" + msgid "🔄 Changes" msgstr "🔄 Modifications" @@ -77,6 +121,9 @@ msgstr "Les résultats d'analyse (statistiques, FWHM, centroïde, détection de msgid "Existing analysis results are now left untouched after such edits, avoiding surprising side effects and results that could become misleading once the data no longer matches the stored analysis parameters" msgstr "Les résultats d'analyse existants sont désormais laissés intacts après de telles modifications, évitant des effets de bord inattendus et des résultats susceptibles de devenir trompeurs lorsque les données ne correspondent plus aux paramètres d'analyse enregistrés" +msgid "Ordinary replay and ordinary mutations do not recompute analyses; however, in History edit mode, editing upstream parameters recomputes downstream analysis actions through the cascade" +msgstr "La relecture ordinaire et les mutations ordinaires ne recalculent pas les analyses ; toutefois, en mode d'édition de l'historique, la modification de paramètres en amont recalcule les actions d'analyse en aval par propagation en cascade" + msgid "The familiar **\"Recompute\"** action (Edit menu, `Ctrl+R`) now refreshes both processing *and* analysis results, giving you full control over when analyses are updated" msgstr "L'action familière **\"Recalculer\"** (menu Édition, `Ctrl+R`) actualise désormais à la fois les résultats de traitement *et* d'analyse, vous permettant de maîtriser pleinement le moment où les analyses sont mises à jour" diff --git a/doc/locale/fr/LC_MESSAGES/requirements.po b/doc/locale/fr/LC_MESSAGES/requirements.po index 9686e4201..2b6352ed9 100644 --- a/doc/locale/fr/LC_MESSAGES/requirements.po +++ b/doc/locale/fr/LC_MESSAGES/requirements.po @@ -6,7 +6,9 @@ #, fuzzy msgid "" msgstr "" -"Language: fr\n" +"Project-Id-Version: PROJECT VERSION\n" +"POT-Creation-Date: 2026-08-05 10:58+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" @@ -40,7 +42,9 @@ msgid ">= 3.14.4" msgstr "" msgid "Automatic GUI generation for easy dataset editing and display" -msgstr "Génération automatique d'interface graphique pour l'édition et l'affichage de jeux de données" +msgstr "" +"Génération automatique d'interface graphique pour l'édition et " +"l'affichage de jeux de données" msgid "PlotPy" msgstr "" @@ -54,10 +58,12 @@ msgstr "Outils de tracé de courbes et d'images pour les applications Python/Qt" msgid "Sigima" msgstr "" -msgid ">= 1.1.4" +msgid ">= 1.1.6" msgstr "" -msgid "Scientific computing engine for 1D signals and 2D images, part of the DataLab open-source platform." +msgid "" +"Scientific computing engine for 1D signals and 2D images, part of the " +"DataLab open-source platform." msgstr "" msgid "NumPy" @@ -129,7 +135,9 @@ msgstr "" msgid ">= 0.110.0" msgstr "" -msgid "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +msgid "" +"FastAPI framework, high performance, easy to learn, fast to code, ready " +"for production" msgstr "" msgid "uvicorn[standard]" @@ -190,7 +198,9 @@ msgid "ruff" msgstr "" msgid "An extremely fast Python linter and code formatter, written in Rust." -msgstr "Analyseur de code et formateur de code Python extrêmement rapide, écrit en Rust." +msgstr "" +"Analyseur de code et formateur de code Python extrêmement rapide, écrit " +"en Rust." msgid "pre-commit" msgstr "" @@ -211,7 +221,9 @@ msgid "sphinx_intl" msgstr "" msgid "Sphinx utility that make it easy to translate and to apply translation." -msgstr "Utilitaire Sphinx qui facilite la traduction et l'application de la traduction." +msgstr "" +"Utilitaire Sphinx qui facilite la traduction et l'application de la " +"traduction." msgid "sphinx-sitemap" msgstr "" @@ -228,8 +240,12 @@ msgstr "Un analyseur étendu compatible [CommonMark](https://spec.commonmark.org msgid "sphinx_design" msgstr "" -msgid "A sphinx extension for designing beautiful, view size responsive web components." -msgstr "Une extension sphinx pour concevoir des composants web soignés et adaptés à la taille de l'écran." +msgid "" +"A sphinx extension for designing beautiful, view size responsive web " +"components." +msgstr "" +"Une extension sphinx pour concevoir des composants web soignés et adaptés" +" à la taille de l'écran." msgid "sphinx-copybutton" msgstr "" @@ -267,3 +283,5 @@ msgstr "" msgid "The next generation HTTP client." msgstr "" +#~ msgid ">= 1.1.4" +#~ msgstr "" diff --git a/doc/release_notes/release_1.03.md b/doc/release_notes/release_1.03.md index c482a3110..4cf47018b 100644 --- a/doc/release_notes/release_1.03.md +++ b/doc/release_notes/release_1.03.md @@ -11,6 +11,12 @@ * Listed directories are appended to the existing plugin search paths at startup and are picked up automatically by the plugin discovery mechanism * Non-existent directories are silently skipped (a warning is recorded in the log file), so a stale environment variable on another machine will not prevent DataLab from starting +**History Panel sessions:** + +* Added serialized and replayable history sessions with workspace-state validation +* Added `.dlhist` import/export support and separated reset sessions from regular history sessions +* Improved replay compatibility reporting for clearer user feedback + **Replace special values processing (signal and image):** DataLab now provides a dedicated **"Replace special values"** processing @@ -53,6 +59,9 @@ and Image panels. * Existing analysis results are now left untouched after such edits, avoiding surprising side effects and results that could become misleading once the data no longer matches the stored analysis parameters +* Ordinary replay and ordinary mutations do not recompute analyses; however, + in History edit mode, editing upstream parameters recomputes downstream + analysis actions through the cascade * The familiar **"Recompute"** action (Edit menu, `Ctrl+R`) now refreshes both processing *and* analysis results, giving you full control over when analyses are updated diff --git a/doc/update_screenshots.py b/doc/update_screenshots.py index 2ae380d1e..8858bb1ce 100644 --- a/doc/update_screenshots.py +++ b/doc/update_screenshots.py @@ -6,6 +6,7 @@ from datalab import config from datalab.tests.features.applauncher import launcher1_app_test +from datalab.tests.features.common import history_panel_app_test from datalab.tests.features.utilities import settings_unit_test from datalab.tests.scenarios import beautiful_app @@ -17,4 +18,5 @@ beautiful_app.run_beautiful_scenario(screenshots=True) beautiful_app.run_blob_detection_on_flower_image(screenshots=True) settings_unit_test.capture_settings_screenshots() + history_panel_app_test.test_history_panel(screenshots=True) print("done.")