Skip to content

Commit a79b5d8

Browse files
committed
Integrate History panel architecture and reliability improvements
Consolidate the History panel implementation around explicit runtime, navigation, replay, and persistence responsibilities. This integration also preserves object identity across metadata and remote-control workflows while isolating GUI tests from interactive application state. * [CHG] : Split History runtime, UI, navigation, facade, and reconnection responsibilities into cohesive components * [NEW] : Add reusable replay UUID mapping and object creation services * [FIX] : Preserve object UUIDs across metadata deletion, remote updates, separate views, replay, duplication, and HDF5 persistence * [FIX] : Keep action/output indexes and plugin provenance consistent during deletion, recomputation, reconnection, duplication, and import * [CHG] : Replace overlapping History tests with focused model, workflow, navigation, persistence, and visual contracts * [FIX] : Isolate unattended GUI tests from History tracking, guided-tour startup, dock visibility, and persistent current-tab state
2 parents 75c7351 + ce7938e commit a79b5d8

34 files changed

Lines changed: 3344 additions & 4231 deletions

datalab/gui/creation.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
2+
3+
"""Dependency-neutral object creation services."""
4+
5+
from __future__ import annotations
6+
7+
from typing import TYPE_CHECKING
8+
9+
import guidata.dataset as gds
10+
import numpy as np
11+
from sigima.objects import (
12+
CustomSignalParam,
13+
Gauss2DParam,
14+
ImageDatatypes,
15+
ImageObj,
16+
NewImageParam,
17+
NewSignalParam,
18+
SignalObj,
19+
create_signal,
20+
)
21+
from sigima.objects import create_image_from_param as create_image_headless
22+
from sigima.objects import create_signal_from_param as create_signal_headless
23+
from sigima.objects.base import BaseProcParam
24+
from sigima.objects.signal import DEFAULT_TITLE as SIGNAL_DEFAULT_TITLE
25+
26+
from datalab.config import _
27+
28+
if TYPE_CHECKING:
29+
from qtpy import QtWidgets as QW
30+
31+
CREATION_PARAMETERS_OPTION = "creation_param_json"
32+
33+
34+
def insert_creation_parameters(obj: SignalObj | ImageObj, param: gds.DataSet) -> None:
35+
"""Insert creation parameters into object metadata.
36+
37+
Args:
38+
obj: Object receiving the serialized parameters.
39+
param: Creation parameters.
40+
"""
41+
obj.set_metadata_option(CREATION_PARAMETERS_OPTION, gds.dataset_to_json(param))
42+
43+
44+
def extract_creation_parameters(obj: SignalObj | ImageObj) -> gds.DataSet | None:
45+
"""Extract creation parameters from object metadata.
46+
47+
Args:
48+
obj: Object containing serialized creation parameters.
49+
50+
Returns:
51+
Creation parameters or None if not found.
52+
"""
53+
try:
54+
param_json = obj.get_metadata_option(CREATION_PARAMETERS_OPTION)
55+
except ValueError:
56+
return None
57+
return gds.json_to_dataset(param_json)
58+
59+
60+
def create_signal_from_param(param: NewSignalParam) -> SignalObj:
61+
"""Create a signal from initialized parameters."""
62+
if isinstance(param, CustomSignalParam):
63+
signal = create_signal(param.title)
64+
signal.xydata = param.xyarray.T
65+
if signal.title == SIGNAL_DEFAULT_TITLE:
66+
signal.title = f"custom(npts={param.size})"
67+
return signal
68+
signal = create_signal_headless(param)
69+
if param.__class__ is not NewSignalParam:
70+
insert_creation_parameters(signal, param)
71+
return signal
72+
73+
74+
def prepare_signal_parameters(
75+
param: NewSignalParam | None,
76+
edit: bool,
77+
parent: QW.QWidget | None = None,
78+
) -> NewSignalParam | None:
79+
"""Initialize and optionally edit signal creation parameters."""
80+
if param is None:
81+
param = NewSignalParam()
82+
edit = True
83+
if isinstance(param, CustomSignalParam):
84+
edit = True
85+
if isinstance(param, CustomSignalParam) and edit:
86+
initial = NewSignalParam(_("Custom signal"))
87+
initial.size = 10
88+
if not initial.edit(parent=parent):
89+
return None
90+
param.setup_array(size=initial.size, xmin=initial.xmin, xmax=initial.xmax)
91+
if edit and not param.edit(parent=parent):
92+
return None
93+
return param
94+
95+
96+
def initialize_image_parameters(param: NewImageParam) -> None:
97+
"""Fill image creation defaults required by the editor and constructor."""
98+
if param.height is None:
99+
param.height = 500
100+
if param.width is None:
101+
param.width = 500
102+
if param.dtype is None:
103+
param.dtype = ImageDatatypes.UINT16
104+
numpy_dtype = param.dtype.to_numpy_dtype()
105+
if isinstance(param, Gauss2DParam):
106+
if param.a is None:
107+
try:
108+
param.a = np.iinfo(numpy_dtype).max / 2.0
109+
except ValueError:
110+
param.a = 10.0
111+
elif isinstance(param, BaseProcParam):
112+
param.set_from_datatype(numpy_dtype)
113+
114+
115+
def create_image_from_param(param: NewImageParam) -> ImageObj:
116+
"""Create an image from initialized parameters."""
117+
initialize_image_parameters(param)
118+
image = create_image_headless(param)
119+
if param.__class__ is not NewImageParam:
120+
insert_creation_parameters(image, param)
121+
return image

datalab/gui/historysession_ops.py

Lines changed: 15 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,20 @@
44

55
from __future__ import annotations
66

7-
import logging
87
from contextlib import contextmanager
98
from copy import deepcopy
109
from typing import TYPE_CHECKING, Any, Generator
1110

12-
from qtpy import QtCore as QC
1311
from qtpy import QtWidgets as QW
1412

1513
from datalab.config import _
1614
from datalab.env import execenv
15+
from datalab.gui.panel.history import chain as hchain
1716
from datalab.history import HistoryAction, HistorySession, WorkspaceState
1817

1918
if TYPE_CHECKING:
2019
from datalab.gui.panel.history import HistoryPanel
2120

22-
_logger = logging.getLogger(__name__)
23-
2421

2522
def create_new_session(
2623
panel: HistoryPanel, panel_str: str | None = None
@@ -34,11 +31,11 @@ def create_new_session(
3431
Returns:
3532
The newly created session.
3633
"""
37-
pstr = panel_str or panel.current_panel_str()
38-
panel.session_increment += 1
39-
session = HistorySession(number=panel.session_increment)
34+
pstr = panel_str or panel.navigation.current_panel_str()
35+
panel.navigation.session_increment += 1
36+
session = HistorySession(number=panel.navigation.session_increment)
4037
panel.history_sessions.append(session)
41-
panel.set_active_session(session, pstr)
38+
panel.navigation.set_active_session(session, pstr)
4239
panel.tree.populate_tree(panel.history_sessions)
4340
panel.refresh_compatibility_items()
4441
return session
@@ -64,18 +61,16 @@ def maybe_start_session_for_input(panel: HistoryPanel, *, load: bool = False) ->
6461
"""
6562
if not panel.record_mode_enabled or panel.is_replaying():
6663
return
67-
if panel.suppress_session_prompt:
64+
if panel.runtime.execution.suppress_session_prompt:
6865
return
6966
# Reuse the current session when there is none yet or it has no actions:
7067
# there is nothing to preserve, so no need to prompt.
7168
if not panel.history_sessions or not panel.history_sessions[-1].actions:
7269
return
7370
# Debounce: a synchronous burst of creations (plugin/macro) must prompt only
7471
# once. The guard is reset on the next event-loop turn.
75-
if panel.session_input_pending:
72+
if not panel.runtime.execution.start_session_input_prompt():
7673
return
77-
panel.session_input_pending = True
78-
QC.QTimer.singleShot(0, lambda: setattr(panel, "session_input_pending", False))
7974
if execenv.unattended:
8075
# Headless runs: honor the accept_dialogs flag (default False -> "No"),
8176
# so tests can drive the behavior without a real modal dialog.
@@ -154,8 +149,8 @@ def add_compute_entry(
154149
pattern=pattern,
155150
kwargs=deepcopy(kwargs),
156151
state=state,
157-
plugin_origin=plugin_origin,
158152
)
153+
action.plugin_origin = deepcopy(plugin_origin)
159154
panel.add_object(action)
160155
if output_uuids is not None:
161156
panel.register_action_outputs(action, output_uuids)
@@ -228,39 +223,7 @@ def register_action_outputs(
228223
``1_to_0`` analysis patterns and for UI actions that did not
229224
create new objects).
230225
"""
231-
# Drop previous outputs for this action from the reverse index.
232-
previous = panel.action_output_uuids.get(action.uuid, [])
233-
for prev_uuid in previous:
234-
if panel.output_to_action.get(prev_uuid) == action.uuid:
235-
panel.output_to_action.pop(prev_uuid, None)
236-
new_outputs = list(output_uuids)
237-
# Ownership transfer: if an output_uuid already belongs to a
238-
# *different* action, remove it from that action's output list so the
239-
# forward mapping stays consistent. The HistoryAction object's
240-
# ``output_uuids`` attribute is NOT updated here because traversing all
241-
# sessions to locate the object would be expensive; the panel-level
242-
# dicts are the source of truth.
243-
for out_uuid in new_outputs:
244-
old_action_uuid = panel.output_to_action.get(out_uuid)
245-
if old_action_uuid is not None and old_action_uuid != action.uuid:
246-
old_list = panel.action_output_uuids.get(old_action_uuid)
247-
if old_list is not None:
248-
try:
249-
old_list.remove(out_uuid)
250-
except ValueError:
251-
pass
252-
if not old_list:
253-
del panel.action_output_uuids[old_action_uuid]
254-
_logger.debug(
255-
"Output %s transferred from action %s to %s",
256-
out_uuid,
257-
old_action_uuid,
258-
action.uuid,
259-
)
260-
action.output_uuids = list(new_outputs)
261-
panel.action_output_uuids[action.uuid] = new_outputs
262-
for out_uuid in new_outputs:
263-
panel.output_to_action[out_uuid] = action.uuid
226+
panel.runtime.objects.register_action_outputs(action, output_uuids)
264227

265228

266229
@contextmanager
@@ -302,10 +265,10 @@ def capture_outputs(
302265
# it failed (or was a full no-op). Do not keep a misleading "OK"
303266
# entry in the history — remove the just-recorded action and
304267
# refresh the tree so the panel stays consistent.
305-
panel.remove_single_action(action)
268+
hchain.remove_single_action(panel, action)
306269
panel.tree.populate_tree(panel.history_sessions)
307270
panel.refresh_compatibility_items()
308-
panel.update_actions_state()
271+
panel.ui.update_actions_state()
309272

310273

311274
def add_ui_entry(
@@ -373,13 +336,13 @@ def add_object(panel: HistoryPanel, obj: HistoryAction) -> None:
373336
and image pipelines stay in separate sessions and recording resumes in the
374337
user-selected session.
375338
"""
376-
pstr = obj.panel_str or panel.current_panel_str()
377-
session = panel.get_active_session(pstr)
339+
pstr = obj.panel_str or panel.navigation.current_panel_str()
340+
session = panel.navigation.get_active_session(pstr)
378341
if session is None:
379342
session = panel.create_new_session(panel_str=pstr)
380343
session.add_action(obj)
381344
session_index = panel.history_sessions.index(session)
382-
panel.tree.add_action_to_tree(obj, session_index)
345+
panel.tree.rebuild_session(session_index)
383346
panel.tree.rearrange_tree()
384347
panel.refresh_compatibility_items()
385-
panel.update_actions_state()
348+
panel.ui.update_actions_state()

0 commit comments

Comments
 (0)