qplotter 1.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qplot/__init__.py +46 -0
- qplot/__main__.py +232 -0
- qplot/_auxiliary_paths.py +67 -0
- qplot/_build_info.json +1 -0
- qplot/_metadata.py +75 -0
- qplot/_repair.py +14 -0
- qplot/_shutdown_supervisor.py +4049 -0
- qplot/_version.py +12 -0
- qplot/_windows_shutdown_job.py +1166 -0
- qplot/configuration/__init__.py +3 -0
- qplot/configuration/config.py +578 -0
- qplot/configuration/config_schema.json +357 -0
- qplot/configuration/fonts.py +3 -0
- qplot/configuration/scripts.py +255 -0
- qplot/configuration/themes/__init__.py +13 -0
- qplot/configuration/themes/_base.py +592 -0
- qplot/configuration/themes/blank.py +17 -0
- qplot/configuration/themes/dark.py +54 -0
- qplot/configuration/themes/light.py +54 -0
- qplot/datahandling/LoadFromDB.py +224 -0
- qplot/datahandling/__init__.py +47 -0
- qplot/datahandling/_trusted_live_helper.py +812 -0
- qplot/datahandling/_trusted_live_protocol.py +1745 -0
- qplot/datahandling/database.py +2340 -0
- qplot/datahandling/dimensions.py +43 -0
- qplot/datahandling/file_identity.py +461 -0
- qplot/datahandling/parameter_data.py +149 -0
- qplot/datahandling/plot_progress.py +17 -0
- qplot/datahandling/qcodes_cache.py +277 -0
- qplot/datahandling/qcodes_compat.py +14 -0
- qplot/datahandling/readSQL.py +2442 -0
- qplot/datahandling/readonly.py +2566 -0
- qplot/datahandling/source_activity.py +21 -0
- qplot/datahandling/trusted_array_preview.py +148 -0
- qplot/datahandling/trusted_derived_cache.py +1605 -0
- qplot/datahandling/trusted_derived_rendering.py +876 -0
- qplot/datahandling/trusted_heatmap.py +246 -0
- qplot/datahandling/trusted_live.py +2808 -0
- qplot/datahandling/trusted_live_queries.py +5907 -0
- qplot/datahandling/trusted_live_service.py +1919 -0
- qplot/datahandling/trusted_live_supervisor.py +2161 -0
- qplot/datahandling/trusted_plot.py +283 -0
- qplot/datahandling/trusted_presentation.py +1065 -0
- qplot/datahandling/trusted_snapshot.py +1132 -0
- qplot/datahandling/trusted_work_coordinator.py +2157 -0
- qplot/datahandling/trusted_work_scheduler.py +1204 -0
- qplot/diagnostics.py +241 -0
- qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
- qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
- qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
- qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
- qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
- qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
- qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
- qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
- qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
- qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
- qplot/testdata.py +1804 -0
- qplot/tools/__init__.py +13 -0
- qplot/tools/finite_means.py +123 -0
- qplot/tools/general.py +139 -0
- qplot/tools/heatmap_geometry.py +391 -0
- qplot/tools/operation_registry.py +179 -0
- qplot/tools/plot_tools.py +650 -0
- qplot/tools/sample_statistics.py +111 -0
- qplot/tools/worker.py +2838 -0
- qplot/windows/__init__.py +9 -0
- qplot/windows/_colorbar.py +422 -0
- qplot/windows/_commands.py +523 -0
- qplot/windows/_config_persistence.py +123 -0
- qplot/windows/_database_actions.py +4933 -0
- qplot/windows/_dataset_handle.py +221 -0
- qplot/windows/_display_precision.py +48 -0
- qplot/windows/_dragdrop.py +70 -0
- qplot/windows/_export_paths.py +1167 -0
- qplot/windows/_help.py +210 -0
- qplot/windows/_mixed_appearance.py +303 -0
- qplot/windows/_mixed_plots.py +461 -0
- qplot/windows/_native_averaging.py +323 -0
- qplot/windows/_native_transforms.py +551 -0
- qplot/windows/_plot1d_snap.py +612 -0
- qplot/windows/_plot1d_traces.py +2236 -0
- qplot/windows/_plot2d_colorbar.py +1127 -0
- qplot/windows/_plot2d_colorbar_dialog.py +786 -0
- qplot/windows/_plot2d_layers.py +2291 -0
- qplot/windows/_plot2d_sweeps.py +712 -0
- qplot/windows/_plotWin.py +2082 -0
- qplot/windows/_plot_actions.py +2703 -0
- qplot/windows/_plot_annotation.py +186 -0
- qplot/windows/_plot_appearance.py +283 -0
- qplot/windows/_plot_axis_scaling.py +1521 -0
- qplot/windows/_plot_data_viewbox.py +94 -0
- qplot/windows/_plot_drop_axes.py +224 -0
- qplot/windows/_plot_export.py +1445 -0
- qplot/windows/_plot_feedback.py +112 -0
- qplot/windows/_plot_marquee.py +659 -0
- qplot/windows/_plot_refresh.py +950 -0
- qplot/windows/_plot_source_promotion.py +178 -0
- qplot/windows/_plot_state.py +237 -0
- qplot/windows/_plot_status.py +115 -0
- qplot/windows/_plot_tiling.py +35 -0
- qplot/windows/_plot_transform_labels.py +60 -0
- qplot/windows/_preferences.py +724 -0
- qplot/windows/_refresh_interval.py +172 -0
- qplot/windows/_run_controls.py +789 -0
- qplot/windows/_shortcuts.py +47 -0
- qplot/windows/_subplots/__init__.py +8 -0
- qplot/windows/_subplots/subplot1d.py +532 -0
- qplot/windows/_subplots/subplot2d.py +919 -0
- qplot/windows/_svg_export.py +209 -0
- qplot/windows/_trusted_derived_qt.py +1759 -0
- qplot/windows/_widgets/__init__.py +23 -0
- qplot/windows/_widgets/_run_formatting.py +387 -0
- qplot/windows/_widgets/details_tables.py +1119 -0
- qplot/windows/_widgets/dropbox.py +290 -0
- qplot/windows/_widgets/operations.py +460 -0
- qplot/windows/_widgets/preview.py +2997 -0
- qplot/windows/_widgets/run_list_items.py +488 -0
- qplot/windows/_widgets/toolbar.py +111 -0
- qplot/windows/_widgets/treeWidgets.py +2572 -0
- qplot/windows/_window_controls.py +414 -0
- qplot/windows/main.py +1632 -0
- qplot/windows/plot1d.py +313 -0
- qplot/windows/plot2d.py +1935 -0
- qplotter-1.6.0.dist-info/METADATA +322 -0
- qplotter-1.6.0.dist-info/RECORD +130 -0
- qplotter-1.6.0.dist-info/WHEEL +5 -0
- qplotter-1.6.0.dist-info/entry_points.txt +4 -0
- qplotter-1.6.0.dist-info/licenses/LICENSE +21 -0
- qplotter-1.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1759 @@
|
|
|
1
|
+
"""Qt owner-thread integration for trusted derived work.
|
|
2
|
+
|
|
3
|
+
This bridge is the sole trusted-path producer for derived metadata, run-table
|
|
4
|
+
thumbnails, and selected-run previews. The coordinator, helper, cache, and
|
|
5
|
+
retry threads can only request one coalesced queued wakeup; all polling, image
|
|
6
|
+
decoding, identity validation, and widget mutation happens on the Qt thread.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import queue
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
16
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
17
|
+
from dataclasses import dataclass, replace
|
|
18
|
+
from typing import TYPE_CHECKING, Any, Protocol, cast
|
|
19
|
+
|
|
20
|
+
from PyQt6 import QtCore, QtGui, sip
|
|
21
|
+
|
|
22
|
+
from qplot.datahandling.file_identity import (
|
|
23
|
+
DatabaseInstance,
|
|
24
|
+
database_instance,
|
|
25
|
+
database_instances_differ,
|
|
26
|
+
)
|
|
27
|
+
from qplot.datahandling.trusted_derived_rendering import (
|
|
28
|
+
TRUSTED_DERIVED_RENDERER_VERSION,
|
|
29
|
+
)
|
|
30
|
+
from qplot.datahandling.trusted_live_queries import (
|
|
31
|
+
TrustedParameterView,
|
|
32
|
+
TrustedSelectedRunDetail,
|
|
33
|
+
TrustedSetpointSummary,
|
|
34
|
+
TrustedSourceRevision,
|
|
35
|
+
)
|
|
36
|
+
from qplot.datahandling.trusted_live_service import TrustedLiveReadService
|
|
37
|
+
from qplot.datahandling.trusted_work_coordinator import (
|
|
38
|
+
TrustedDerivedErrorRecord,
|
|
39
|
+
TrustedDerivedRun,
|
|
40
|
+
TrustedSelectedDetailPublication,
|
|
41
|
+
TrustedWorkCoordinator,
|
|
42
|
+
)
|
|
43
|
+
from qplot.datahandling.trusted_work_scheduler import (
|
|
44
|
+
RenderingOptions,
|
|
45
|
+
ScheduledWork,
|
|
46
|
+
TrustedWorkKind,
|
|
47
|
+
WorkFormat,
|
|
48
|
+
WorkPublication,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if TYPE_CHECKING:
|
|
52
|
+
from qplot.windows.main import MainWindow
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_THUMBNAIL_SIZE = 96
|
|
56
|
+
_SNAPSHOT_REQUEST_DEADLINE_SECONDS = 15.0
|
|
57
|
+
_SNAPSHOT_MAX_OUTSTANDING = 2
|
|
58
|
+
_MISSING = object()
|
|
59
|
+
_VIEWPORT_EVENTS = {
|
|
60
|
+
QtCore.QEvent.Type.LayoutRequest,
|
|
61
|
+
QtCore.QEvent.Type.Resize,
|
|
62
|
+
QtCore.QEvent.Type.Show,
|
|
63
|
+
QtCore.QEvent.Type.UpdateRequest,
|
|
64
|
+
QtCore.QEvent.Type.Wheel,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _SnapshotSource(Protocol):
|
|
69
|
+
"""The worker-only portion of a retained trusted Snapshot source."""
|
|
70
|
+
|
|
71
|
+
session_token: object
|
|
72
|
+
|
|
73
|
+
def page(
|
|
74
|
+
self,
|
|
75
|
+
parent_handle: object,
|
|
76
|
+
continuation: object,
|
|
77
|
+
*,
|
|
78
|
+
cancel_check: Callable[[], None],
|
|
79
|
+
) -> object: ...
|
|
80
|
+
|
|
81
|
+
def full_value(
|
|
82
|
+
self,
|
|
83
|
+
handle: object,
|
|
84
|
+
*,
|
|
85
|
+
cancel_check: Callable[[], None],
|
|
86
|
+
) -> object: ...
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True, slots=True)
|
|
90
|
+
class _SnapshotRequestFence:
|
|
91
|
+
"""Complete owner-side identity for one selected Snapshot request."""
|
|
92
|
+
|
|
93
|
+
database_instance: DatabaseInstance
|
|
94
|
+
binding_serial: int
|
|
95
|
+
coordinator_generation: int
|
|
96
|
+
helper_incarnation: int
|
|
97
|
+
source_revision: TrustedSourceRevision
|
|
98
|
+
selection_generation: int
|
|
99
|
+
run_index: int
|
|
100
|
+
run_id: int
|
|
101
|
+
run_guid: str
|
|
102
|
+
service: TrustedLiveReadService
|
|
103
|
+
service_session_generation: int
|
|
104
|
+
snapshot_token: object
|
|
105
|
+
session_serial: int
|
|
106
|
+
parent_handle: object
|
|
107
|
+
continuation: object
|
|
108
|
+
request: object
|
|
109
|
+
kind: str
|
|
110
|
+
handle: object | None = None
|
|
111
|
+
path: object | None = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(slots=True)
|
|
115
|
+
class _SnapshotWork:
|
|
116
|
+
"""Qt-free arguments captured before a Snapshot worker is submitted."""
|
|
117
|
+
|
|
118
|
+
work_id: int
|
|
119
|
+
fence: _SnapshotRequestFence
|
|
120
|
+
source: _SnapshotSource | None
|
|
121
|
+
cancelled: threading.Event
|
|
122
|
+
deadline: float
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True, slots=True)
|
|
126
|
+
class _SnapshotWorkerResult:
|
|
127
|
+
"""Traceback-free worker outcome handed back through the queued Qt signal."""
|
|
128
|
+
|
|
129
|
+
work: _SnapshotWork
|
|
130
|
+
value: object | None
|
|
131
|
+
accepted: bool
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class TrustedDerivedQtBridge(QtCore.QObject):
|
|
135
|
+
"""Own one active coordinator and transactionally publish into MainWindow."""
|
|
136
|
+
|
|
137
|
+
_queuedWakeup = QtCore.pyqtSignal()
|
|
138
|
+
_snapshotResultReady = QtCore.pyqtSignal()
|
|
139
|
+
|
|
140
|
+
def __init__(self, window: MainWindow) -> None:
|
|
141
|
+
super().__init__(window)
|
|
142
|
+
self._window = window
|
|
143
|
+
self._owner_thread_id = threading.get_ident()
|
|
144
|
+
self._wakeup_lock = threading.Lock()
|
|
145
|
+
self._wakeup_queued = False
|
|
146
|
+
self._accepting_wakeups = False
|
|
147
|
+
self._accepting_publications = False
|
|
148
|
+
self._shutting_down = False
|
|
149
|
+
self._binding_serial = 0
|
|
150
|
+
self._database_instance: DatabaseInstance | None = None
|
|
151
|
+
self._service: TrustedLiveReadService | None = None
|
|
152
|
+
self._coordinator: TrustedWorkCoordinator | None = None
|
|
153
|
+
self._coordinator_generation: int | None = None
|
|
154
|
+
self._retiring: list[TrustedWorkCoordinator] = []
|
|
155
|
+
self._run_ids: list[int] = []
|
|
156
|
+
self._run_guids: list[str] = []
|
|
157
|
+
self._index_by_guid: dict[str, int] = {}
|
|
158
|
+
self._index_by_run_id: dict[int, int] = {}
|
|
159
|
+
self._metadata_by_guid: dict[str, dict[str, Any]] = {}
|
|
160
|
+
self._parameters_by_guid: dict[str, tuple[TrustedParameterView, ...]] = {}
|
|
161
|
+
self._summaries_by_guid: dict[str, tuple[TrustedSetpointSummary, ...]] = {}
|
|
162
|
+
self._selected_guid = ""
|
|
163
|
+
self._selected_detail_publication: TrustedSelectedDetailPublication | None = (
|
|
164
|
+
None
|
|
165
|
+
)
|
|
166
|
+
self._detail_display_guid = ""
|
|
167
|
+
self._snapshot_source: _SnapshotSource | None = None
|
|
168
|
+
self._snapshot_session_token: object | None = None
|
|
169
|
+
self._snapshot_session_cancel: threading.Event | None = None
|
|
170
|
+
self._snapshot_session_serial = 0
|
|
171
|
+
self._snapshot_executor: ThreadPoolExecutor | None = None
|
|
172
|
+
self._snapshot_executor_closed = False
|
|
173
|
+
self._snapshot_work_serial = 0
|
|
174
|
+
self._snapshot_work_lock = threading.Lock()
|
|
175
|
+
self._snapshot_slots: dict[int, _SnapshotWork] = {}
|
|
176
|
+
self._snapshot_futures: dict[int, Future[_SnapshotWorkerResult]] = {}
|
|
177
|
+
self._snapshot_live_futures: set[Future[_SnapshotWorkerResult]] = set()
|
|
178
|
+
self._snapshot_results: queue.Queue[_SnapshotWorkerResult] = queue.Queue(
|
|
179
|
+
maxsize=_SNAPSHOT_MAX_OUTSTANDING
|
|
180
|
+
)
|
|
181
|
+
self._last_errors: dict[tuple[str, TrustedWorkKind], str] = {}
|
|
182
|
+
self._last_helper_incarnation = 0
|
|
183
|
+
self._replacement_reload_queued = False
|
|
184
|
+
self._formats = self._work_formats(window.preview_size)
|
|
185
|
+
|
|
186
|
+
self._queuedWakeup.connect( # type: ignore[call-arg]
|
|
187
|
+
self._poll,
|
|
188
|
+
QtCore.Qt.ConnectionType.QueuedConnection,
|
|
189
|
+
)
|
|
190
|
+
self._snapshotResultReady.connect( # type: ignore[call-arg]
|
|
191
|
+
self._publish_snapshot_results,
|
|
192
|
+
QtCore.Qt.ConnectionType.QueuedConnection,
|
|
193
|
+
)
|
|
194
|
+
self._priority_timer = QtCore.QTimer(self)
|
|
195
|
+
self._priority_timer.setSingleShot(True)
|
|
196
|
+
self._priority_timer.setInterval(0)
|
|
197
|
+
self._priority_timer.timeout.connect(self._apply_priority)
|
|
198
|
+
self._retire_timer = QtCore.QTimer(self)
|
|
199
|
+
self._retire_timer.setInterval(25)
|
|
200
|
+
self._retire_timer.timeout.connect(self._reap_retiring)
|
|
201
|
+
|
|
202
|
+
run_list = window.RunList
|
|
203
|
+
run_list.installEventFilter(self)
|
|
204
|
+
viewport = run_list.viewport()
|
|
205
|
+
if viewport is not None:
|
|
206
|
+
viewport.installEventFilter(self)
|
|
207
|
+
header = run_list.header()
|
|
208
|
+
if header is not None:
|
|
209
|
+
header.installEventFilter(self)
|
|
210
|
+
header.sortIndicatorChanged.connect(self.request_priority_update)
|
|
211
|
+
run_list.itemSelectionChanged.connect(self.request_priority_update)
|
|
212
|
+
model = run_list.model()
|
|
213
|
+
if model is not None:
|
|
214
|
+
model.modelReset.connect(self.request_priority_update)
|
|
215
|
+
model.layoutChanged.connect(self.request_priority_update)
|
|
216
|
+
model.rowsInserted.connect(self.request_priority_update)
|
|
217
|
+
model.rowsRemoved.connect(self.request_priority_update)
|
|
218
|
+
|
|
219
|
+
info_box = window.infoBox
|
|
220
|
+
page_requested = getattr(info_box, "snapshotPageRequested", None)
|
|
221
|
+
if page_requested is not None:
|
|
222
|
+
page_requested.connect(self._request_snapshot_page)
|
|
223
|
+
full_value_requested = getattr(
|
|
224
|
+
info_box,
|
|
225
|
+
"snapshotFullValueRequested",
|
|
226
|
+
None,
|
|
227
|
+
)
|
|
228
|
+
if full_value_requested is not None:
|
|
229
|
+
full_value_requested.connect(self._request_snapshot_full_value)
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def coordinator(self) -> TrustedWorkCoordinator | None:
|
|
233
|
+
"""Expose the owner-thread coordinator for diagnostics and tests."""
|
|
234
|
+
|
|
235
|
+
self._require_owner()
|
|
236
|
+
return self._coordinator
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def accepting_publications(self) -> bool:
|
|
240
|
+
return self._accepting_publications
|
|
241
|
+
|
|
242
|
+
def eventFilter(self, watched, event): # noqa: N802 - Qt API
|
|
243
|
+
if event.type() in _VIEWPORT_EVENTS:
|
|
244
|
+
self.request_priority_update()
|
|
245
|
+
return super().eventFilter(watched, event)
|
|
246
|
+
|
|
247
|
+
@QtCore.pyqtSlot()
|
|
248
|
+
def request_priority_update(self, *_args) -> None:
|
|
249
|
+
"""Coalesce selection, sorting, filtering, scrolling, and resize noise."""
|
|
250
|
+
|
|
251
|
+
if self._shutting_down or self._coordinator is None:
|
|
252
|
+
return
|
|
253
|
+
self._priority_timer.start()
|
|
254
|
+
|
|
255
|
+
def suspend_publications(self) -> None:
|
|
256
|
+
"""Disarm queued work before a database publication transaction."""
|
|
257
|
+
|
|
258
|
+
self._require_owner()
|
|
259
|
+
self._priority_timer.stop()
|
|
260
|
+
with self._wakeup_lock:
|
|
261
|
+
self._accepting_wakeups = False
|
|
262
|
+
self._accepting_publications = False
|
|
263
|
+
self._binding_serial += 1
|
|
264
|
+
|
|
265
|
+
def resume_publications(self) -> None:
|
|
266
|
+
"""Resume an unchanged committed binding after transaction rollback."""
|
|
267
|
+
|
|
268
|
+
self._require_owner()
|
|
269
|
+
if self._shutting_down or self._coordinator is None:
|
|
270
|
+
return
|
|
271
|
+
self._accepting_publications = True
|
|
272
|
+
with self._wakeup_lock:
|
|
273
|
+
self._accepting_wakeups = True
|
|
274
|
+
runs = self._window.RunList.all_run_metadata()
|
|
275
|
+
preview = self._window.infoBox.preview
|
|
276
|
+
refresh = getattr(preview, "refresh_trusted_derived_runs", None)
|
|
277
|
+
if callable(refresh):
|
|
278
|
+
refresh(runs)
|
|
279
|
+
else:
|
|
280
|
+
preview.set_trusted_derived_runs(runs)
|
|
281
|
+
self.select_run(getattr(self._window, "_selected_run_guid", None))
|
|
282
|
+
self.request_priority_update()
|
|
283
|
+
self._request_wakeup()
|
|
284
|
+
|
|
285
|
+
def refresh_active_database(
|
|
286
|
+
self,
|
|
287
|
+
database: DatabaseInstance,
|
|
288
|
+
runs: Mapping[int, Mapping[str, object]],
|
|
289
|
+
service: TrustedLiveReadService,
|
|
290
|
+
) -> bool:
|
|
291
|
+
"""Idempotently refresh one already-active trusted database binding."""
|
|
292
|
+
|
|
293
|
+
self._require_owner()
|
|
294
|
+
coordinator = self._coordinator
|
|
295
|
+
if (
|
|
296
|
+
self._shutting_down
|
|
297
|
+
or not self._accepting_publications
|
|
298
|
+
or coordinator is None
|
|
299
|
+
or self._database_instance is None
|
|
300
|
+
or self._service is not service
|
|
301
|
+
or database_instances_differ(self._database_instance, database)
|
|
302
|
+
or database_instances_differ(database, service.database_instance)
|
|
303
|
+
):
|
|
304
|
+
return False
|
|
305
|
+
_derived, run_ids, run_guids = self._normalise_runs(database, runs, service)
|
|
306
|
+
if (
|
|
307
|
+
run_ids[: len(self._run_ids)] != self._run_ids
|
|
308
|
+
or run_guids[: len(self._run_guids)] != self._run_guids
|
|
309
|
+
):
|
|
310
|
+
return False
|
|
311
|
+
if len(run_ids) > len(self._run_ids):
|
|
312
|
+
self.reconcile_runs(runs)
|
|
313
|
+
self._database_instance = database
|
|
314
|
+
preview = self._window.infoBox.preview
|
|
315
|
+
refresh = getattr(preview, "refresh_trusted_derived_runs", None)
|
|
316
|
+
if callable(refresh):
|
|
317
|
+
refresh(runs)
|
|
318
|
+
else:
|
|
319
|
+
preview.set_trusted_derived_runs(runs)
|
|
320
|
+
self.select_run(getattr(self._window, "_selected_run_guid", None))
|
|
321
|
+
self.request_priority_update()
|
|
322
|
+
self._request_wakeup()
|
|
323
|
+
return True
|
|
324
|
+
|
|
325
|
+
def bind_database(
|
|
326
|
+
self,
|
|
327
|
+
database: DatabaseInstance,
|
|
328
|
+
runs: Mapping[int, Mapping[str, object]],
|
|
329
|
+
service: TrustedLiveReadService,
|
|
330
|
+
) -> None:
|
|
331
|
+
"""Bind after the cheap run-list transaction has committed."""
|
|
332
|
+
|
|
333
|
+
self._require_owner()
|
|
334
|
+
if self._shutting_down:
|
|
335
|
+
return
|
|
336
|
+
if not isinstance(database, DatabaseInstance) or database.identity is None:
|
|
337
|
+
raise ValueError("The trusted Qt bridge requires an exact database.")
|
|
338
|
+
if not isinstance(service, TrustedLiveReadService):
|
|
339
|
+
raise TypeError("service must be TrustedLiveReadService.")
|
|
340
|
+
if database_instances_differ(database, service.database_instance):
|
|
341
|
+
raise ValueError("The bridge service is bound to another database.")
|
|
342
|
+
derived_runs, run_ids, run_guids = self._normalise_runs(database, runs, service)
|
|
343
|
+
index_by_guid = {guid: index for index, guid in enumerate(run_guids)}
|
|
344
|
+
selected_guid = str(getattr(self._window, "_selected_run_guid", "") or "")
|
|
345
|
+
priority = (
|
|
346
|
+
index_by_guid.get(selected_guid),
|
|
347
|
+
self._visible_stable_indices(index_by_guid),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
self.suspend_publications()
|
|
351
|
+
self._invalidate_selected_detail_viewer()
|
|
352
|
+
coordinator = self._coordinator
|
|
353
|
+
if coordinator is None:
|
|
354
|
+
coordinator = TrustedWorkCoordinator(
|
|
355
|
+
database,
|
|
356
|
+
derived_runs,
|
|
357
|
+
service,
|
|
358
|
+
formats=self._formats,
|
|
359
|
+
wakeup=self._request_wakeup,
|
|
360
|
+
on_publish=self._publish,
|
|
361
|
+
on_selected_detail=self._publish_selected_detail,
|
|
362
|
+
on_error=self._record_error,
|
|
363
|
+
)
|
|
364
|
+
self._coordinator = coordinator
|
|
365
|
+
coordinator.set_priority(*priority, pump=False)
|
|
366
|
+
else:
|
|
367
|
+
coordinator.switch_database(
|
|
368
|
+
database,
|
|
369
|
+
derived_runs,
|
|
370
|
+
service,
|
|
371
|
+
priority=priority,
|
|
372
|
+
defer_start=True,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
self._database_instance = database
|
|
376
|
+
self._service = service
|
|
377
|
+
self._run_ids = run_ids
|
|
378
|
+
self._run_guids = run_guids
|
|
379
|
+
self._index_by_guid = index_by_guid
|
|
380
|
+
self._index_by_run_id = {
|
|
381
|
+
run_id: index for index, run_id in enumerate(self._run_ids)
|
|
382
|
+
}
|
|
383
|
+
self._metadata_by_guid = {}
|
|
384
|
+
self._parameters_by_guid = {}
|
|
385
|
+
self._summaries_by_guid = {}
|
|
386
|
+
self._selected_guid = ""
|
|
387
|
+
self._selected_detail_publication = None
|
|
388
|
+
self._detail_display_guid = ""
|
|
389
|
+
self._last_errors = {}
|
|
390
|
+
self._last_helper_incarnation = 0
|
|
391
|
+
self._replacement_reload_queued = False
|
|
392
|
+
self._coordinator_generation = coordinator.snapshot().generation
|
|
393
|
+
preview = self._window.infoBox.preview
|
|
394
|
+
preview.set_trusted_derived_runs(runs)
|
|
395
|
+
self._accepting_publications = True
|
|
396
|
+
with self._wakeup_lock:
|
|
397
|
+
self._accepting_wakeups = True
|
|
398
|
+
# A completion can race through while publications are suspended for
|
|
399
|
+
# the baseline transaction. Its notifier is deliberately discarded,
|
|
400
|
+
# so drain once after rearming to avoid stranding the sole active slot.
|
|
401
|
+
coordinator.poll()
|
|
402
|
+
self._apply_priority()
|
|
403
|
+
self.select_run(getattr(self._window, "_selected_run_guid", None))
|
|
404
|
+
coordinator.start()
|
|
405
|
+
|
|
406
|
+
def reconcile_runs(
|
|
407
|
+
self,
|
|
408
|
+
runs: Mapping[int, Mapping[str, object]],
|
|
409
|
+
) -> None:
|
|
410
|
+
"""Append newly visible basic rows without rebuilding the coordinator."""
|
|
411
|
+
|
|
412
|
+
self._require_owner()
|
|
413
|
+
coordinator = self._coordinator
|
|
414
|
+
database = self._database_instance
|
|
415
|
+
service = self._service
|
|
416
|
+
if (
|
|
417
|
+
coordinator is None
|
|
418
|
+
or database is None
|
|
419
|
+
or service is None
|
|
420
|
+
or not self._accepting_publications
|
|
421
|
+
):
|
|
422
|
+
return
|
|
423
|
+
_all, run_ids, run_guids = self._normalise_runs(database, runs, service)
|
|
424
|
+
if run_ids[: len(self._run_ids)] != self._run_ids:
|
|
425
|
+
raise ValueError("Trusted UI run reconciliation must be append-only.")
|
|
426
|
+
if run_guids[: len(self._run_guids)] != self._run_guids:
|
|
427
|
+
raise ValueError("Trusted UI run GUIDs must remain a stable prefix.")
|
|
428
|
+
if len(run_ids) == len(self._run_ids):
|
|
429
|
+
return
|
|
430
|
+
additions = [
|
|
431
|
+
self._derived_run(database, run_id, guid, service)
|
|
432
|
+
for run_id, guid in zip(
|
|
433
|
+
run_ids[len(self._run_ids) :],
|
|
434
|
+
run_guids[len(self._run_guids) :],
|
|
435
|
+
strict=True,
|
|
436
|
+
)
|
|
437
|
+
]
|
|
438
|
+
index_by_guid = {guid: index for index, guid in enumerate(run_guids)}
|
|
439
|
+
selected_guid = str(getattr(self._window, "_selected_run_guid", "") or "")
|
|
440
|
+
visible_indices = self._visible_stable_indices(index_by_guid)
|
|
441
|
+
coordinator.reconcile_runs(
|
|
442
|
+
(*coordinator.runs, *additions),
|
|
443
|
+
priority=(index_by_guid.get(selected_guid), visible_indices),
|
|
444
|
+
)
|
|
445
|
+
self._run_ids = run_ids
|
|
446
|
+
self._run_guids = run_guids
|
|
447
|
+
self._index_by_guid = index_by_guid
|
|
448
|
+
self._index_by_run_id = {
|
|
449
|
+
run_id: index for index, run_id in enumerate(self._run_ids)
|
|
450
|
+
}
|
|
451
|
+
self._window.infoBox.preview.add_trusted_derived_runs(runs)
|
|
452
|
+
self.request_priority_update()
|
|
453
|
+
|
|
454
|
+
def select_run(self, guid: str | None) -> None:
|
|
455
|
+
"""Publish retained state for the selection, then reprioritise pending work."""
|
|
456
|
+
|
|
457
|
+
self._require_owner()
|
|
458
|
+
if not self._accepting_publications:
|
|
459
|
+
return
|
|
460
|
+
exact_guid = str(guid or "")
|
|
461
|
+
selection_changed = exact_guid != self._selected_guid
|
|
462
|
+
if selection_changed:
|
|
463
|
+
self._selected_guid = exact_guid
|
|
464
|
+
self._selected_detail_publication = None
|
|
465
|
+
self._detail_display_guid = ""
|
|
466
|
+
self._invalidate_selected_detail_viewer()
|
|
467
|
+
coordinator = self._coordinator
|
|
468
|
+
selected_index = self._index_by_guid.get(exact_guid)
|
|
469
|
+
if coordinator is not None:
|
|
470
|
+
coordinator.set_priority(
|
|
471
|
+
selected_index,
|
|
472
|
+
self._visible_stable_indices(),
|
|
473
|
+
)
|
|
474
|
+
preview = self._window.infoBox.preview
|
|
475
|
+
if not exact_guid or selected_index is None:
|
|
476
|
+
preview.clear_current_run()
|
|
477
|
+
self.request_priority_update()
|
|
478
|
+
return
|
|
479
|
+
item = self._window.RunList._item_for_guid(exact_guid)
|
|
480
|
+
run_metadata = dict(getattr(item, "run_metadata", {}) or {})
|
|
481
|
+
metadata = self._metadata_by_guid.get(exact_guid)
|
|
482
|
+
if self._detail_display_guid == exact_guid:
|
|
483
|
+
update_live = getattr(
|
|
484
|
+
self._window.infoBox,
|
|
485
|
+
"update_live_run_details",
|
|
486
|
+
None,
|
|
487
|
+
)
|
|
488
|
+
if callable(update_live):
|
|
489
|
+
update_live(run_metadata)
|
|
490
|
+
preview.set_current_guid(exact_guid)
|
|
491
|
+
elif metadata is not None:
|
|
492
|
+
self._window.infoBox.set_trusted_derived_metadata(
|
|
493
|
+
run_metadata,
|
|
494
|
+
self._parameters_by_guid.get(exact_guid, ()),
|
|
495
|
+
self._summaries_by_guid.get(exact_guid, ()),
|
|
496
|
+
metadata,
|
|
497
|
+
)
|
|
498
|
+
else:
|
|
499
|
+
self._window.infoBox.set_trusted_run_loading(run_metadata)
|
|
500
|
+
preview.set_current_guid(exact_guid)
|
|
501
|
+
if metadata is not None and self._detail_display_guid != exact_guid:
|
|
502
|
+
preview.set_current_guid(exact_guid)
|
|
503
|
+
needs_replay = getattr(preview, "trusted_preview_needs_replay", None)
|
|
504
|
+
if callable(needs_replay) and needs_replay(exact_guid):
|
|
505
|
+
self._request_completed_work(exact_guid, TrustedWorkKind.PREVIEW)
|
|
506
|
+
self.request_priority_update()
|
|
507
|
+
|
|
508
|
+
def source_changed(self, run_ids: Sequence[int]) -> None:
|
|
509
|
+
"""Coalesce live appends/completion changes through Stage 5B invalidation."""
|
|
510
|
+
|
|
511
|
+
self._require_owner()
|
|
512
|
+
coordinator = self._coordinator
|
|
513
|
+
if coordinator is None or not self._accepting_publications:
|
|
514
|
+
return
|
|
515
|
+
seen: set[int] = set()
|
|
516
|
+
for run_id in run_ids:
|
|
517
|
+
try:
|
|
518
|
+
exact = int(run_id)
|
|
519
|
+
except (TypeError, ValueError):
|
|
520
|
+
continue
|
|
521
|
+
index = self._index_by_run_id.get(exact)
|
|
522
|
+
if index is None or index in seen:
|
|
523
|
+
continue
|
|
524
|
+
seen.add(index)
|
|
525
|
+
if self._run_guids[index] == self._selected_guid:
|
|
526
|
+
self._selected_detail_publication = None
|
|
527
|
+
self._invalidate_selected_detail_viewer()
|
|
528
|
+
guid = self._run_guids[index]
|
|
529
|
+
discard_plot = getattr(
|
|
530
|
+
self._window.infoBox.preview, "discard_plot_previews", None
|
|
531
|
+
)
|
|
532
|
+
if callable(discard_plot) and discard_plot(guid):
|
|
533
|
+
self._window.RunList.set_run_previews(guid, [])
|
|
534
|
+
coordinator.source_changed(index)
|
|
535
|
+
|
|
536
|
+
def helper_restarted(self) -> None:
|
|
537
|
+
"""Invalidate queued and cached work at an explicit helper boundary."""
|
|
538
|
+
|
|
539
|
+
self._require_owner()
|
|
540
|
+
coordinator = self._coordinator
|
|
541
|
+
if coordinator is None or not self._accepting_publications:
|
|
542
|
+
return
|
|
543
|
+
self._selected_detail_publication = None
|
|
544
|
+
self._invalidate_selected_detail_viewer()
|
|
545
|
+
self._last_helper_incarnation += 1
|
|
546
|
+
coordinator.helper_restarted()
|
|
547
|
+
self._coordinator_generation = coordinator.snapshot().generation
|
|
548
|
+
|
|
549
|
+
def update_preview_size(self, preview_size: int) -> None:
|
|
550
|
+
"""Invalidate only full previews; run-table thumbnails stay current."""
|
|
551
|
+
|
|
552
|
+
self._require_owner()
|
|
553
|
+
preview_format = self._preview_format(preview_size)
|
|
554
|
+
if self._formats[TrustedWorkKind.PREVIEW] == preview_format:
|
|
555
|
+
return
|
|
556
|
+
self._formats = {**self._formats, TrustedWorkKind.PREVIEW: preview_format}
|
|
557
|
+
discard = getattr(
|
|
558
|
+
self._window.infoBox.preview,
|
|
559
|
+
"discard_trusted_previews",
|
|
560
|
+
None,
|
|
561
|
+
)
|
|
562
|
+
if callable(discard):
|
|
563
|
+
discard()
|
|
564
|
+
coordinator = self._coordinator
|
|
565
|
+
if coordinator is not None and self._accepting_publications:
|
|
566
|
+
coordinator.update_format(TrustedWorkKind.PREVIEW, preview_format)
|
|
567
|
+
|
|
568
|
+
def clear_database(self) -> None:
|
|
569
|
+
"""Stop publication immediately and retire work without a GUI wait."""
|
|
570
|
+
|
|
571
|
+
self._require_owner()
|
|
572
|
+
self.suspend_publications()
|
|
573
|
+
coordinator = self._coordinator
|
|
574
|
+
self._coordinator = None
|
|
575
|
+
self._coordinator_generation = None
|
|
576
|
+
self._database_instance = None
|
|
577
|
+
self._service = None
|
|
578
|
+
self._run_ids = []
|
|
579
|
+
self._run_guids = []
|
|
580
|
+
self._index_by_guid = {}
|
|
581
|
+
self._index_by_run_id = {}
|
|
582
|
+
self._metadata_by_guid = {}
|
|
583
|
+
self._parameters_by_guid = {}
|
|
584
|
+
self._summaries_by_guid = {}
|
|
585
|
+
self._selected_guid = ""
|
|
586
|
+
self._selected_detail_publication = None
|
|
587
|
+
self._detail_display_guid = ""
|
|
588
|
+
self._last_errors = {}
|
|
589
|
+
self._replacement_reload_queued = False
|
|
590
|
+
self._invalidate_selected_detail_viewer()
|
|
591
|
+
if coordinator is not None:
|
|
592
|
+
coordinator.close_async()
|
|
593
|
+
self._retiring.append(coordinator)
|
|
594
|
+
self._retire_timer.start()
|
|
595
|
+
|
|
596
|
+
def shutdown(self) -> None:
|
|
597
|
+
self._require_owner()
|
|
598
|
+
if self._shutting_down:
|
|
599
|
+
return
|
|
600
|
+
self._shutting_down = True
|
|
601
|
+
self._priority_timer.stop()
|
|
602
|
+
self.clear_database()
|
|
603
|
+
self._shutdown_snapshot_executor()
|
|
604
|
+
with self._wakeup_lock:
|
|
605
|
+
self._accepting_wakeups = False
|
|
606
|
+
|
|
607
|
+
def background_active(self) -> bool:
|
|
608
|
+
self._require_owner()
|
|
609
|
+
self._reap_retiring()
|
|
610
|
+
with self._snapshot_work_lock:
|
|
611
|
+
snapshot_active = bool(self._snapshot_live_futures or self._snapshot_slots)
|
|
612
|
+
return bool(self._retiring) or snapshot_active
|
|
613
|
+
|
|
614
|
+
def shutdown_diagnostic(self) -> str | None:
|
|
615
|
+
self._require_owner()
|
|
616
|
+
with self._snapshot_work_lock:
|
|
617
|
+
snapshot_work = len(self._snapshot_slots)
|
|
618
|
+
snapshot_futures = len(self._snapshot_live_futures)
|
|
619
|
+
parts: list[str] = []
|
|
620
|
+
if self._retiring:
|
|
621
|
+
parts.append(f"trusted_derived_coordinators_retiring={len(self._retiring)}")
|
|
622
|
+
if snapshot_work or snapshot_futures:
|
|
623
|
+
parts.append(
|
|
624
|
+
"trusted_snapshot_work="
|
|
625
|
+
f"{snapshot_work},trusted_snapshot_futures={snapshot_futures}"
|
|
626
|
+
)
|
|
627
|
+
return ",".join(parts) or None
|
|
628
|
+
|
|
629
|
+
def escalate_cleanup(self) -> None:
|
|
630
|
+
self._require_owner()
|
|
631
|
+
self._cancel_snapshot_session()
|
|
632
|
+
if self._shutting_down:
|
|
633
|
+
self._shutdown_snapshot_executor()
|
|
634
|
+
if self._coordinator is not None:
|
|
635
|
+
self._coordinator.close_async()
|
|
636
|
+
for coordinator in self._retiring:
|
|
637
|
+
coordinator.close_async()
|
|
638
|
+
self._reap_retiring()
|
|
639
|
+
|
|
640
|
+
@QtCore.pyqtSlot()
|
|
641
|
+
def _poll(self) -> None:
|
|
642
|
+
self._require_owner()
|
|
643
|
+
with self._wakeup_lock:
|
|
644
|
+
self._wakeup_queued = False
|
|
645
|
+
if not self._accepting_publications or self._shutting_down:
|
|
646
|
+
self._reap_retiring()
|
|
647
|
+
return
|
|
648
|
+
coordinator = self._coordinator
|
|
649
|
+
if coordinator is not None:
|
|
650
|
+
coordinator.poll()
|
|
651
|
+
self._reap_retiring()
|
|
652
|
+
|
|
653
|
+
def _request_wakeup(self) -> None:
|
|
654
|
+
"""Thread-safe notifier used by both worker and retry-timer threads."""
|
|
655
|
+
|
|
656
|
+
with self._wakeup_lock:
|
|
657
|
+
if (
|
|
658
|
+
not self._accepting_wakeups
|
|
659
|
+
or self._wakeup_queued
|
|
660
|
+
or self._shutting_down
|
|
661
|
+
):
|
|
662
|
+
return
|
|
663
|
+
self._wakeup_queued = True
|
|
664
|
+
try:
|
|
665
|
+
self._queuedWakeup.emit()
|
|
666
|
+
except RuntimeError:
|
|
667
|
+
with self._wakeup_lock:
|
|
668
|
+
self._wakeup_queued = False
|
|
669
|
+
|
|
670
|
+
@QtCore.pyqtSlot()
|
|
671
|
+
def _apply_priority(self) -> None:
|
|
672
|
+
self._require_owner()
|
|
673
|
+
coordinator = self._coordinator
|
|
674
|
+
if coordinator is None or not self._accepting_publications:
|
|
675
|
+
return
|
|
676
|
+
selected_guid = str(getattr(self._window, "_selected_run_guid", "") or "")
|
|
677
|
+
coordinator.set_priority(
|
|
678
|
+
self._index_by_guid.get(selected_guid),
|
|
679
|
+
self._visible_stable_indices(),
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
def _visible_stable_indices(
|
|
683
|
+
self,
|
|
684
|
+
index_by_guid: Mapping[str, int] | None = None,
|
|
685
|
+
) -> tuple[int, ...]:
|
|
686
|
+
stable_indices = self._index_by_guid if index_by_guid is None else index_by_guid
|
|
687
|
+
run_list = self._window.RunList
|
|
688
|
+
viewport = run_list.viewport()
|
|
689
|
+
if viewport is None or viewport.height() <= 0:
|
|
690
|
+
return ()
|
|
691
|
+
first = run_list.itemAt(QtCore.QPoint(1, 1))
|
|
692
|
+
if first is None:
|
|
693
|
+
for y in range(0, viewport.height(), 8):
|
|
694
|
+
first = run_list.itemAt(QtCore.QPoint(1, y))
|
|
695
|
+
if first is not None:
|
|
696
|
+
break
|
|
697
|
+
visible: list[int] = []
|
|
698
|
+
item = first
|
|
699
|
+
while item is not None:
|
|
700
|
+
rect = run_list.visualItemRect(item)
|
|
701
|
+
if rect.isValid() and rect.top() > viewport.rect().bottom():
|
|
702
|
+
break
|
|
703
|
+
guid = str(getattr(item, "guid", "") or "")
|
|
704
|
+
index = stable_indices.get(guid)
|
|
705
|
+
if index is not None:
|
|
706
|
+
visible.append(index)
|
|
707
|
+
item = run_list.itemBelow(item)
|
|
708
|
+
return tuple(dict.fromkeys(visible))
|
|
709
|
+
|
|
710
|
+
def _publish(self, publication: WorkPublication) -> None:
|
|
711
|
+
self._require_owner()
|
|
712
|
+
if not self._publication_is_current(publication):
|
|
713
|
+
return
|
|
714
|
+
payload = publication.result
|
|
715
|
+
if not isinstance(payload, dict):
|
|
716
|
+
return
|
|
717
|
+
kind = publication.key.kind
|
|
718
|
+
if payload.get("kind") != kind.name.lower():
|
|
719
|
+
return
|
|
720
|
+
source = self._pairs(payload.get("source"))
|
|
721
|
+
guid = publication.key.run_guid
|
|
722
|
+
index = self._index_by_guid.get(guid)
|
|
723
|
+
if index is None:
|
|
724
|
+
return
|
|
725
|
+
run_id = self._run_ids[index]
|
|
726
|
+
if source.get("run_id") not in (None, run_id):
|
|
727
|
+
return
|
|
728
|
+
if source.get("run_guid") not in (None, guid):
|
|
729
|
+
return
|
|
730
|
+
incarnation = source.get("helper_incarnation")
|
|
731
|
+
if type(incarnation) is int:
|
|
732
|
+
if incarnation < self._last_helper_incarnation:
|
|
733
|
+
return
|
|
734
|
+
if (
|
|
735
|
+
self._last_helper_incarnation
|
|
736
|
+
and incarnation > self._last_helper_incarnation
|
|
737
|
+
):
|
|
738
|
+
# The helper can retire itself after a bounded-read failure.
|
|
739
|
+
# Treat its first replacement result as a boundary and let
|
|
740
|
+
# the coordinator reschedule against the new namespace.
|
|
741
|
+
self._last_helper_incarnation = incarnation
|
|
742
|
+
self._selected_detail_publication = None
|
|
743
|
+
self._invalidate_selected_detail_viewer()
|
|
744
|
+
coordinator = self._coordinator
|
|
745
|
+
if coordinator is not None:
|
|
746
|
+
coordinator.helper_restarted()
|
|
747
|
+
self._coordinator_generation = coordinator.snapshot().generation
|
|
748
|
+
return
|
|
749
|
+
self._last_helper_incarnation = incarnation
|
|
750
|
+
|
|
751
|
+
if kind is TrustedWorkKind.METADATA:
|
|
752
|
+
self._publish_metadata(publication, run_id, guid, payload)
|
|
753
|
+
return
|
|
754
|
+
if kind is TrustedWorkKind.THUMBNAIL:
|
|
755
|
+
accepts = getattr(self._window.RunList, "accepts_run_preview", None)
|
|
756
|
+
if callable(accepts) and not accepts(guid):
|
|
757
|
+
return
|
|
758
|
+
previews, decode_error = self._decode_images(guid, payload)
|
|
759
|
+
if decode_error is not None:
|
|
760
|
+
previews = [self._unavailable_preview(guid, decode_error)]
|
|
761
|
+
status = str(payload.get("status") or "error")
|
|
762
|
+
description = str(payload.get("description") or "Derived result unavailable.")
|
|
763
|
+
if status not in {"ok", "empty"} and not previews:
|
|
764
|
+
previews = [self._unavailable_preview(guid, description)]
|
|
765
|
+
if kind is TrustedWorkKind.THUMBNAIL:
|
|
766
|
+
self._window.RunList.set_run_previews(guid, previews)
|
|
767
|
+
self._window.RunList.set_run_preview_generating(guid, False)
|
|
768
|
+
elif kind is TrustedWorkKind.PREVIEW:
|
|
769
|
+
self._window.infoBox.preview.publish_trusted_previews(
|
|
770
|
+
guid,
|
|
771
|
+
previews,
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
def _publish_selected_detail(
|
|
775
|
+
self,
|
|
776
|
+
publication: TrustedSelectedDetailPublication,
|
|
777
|
+
) -> None:
|
|
778
|
+
self._require_owner()
|
|
779
|
+
coordinator = self._coordinator
|
|
780
|
+
if (
|
|
781
|
+
coordinator is None
|
|
782
|
+
or publication.key.kind is not TrustedWorkKind.METADATA
|
|
783
|
+
or publication.generation != self._coordinator_generation
|
|
784
|
+
or publication.run_guid != publication.key.run_guid
|
|
785
|
+
or publication.run_index != self._index_by_guid.get(publication.run_guid)
|
|
786
|
+
or publication.run_id != self._run_ids[publication.run_index]
|
|
787
|
+
or publication.run_guid != self._selected_guid
|
|
788
|
+
or publication.run_guid
|
|
789
|
+
!= str(getattr(self._window, "_selected_run_guid", "") or "")
|
|
790
|
+
or coordinator.snapshot().selected_index != publication.run_index
|
|
791
|
+
):
|
|
792
|
+
return
|
|
793
|
+
if not self._publication_is_current(
|
|
794
|
+
WorkPublication(
|
|
795
|
+
publication.generation,
|
|
796
|
+
publication.key,
|
|
797
|
+
None,
|
|
798
|
+
True,
|
|
799
|
+
)
|
|
800
|
+
):
|
|
801
|
+
return
|
|
802
|
+
detail = publication.detail
|
|
803
|
+
if (
|
|
804
|
+
detail.run.run_id != publication.run_id
|
|
805
|
+
or str(detail.run.as_dict().get("guid") or "") != publication.run_guid
|
|
806
|
+
):
|
|
807
|
+
return
|
|
808
|
+
detached = self._detach_snapshot_source(publication)
|
|
809
|
+
if detached is None:
|
|
810
|
+
return
|
|
811
|
+
publication, detail, snapshot_source, snapshot_token = detached
|
|
812
|
+
item = self._window.RunList._item_for_guid(publication.run_guid)
|
|
813
|
+
current_run = dict(getattr(item, "run_metadata", {}) or {})
|
|
814
|
+
detail_run = detail.run.as_dict()
|
|
815
|
+
detail_run["run_id"] = publication.run_id
|
|
816
|
+
detail_run["guid"] = publication.run_guid
|
|
817
|
+
merged_run = self._preserve_newer_live_facts(current_run, detail_run)
|
|
818
|
+
# Accepted detail, including a newer detail for the same run, defines a
|
|
819
|
+
# new request session. Cancel and forget the prior source before any
|
|
820
|
+
# widget can observe the replacement token.
|
|
821
|
+
self._invalidate_selected_detail_viewer()
|
|
822
|
+
if snapshot_source is not None:
|
|
823
|
+
self._snapshot_session_serial += 1
|
|
824
|
+
self._snapshot_source = snapshot_source
|
|
825
|
+
self._snapshot_session_token = snapshot_token
|
|
826
|
+
self._snapshot_session_cancel = threading.Event()
|
|
827
|
+
self._window.RunList.updateRuns({publication.run_id: merged_run})
|
|
828
|
+
self._selected_detail_publication = publication
|
|
829
|
+
self._detail_display_guid = publication.run_guid
|
|
830
|
+
self._window.infoBox.set_snapshot_run_detail(detail)
|
|
831
|
+
update_live = getattr(
|
|
832
|
+
self._window.infoBox,
|
|
833
|
+
"update_live_run_details",
|
|
834
|
+
None,
|
|
835
|
+
)
|
|
836
|
+
if callable(update_live):
|
|
837
|
+
update_live(merged_run)
|
|
838
|
+
# Detail rendering and later overview patches must never detach the
|
|
839
|
+
# independently retained Stage 5C preview selection.
|
|
840
|
+
self._window.infoBox.preview.set_current_guid(publication.run_guid)
|
|
841
|
+
|
|
842
|
+
def _request_completed_work(
|
|
843
|
+
self,
|
|
844
|
+
guid: str,
|
|
845
|
+
kind: TrustedWorkKind,
|
|
846
|
+
) -> bool:
|
|
847
|
+
coordinator = self._coordinator
|
|
848
|
+
database = self._database_instance
|
|
849
|
+
generation = self._coordinator_generation
|
|
850
|
+
index = self._index_by_guid.get(guid)
|
|
851
|
+
if (
|
|
852
|
+
coordinator is None
|
|
853
|
+
or database is None
|
|
854
|
+
or generation is None
|
|
855
|
+
or index is None
|
|
856
|
+
or not self._accepting_publications
|
|
857
|
+
):
|
|
858
|
+
return False
|
|
859
|
+
return coordinator.request_completed_work(
|
|
860
|
+
index,
|
|
861
|
+
kind,
|
|
862
|
+
database_instance=database,
|
|
863
|
+
generation=generation,
|
|
864
|
+
run_guid=guid,
|
|
865
|
+
prioritize=True,
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
def _publish_metadata(
|
|
869
|
+
self,
|
|
870
|
+
publication: WorkPublication,
|
|
871
|
+
run_id: int,
|
|
872
|
+
guid: str,
|
|
873
|
+
payload: Mapping[str, object],
|
|
874
|
+
) -> None:
|
|
875
|
+
status = str(payload.get("status") or "error")
|
|
876
|
+
description = str(payload.get("description") or "Run metadata unavailable.")
|
|
877
|
+
if status != "ok":
|
|
878
|
+
if guid == getattr(self._window, "_selected_run_guid", None):
|
|
879
|
+
item = self._window.RunList._item_for_guid(guid)
|
|
880
|
+
run = dict(getattr(item, "run_metadata", {}) or {})
|
|
881
|
+
if self._detail_display_guid != guid:
|
|
882
|
+
self._window.infoBox.set_trusted_run_error(description, run)
|
|
883
|
+
self._window.infoBox.preview.set_current_guid(guid)
|
|
884
|
+
return
|
|
885
|
+
metadata = {
|
|
886
|
+
str(name): value
|
|
887
|
+
for name, value in self._pairs(payload.get("metadata")).items()
|
|
888
|
+
if isinstance(name, str)
|
|
889
|
+
}
|
|
890
|
+
if metadata.get("run_id") != run_id or metadata.get("guid") != guid:
|
|
891
|
+
return
|
|
892
|
+
raw_run_fields = self._pairs(metadata.get("run_fields"))
|
|
893
|
+
run_fields: dict[str, object] = {
|
|
894
|
+
str(name): self._thaw_payload_value(value)
|
|
895
|
+
for name, value in raw_run_fields.items()
|
|
896
|
+
}
|
|
897
|
+
# An omitted field leaves existing metadata alone; an explicit None
|
|
898
|
+
# invalidates earlier evidence (including acquired counts and shapes).
|
|
899
|
+
run_fields["run_id"] = run_id
|
|
900
|
+
run_fields["guid"] = guid
|
|
901
|
+
item = self._window.RunList._item_for_guid(guid)
|
|
902
|
+
current_run = dict(getattr(item, "run_metadata", {}) or {})
|
|
903
|
+
run_fields = self._preserve_newer_live_facts(current_run, run_fields)
|
|
904
|
+
parameters = self._parameter_views(metadata.get("parameters"))
|
|
905
|
+
dimensions = {
|
|
906
|
+
parameter.name: len(parameter.depends_on)
|
|
907
|
+
for parameter in parameters if parameter.depends_on
|
|
908
|
+
}
|
|
909
|
+
measured_parameters = run_fields.get("measure_parameters")
|
|
910
|
+
if not isinstance(measured_parameters, (list, tuple)):
|
|
911
|
+
measured_parameters = ()
|
|
912
|
+
run_fields["preview_dimensions"] = [
|
|
913
|
+
dimensions.get(name) if isinstance(name, str) else None
|
|
914
|
+
for name in measured_parameters
|
|
915
|
+
]
|
|
916
|
+
summaries = self._setpoint_summaries(metadata.get("setpoint_summaries"))
|
|
917
|
+
self._metadata_by_guid[guid] = dict(metadata)
|
|
918
|
+
self._parameters_by_guid[guid] = parameters
|
|
919
|
+
self._summaries_by_guid[guid] = summaries
|
|
920
|
+
self._window.RunList.updateRuns({run_id: run_fields})
|
|
921
|
+
# PreviewTab replaces each run record, while RunList merges patches.
|
|
922
|
+
# Give both consumers the same accepted facts, including omitted fields
|
|
923
|
+
# retained by RunList and explicit clears from this publication.
|
|
924
|
+
item = self._window.RunList._item_for_guid(guid)
|
|
925
|
+
current_run = dict(getattr(item, "run_metadata", {}) or run_fields)
|
|
926
|
+
self._window.infoBox.preview.add_trusted_derived_runs({run_id: current_run})
|
|
927
|
+
if guid != getattr(self._window, "_selected_run_guid", None):
|
|
928
|
+
return
|
|
929
|
+
retained = self._selected_detail_publication
|
|
930
|
+
if retained is not None and retained.key != publication.key:
|
|
931
|
+
self._selected_detail_publication = None
|
|
932
|
+
self._invalidate_selected_detail_viewer()
|
|
933
|
+
item = self._window.RunList._item_for_guid(guid)
|
|
934
|
+
current_run = dict(getattr(item, "run_metadata", {}) or run_fields)
|
|
935
|
+
if self._detail_display_guid == guid:
|
|
936
|
+
update_live = getattr(
|
|
937
|
+
self._window.infoBox,
|
|
938
|
+
"update_live_run_details",
|
|
939
|
+
None,
|
|
940
|
+
)
|
|
941
|
+
if callable(update_live):
|
|
942
|
+
update_live(current_run)
|
|
943
|
+
self._window.infoBox.preview.set_current_guid(guid)
|
|
944
|
+
return
|
|
945
|
+
self._window.infoBox.set_trusted_derived_metadata(
|
|
946
|
+
current_run,
|
|
947
|
+
parameters,
|
|
948
|
+
summaries,
|
|
949
|
+
metadata,
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
@staticmethod
|
|
953
|
+
def _preserve_newer_live_facts(
|
|
954
|
+
current: Mapping[str, object],
|
|
955
|
+
derived: dict[str, object],
|
|
956
|
+
) -> dict[str, object]:
|
|
957
|
+
"""Merge a captured prefix without rolling back later cheap refresh facts."""
|
|
958
|
+
|
|
959
|
+
merged = dict(derived)
|
|
960
|
+
current_count = current.get("result_count")
|
|
961
|
+
derived_count = derived.get("result_count")
|
|
962
|
+
if type(current_count) is int and (
|
|
963
|
+
type(derived_count) is not int or current_count > derived_count
|
|
964
|
+
):
|
|
965
|
+
merged["result_count"] = current_count
|
|
966
|
+
current_activity = current.get("database_modified_timestamp")
|
|
967
|
+
derived_activity = derived.get("database_modified_timestamp")
|
|
968
|
+
if isinstance(current_activity, (int, float)) and (
|
|
969
|
+
not isinstance(derived_activity, (int, float))
|
|
970
|
+
or current_activity > derived_activity
|
|
971
|
+
):
|
|
972
|
+
merged["database_modified_timestamp"] = current_activity
|
|
973
|
+
if bool(current.get("is_completed")):
|
|
974
|
+
merged["is_completed"] = True
|
|
975
|
+
if current.get("completed_timestamp") is not None:
|
|
976
|
+
merged["completed_timestamp"] = current["completed_timestamp"]
|
|
977
|
+
if current.get("measurement_exception") is not None:
|
|
978
|
+
merged["measurement_exception"] = current["measurement_exception"]
|
|
979
|
+
return merged
|
|
980
|
+
|
|
981
|
+
def _publication_is_current(self, publication: WorkPublication) -> bool:
|
|
982
|
+
if (
|
|
983
|
+
self._shutting_down
|
|
984
|
+
or not self._accepting_publications
|
|
985
|
+
or self._coordinator is None
|
|
986
|
+
or self._database_instance is None
|
|
987
|
+
or self._service is None
|
|
988
|
+
or publication.generation != self._coordinator_generation
|
|
989
|
+
or publication.key.database_instance != self._database_instance
|
|
990
|
+
or publication.key.renderer_version
|
|
991
|
+
!= self._formats[publication.key.kind].renderer_version
|
|
992
|
+
or publication.key.rendering_options
|
|
993
|
+
!= self._formats[publication.key.kind].options
|
|
994
|
+
):
|
|
995
|
+
return False
|
|
996
|
+
window = self._window
|
|
997
|
+
if sip.isdeleted(window) or sip.isdeleted(window.RunList):
|
|
998
|
+
return False
|
|
999
|
+
if (
|
|
1000
|
+
getattr(window, "_shutdown_started", False)
|
|
1001
|
+
or getattr(window, "_shutdown_ready", False)
|
|
1002
|
+
or getattr(window, "_trusted_read_service", None) is not self._service
|
|
1003
|
+
or getattr(window, "_loaded_database_instance", None) is None
|
|
1004
|
+
):
|
|
1005
|
+
return False
|
|
1006
|
+
loaded = window._loaded_database_instance
|
|
1007
|
+
if database_instances_differ(self._database_instance, loaded):
|
|
1008
|
+
return False
|
|
1009
|
+
current = database_instance(self._database_instance.logical_path)
|
|
1010
|
+
if database_instances_differ(
|
|
1011
|
+
self._database_instance, current
|
|
1012
|
+
) or not self._database_instance.sidecar_identities.issubset(
|
|
1013
|
+
current.sidecar_identities
|
|
1014
|
+
):
|
|
1015
|
+
self._queue_replacement_reload()
|
|
1016
|
+
return False
|
|
1017
|
+
item = window.RunList._item_for_guid(publication.key.run_guid)
|
|
1018
|
+
index = self._index_by_guid.get(publication.key.run_guid)
|
|
1019
|
+
if item is None or index is None:
|
|
1020
|
+
return False
|
|
1021
|
+
runs = self._coordinator.runs
|
|
1022
|
+
if (
|
|
1023
|
+
index >= len(runs)
|
|
1024
|
+
or runs[index].run_guid != publication.key.run_guid
|
|
1025
|
+
or runs[index].source_revision != publication.key.source_revision
|
|
1026
|
+
):
|
|
1027
|
+
return False
|
|
1028
|
+
try:
|
|
1029
|
+
item_run_id = int(window.RunList._item_run_id(item))
|
|
1030
|
+
except (TypeError, ValueError):
|
|
1031
|
+
return False
|
|
1032
|
+
return item_run_id == self._run_ids[index]
|
|
1033
|
+
|
|
1034
|
+
def _queue_replacement_reload(self) -> None:
|
|
1035
|
+
if self._replacement_reload_queued or self._database_instance is None:
|
|
1036
|
+
return
|
|
1037
|
+
expected_database = self._database_instance
|
|
1038
|
+
self._replacement_reload_queued = True
|
|
1039
|
+
path = expected_database.logical_path
|
|
1040
|
+
self._invalidate_selected_detail_viewer()
|
|
1041
|
+
self.suspend_publications()
|
|
1042
|
+
QtCore.QTimer.singleShot(
|
|
1043
|
+
0,
|
|
1044
|
+
lambda: self._run_queued_replacement_reload(path, expected_database),
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
def _run_queued_replacement_reload(
|
|
1048
|
+
self,
|
|
1049
|
+
path: str,
|
|
1050
|
+
expected_database: DatabaseInstance,
|
|
1051
|
+
) -> None:
|
|
1052
|
+
"""Reload only while this bridge still owns the replacement claim."""
|
|
1053
|
+
|
|
1054
|
+
self._require_owner()
|
|
1055
|
+
if (
|
|
1056
|
+
not self._replacement_reload_queued
|
|
1057
|
+
or self._shutting_down
|
|
1058
|
+
or self._database_instance is not expected_database
|
|
1059
|
+
):
|
|
1060
|
+
return
|
|
1061
|
+
self._replacement_reload_queued = False
|
|
1062
|
+
self._window._reload_replaced_database(path)
|
|
1063
|
+
|
|
1064
|
+
@staticmethod
|
|
1065
|
+
def _detach_snapshot_source(
|
|
1066
|
+
publication: TrustedSelectedDetailPublication,
|
|
1067
|
+
) -> (
|
|
1068
|
+
tuple[
|
|
1069
|
+
TrustedSelectedDetailPublication,
|
|
1070
|
+
TrustedSelectedRunDetail,
|
|
1071
|
+
_SnapshotSource | None,
|
|
1072
|
+
object | None,
|
|
1073
|
+
]
|
|
1074
|
+
| None
|
|
1075
|
+
):
|
|
1076
|
+
"""Remove the sole raw source before retaining or publishing a detail."""
|
|
1077
|
+
|
|
1078
|
+
detail = publication.detail
|
|
1079
|
+
snapshot = detail.snapshot
|
|
1080
|
+
source = getattr(snapshot, "source", _MISSING)
|
|
1081
|
+
token = getattr(snapshot, "session_token", _MISSING)
|
|
1082
|
+
if source is _MISSING and token is _MISSING:
|
|
1083
|
+
# Compatibility with bounded pre-pagination views: there is no raw
|
|
1084
|
+
# source to retain, so preserve object identity for those callers.
|
|
1085
|
+
return publication, detail, None, None
|
|
1086
|
+
if source is _MISSING or source is None:
|
|
1087
|
+
if token is _MISSING or token is None or token == "":
|
|
1088
|
+
return publication, detail, None, None
|
|
1089
|
+
return None
|
|
1090
|
+
if token is _MISSING or token is None or token == "":
|
|
1091
|
+
return None
|
|
1092
|
+
source_token = getattr(source, "session_token", _MISSING)
|
|
1093
|
+
if (
|
|
1094
|
+
source_token is _MISSING
|
|
1095
|
+
or not TrustedDerivedQtBridge._safe_equal(source_token, token)
|
|
1096
|
+
or not callable(getattr(source, "page", None))
|
|
1097
|
+
or not callable(getattr(source, "full_value", None))
|
|
1098
|
+
):
|
|
1099
|
+
return None
|
|
1100
|
+
try:
|
|
1101
|
+
detached_snapshot = replace(snapshot, source=None)
|
|
1102
|
+
detached_detail = replace(detail, snapshot=detached_snapshot)
|
|
1103
|
+
detached_publication = replace(publication, detail=detached_detail)
|
|
1104
|
+
except (TypeError, ValueError):
|
|
1105
|
+
return None
|
|
1106
|
+
return (
|
|
1107
|
+
detached_publication,
|
|
1108
|
+
detached_detail,
|
|
1109
|
+
cast(_SnapshotSource, source),
|
|
1110
|
+
token,
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
@QtCore.pyqtSlot(object)
|
|
1114
|
+
def _request_snapshot_page(self, request: object) -> None:
|
|
1115
|
+
self._submit_snapshot_request(request, kind="page")
|
|
1116
|
+
|
|
1117
|
+
@QtCore.pyqtSlot(object)
|
|
1118
|
+
def _request_snapshot_full_value(self, request: object) -> None:
|
|
1119
|
+
self._submit_snapshot_request(request, kind="value")
|
|
1120
|
+
|
|
1121
|
+
def _submit_snapshot_request(self, request: object, *, kind: str) -> None:
|
|
1122
|
+
"""Fence and submit at most two selected-session Snapshot requests."""
|
|
1123
|
+
|
|
1124
|
+
self._require_owner()
|
|
1125
|
+
source = self._snapshot_source
|
|
1126
|
+
session_cancel = self._snapshot_session_cancel
|
|
1127
|
+
publication = self._selected_detail_publication
|
|
1128
|
+
database = self._database_instance
|
|
1129
|
+
service = self._service
|
|
1130
|
+
generation = self._coordinator_generation
|
|
1131
|
+
request_token = getattr(request, "session_token", _MISSING)
|
|
1132
|
+
parent_handle = getattr(request, "parent_handle", _MISSING)
|
|
1133
|
+
continuation = getattr(request, "continuation", _MISSING)
|
|
1134
|
+
handle = getattr(request, "handle", _MISSING) if kind == "value" else None
|
|
1135
|
+
path = getattr(request, "path", _MISSING) if kind == "value" else None
|
|
1136
|
+
if (
|
|
1137
|
+
kind not in {"page", "value"}
|
|
1138
|
+
or source is None
|
|
1139
|
+
or session_cancel is None
|
|
1140
|
+
or session_cancel.is_set()
|
|
1141
|
+
or publication is None
|
|
1142
|
+
or database is None
|
|
1143
|
+
or service is None
|
|
1144
|
+
or generation is None
|
|
1145
|
+
or request_token is _MISSING
|
|
1146
|
+
or parent_handle is _MISSING
|
|
1147
|
+
or continuation is _MISSING
|
|
1148
|
+
or (kind == "value" and (handle is _MISSING or path is _MISSING))
|
|
1149
|
+
or not self._safe_equal(request_token, self._snapshot_session_token)
|
|
1150
|
+
):
|
|
1151
|
+
self._reject_snapshot_request(request)
|
|
1152
|
+
return
|
|
1153
|
+
try:
|
|
1154
|
+
service_generation = service.session_generation
|
|
1155
|
+
except (AttributeError, RuntimeError):
|
|
1156
|
+
self._reject_snapshot_request(request)
|
|
1157
|
+
return
|
|
1158
|
+
if type(service_generation) is not int or service_generation <= 0:
|
|
1159
|
+
self._reject_snapshot_request(request)
|
|
1160
|
+
return
|
|
1161
|
+
fence = _SnapshotRequestFence(
|
|
1162
|
+
database_instance=database,
|
|
1163
|
+
binding_serial=self._binding_serial,
|
|
1164
|
+
coordinator_generation=generation,
|
|
1165
|
+
helper_incarnation=publication.helper_incarnation,
|
|
1166
|
+
source_revision=publication.key.source_revision,
|
|
1167
|
+
selection_generation=publication.selection_generation,
|
|
1168
|
+
run_index=publication.run_index,
|
|
1169
|
+
run_id=publication.run_id,
|
|
1170
|
+
run_guid=publication.run_guid,
|
|
1171
|
+
service=service,
|
|
1172
|
+
service_session_generation=service_generation,
|
|
1173
|
+
snapshot_token=request_token,
|
|
1174
|
+
session_serial=self._snapshot_session_serial,
|
|
1175
|
+
parent_handle=parent_handle,
|
|
1176
|
+
continuation=continuation,
|
|
1177
|
+
request=request,
|
|
1178
|
+
kind=kind,
|
|
1179
|
+
handle=handle,
|
|
1180
|
+
path=path,
|
|
1181
|
+
)
|
|
1182
|
+
if not self._snapshot_fence_is_current(fence, source):
|
|
1183
|
+
self._reject_snapshot_request(request)
|
|
1184
|
+
return
|
|
1185
|
+
if self._snapshot_executor_closed:
|
|
1186
|
+
self._reject_snapshot_request(request)
|
|
1187
|
+
return
|
|
1188
|
+
executor = self._snapshot_executor
|
|
1189
|
+
if executor is None:
|
|
1190
|
+
executor = ThreadPoolExecutor(
|
|
1191
|
+
max_workers=1,
|
|
1192
|
+
thread_name_prefix="qplot-trusted-snapshot",
|
|
1193
|
+
)
|
|
1194
|
+
self._snapshot_executor = executor
|
|
1195
|
+
self._snapshot_work_serial += 1
|
|
1196
|
+
work = _SnapshotWork(
|
|
1197
|
+
work_id=self._snapshot_work_serial,
|
|
1198
|
+
fence=fence,
|
|
1199
|
+
source=source,
|
|
1200
|
+
cancelled=session_cancel,
|
|
1201
|
+
deadline=time.monotonic() + _SNAPSHOT_REQUEST_DEADLINE_SECONDS,
|
|
1202
|
+
)
|
|
1203
|
+
# This is the final owner-side revalidation immediately before submit.
|
|
1204
|
+
if not self._snapshot_fence_is_current(fence, source):
|
|
1205
|
+
self._reject_snapshot_request(request)
|
|
1206
|
+
return
|
|
1207
|
+
with self._snapshot_work_lock:
|
|
1208
|
+
queue_full = len(self._snapshot_slots) >= _SNAPSHOT_MAX_OUTSTANDING
|
|
1209
|
+
if not queue_full:
|
|
1210
|
+
self._snapshot_slots[work.work_id] = work
|
|
1211
|
+
if queue_full:
|
|
1212
|
+
self._reject_snapshot_request(request)
|
|
1213
|
+
return
|
|
1214
|
+
try:
|
|
1215
|
+
future = executor.submit(self._run_snapshot_work, work)
|
|
1216
|
+
except RuntimeError:
|
|
1217
|
+
with self._snapshot_work_lock:
|
|
1218
|
+
self._snapshot_slots.pop(work.work_id, None)
|
|
1219
|
+
self._reject_snapshot_request(request)
|
|
1220
|
+
return
|
|
1221
|
+
with self._snapshot_work_lock:
|
|
1222
|
+
self._snapshot_futures[work.work_id] = future
|
|
1223
|
+
self._snapshot_live_futures.add(future)
|
|
1224
|
+
|
|
1225
|
+
def publish_when_done(completed: Future[_SnapshotWorkerResult]) -> None:
|
|
1226
|
+
self._snapshot_work_done(work, completed)
|
|
1227
|
+
|
|
1228
|
+
future.add_done_callback(publish_when_done)
|
|
1229
|
+
|
|
1230
|
+
@staticmethod
|
|
1231
|
+
def _run_snapshot_work(work: _SnapshotWork) -> _SnapshotWorkerResult:
|
|
1232
|
+
"""Run pure Snapshot source work without reading or mutating Qt state."""
|
|
1233
|
+
|
|
1234
|
+
def cancel_check() -> None:
|
|
1235
|
+
if work.cancelled.is_set():
|
|
1236
|
+
raise InterruptedError("The selected Snapshot session was cancelled.")
|
|
1237
|
+
if time.monotonic() >= work.deadline:
|
|
1238
|
+
raise TimeoutError(
|
|
1239
|
+
"The selected Snapshot request exceeded its deadline."
|
|
1240
|
+
)
|
|
1241
|
+
|
|
1242
|
+
try:
|
|
1243
|
+
cancel_check()
|
|
1244
|
+
source = work.source
|
|
1245
|
+
if source is None:
|
|
1246
|
+
raise InterruptedError("The selected Snapshot source was released.")
|
|
1247
|
+
if work.fence.kind == "page":
|
|
1248
|
+
value = source.page(
|
|
1249
|
+
work.fence.parent_handle,
|
|
1250
|
+
work.fence.continuation,
|
|
1251
|
+
cancel_check=cancel_check,
|
|
1252
|
+
)
|
|
1253
|
+
else:
|
|
1254
|
+
value = source.full_value(
|
|
1255
|
+
work.fence.handle,
|
|
1256
|
+
cancel_check=cancel_check,
|
|
1257
|
+
)
|
|
1258
|
+
cancel_check()
|
|
1259
|
+
except BaseException: # noqa: BLE001 - only a traceback-free rejection escapes
|
|
1260
|
+
return _SnapshotWorkerResult(work, None, False)
|
|
1261
|
+
return _SnapshotWorkerResult(work, value, value is not None)
|
|
1262
|
+
|
|
1263
|
+
def _snapshot_work_done(
|
|
1264
|
+
self,
|
|
1265
|
+
work: _SnapshotWork,
|
|
1266
|
+
future: Future[_SnapshotWorkerResult],
|
|
1267
|
+
) -> None:
|
|
1268
|
+
"""Thread-safe completion hook; it emits but never mutates a widget."""
|
|
1269
|
+
|
|
1270
|
+
try:
|
|
1271
|
+
outcome = future.result()
|
|
1272
|
+
except BaseException: # noqa: BLE001 - discard exception/traceback at boundary
|
|
1273
|
+
outcome = _SnapshotWorkerResult(work, None, False)
|
|
1274
|
+
emit = False
|
|
1275
|
+
with self._snapshot_work_lock:
|
|
1276
|
+
self._snapshot_live_futures.discard(future)
|
|
1277
|
+
self._snapshot_futures.pop(work.work_id, None)
|
|
1278
|
+
if self._snapshot_slots.get(work.work_id) is not work:
|
|
1279
|
+
return
|
|
1280
|
+
if work.cancelled.is_set():
|
|
1281
|
+
self._snapshot_slots.pop(work.work_id, None)
|
|
1282
|
+
return
|
|
1283
|
+
try:
|
|
1284
|
+
self._snapshot_results.put_nowait(outcome)
|
|
1285
|
+
except queue.Full:
|
|
1286
|
+
self._snapshot_slots.pop(work.work_id, None)
|
|
1287
|
+
return
|
|
1288
|
+
emit = True
|
|
1289
|
+
if emit:
|
|
1290
|
+
try:
|
|
1291
|
+
self._snapshotResultReady.emit()
|
|
1292
|
+
except RuntimeError:
|
|
1293
|
+
with self._snapshot_work_lock:
|
|
1294
|
+
self._snapshot_slots.pop(work.work_id, None)
|
|
1295
|
+
self._drain_snapshot_results_locked(work.work_id)
|
|
1296
|
+
|
|
1297
|
+
@QtCore.pyqtSlot()
|
|
1298
|
+
def _publish_snapshot_results(self) -> None:
|
|
1299
|
+
"""Publish worker outcomes only after repeating the complete fence."""
|
|
1300
|
+
|
|
1301
|
+
self._require_owner()
|
|
1302
|
+
outcomes: list[_SnapshotWorkerResult] = []
|
|
1303
|
+
while True:
|
|
1304
|
+
try:
|
|
1305
|
+
outcome = self._snapshot_results.get_nowait()
|
|
1306
|
+
except queue.Empty:
|
|
1307
|
+
break
|
|
1308
|
+
with self._snapshot_work_lock:
|
|
1309
|
+
if self._snapshot_slots.get(outcome.work.work_id) is not outcome.work:
|
|
1310
|
+
continue
|
|
1311
|
+
self._snapshot_slots.pop(outcome.work.work_id, None)
|
|
1312
|
+
outcomes.append(outcome)
|
|
1313
|
+
for outcome in outcomes:
|
|
1314
|
+
request = outcome.work.fence.request
|
|
1315
|
+
source = outcome.work.source
|
|
1316
|
+
if (
|
|
1317
|
+
not outcome.accepted
|
|
1318
|
+
or source is None
|
|
1319
|
+
or time.monotonic() >= outcome.work.deadline
|
|
1320
|
+
or not self._snapshot_fence_is_current(
|
|
1321
|
+
outcome.work.fence,
|
|
1322
|
+
source,
|
|
1323
|
+
)
|
|
1324
|
+
):
|
|
1325
|
+
self._reject_snapshot_request(request)
|
|
1326
|
+
continue
|
|
1327
|
+
info_box = self._window.infoBox
|
|
1328
|
+
if outcome.work.fence.kind == "page":
|
|
1329
|
+
accept = getattr(info_box, "accept_snapshot_page", None)
|
|
1330
|
+
if callable(accept):
|
|
1331
|
+
accepted = accept(outcome.value)
|
|
1332
|
+
if accepted is False:
|
|
1333
|
+
self._reject_snapshot_request(request)
|
|
1334
|
+
else:
|
|
1335
|
+
self._reject_snapshot_request(request)
|
|
1336
|
+
else:
|
|
1337
|
+
show = getattr(info_box, "show_snapshot_full_value", None)
|
|
1338
|
+
if callable(show):
|
|
1339
|
+
accepted = show(request, outcome.value)
|
|
1340
|
+
if accepted is False:
|
|
1341
|
+
self._reject_snapshot_request(request)
|
|
1342
|
+
else:
|
|
1343
|
+
self._reject_snapshot_request(request)
|
|
1344
|
+
|
|
1345
|
+
def _snapshot_fence_is_current(
|
|
1346
|
+
self,
|
|
1347
|
+
fence: _SnapshotRequestFence,
|
|
1348
|
+
source: _SnapshotSource,
|
|
1349
|
+
) -> bool:
|
|
1350
|
+
publication = self._selected_detail_publication
|
|
1351
|
+
service = self._service
|
|
1352
|
+
coordinator = self._coordinator
|
|
1353
|
+
cancel = self._snapshot_session_cancel
|
|
1354
|
+
if (
|
|
1355
|
+
self._shutting_down
|
|
1356
|
+
or not self._accepting_publications
|
|
1357
|
+
or publication is None
|
|
1358
|
+
or service is None
|
|
1359
|
+
or coordinator is None
|
|
1360
|
+
or cancel is None
|
|
1361
|
+
or cancel.is_set()
|
|
1362
|
+
or self._snapshot_source is not source
|
|
1363
|
+
or self._database_instance != fence.database_instance
|
|
1364
|
+
or self._binding_serial != fence.binding_serial
|
|
1365
|
+
or self._coordinator_generation != fence.coordinator_generation
|
|
1366
|
+
or publication.generation != fence.coordinator_generation
|
|
1367
|
+
or publication.key.database_instance != fence.database_instance
|
|
1368
|
+
or publication.helper_incarnation != fence.helper_incarnation
|
|
1369
|
+
or publication.key.source_revision != fence.source_revision
|
|
1370
|
+
or publication.selection_generation != fence.selection_generation
|
|
1371
|
+
or publication.run_index != fence.run_index
|
|
1372
|
+
or publication.run_id != fence.run_id
|
|
1373
|
+
or publication.run_guid != fence.run_guid
|
|
1374
|
+
or publication.key.run_guid != fence.run_guid
|
|
1375
|
+
or service is not fence.service
|
|
1376
|
+
or self._snapshot_session_serial != fence.session_serial
|
|
1377
|
+
or not self._safe_equal(
|
|
1378
|
+
self._snapshot_session_token,
|
|
1379
|
+
fence.snapshot_token,
|
|
1380
|
+
)
|
|
1381
|
+
or not self._snapshot_request_matches_fence(fence)
|
|
1382
|
+
or fence.run_index < 0
|
|
1383
|
+
or fence.run_index >= len(self._run_ids)
|
|
1384
|
+
or self._run_ids[fence.run_index] != fence.run_id
|
|
1385
|
+
or self._run_guids[fence.run_index] != fence.run_guid
|
|
1386
|
+
or self._selected_guid != fence.run_guid
|
|
1387
|
+
or str(getattr(self._window, "_selected_run_guid", "") or "")
|
|
1388
|
+
!= fence.run_guid
|
|
1389
|
+
):
|
|
1390
|
+
return False
|
|
1391
|
+
try:
|
|
1392
|
+
if service.session_generation != fence.service_session_generation:
|
|
1393
|
+
return False
|
|
1394
|
+
selected_index = coordinator.snapshot().selected_index
|
|
1395
|
+
except (AttributeError, RuntimeError):
|
|
1396
|
+
return False
|
|
1397
|
+
if selected_index != fence.run_index:
|
|
1398
|
+
return False
|
|
1399
|
+
return self._publication_is_current(
|
|
1400
|
+
WorkPublication(
|
|
1401
|
+
fence.coordinator_generation,
|
|
1402
|
+
publication.key,
|
|
1403
|
+
None,
|
|
1404
|
+
True,
|
|
1405
|
+
)
|
|
1406
|
+
)
|
|
1407
|
+
|
|
1408
|
+
@staticmethod
|
|
1409
|
+
def _snapshot_request_matches_fence(fence: _SnapshotRequestFence) -> bool:
|
|
1410
|
+
request = fence.request
|
|
1411
|
+
if (
|
|
1412
|
+
not TrustedDerivedQtBridge._safe_equal(
|
|
1413
|
+
getattr(request, "session_token", _MISSING),
|
|
1414
|
+
fence.snapshot_token,
|
|
1415
|
+
)
|
|
1416
|
+
or not TrustedDerivedQtBridge._safe_equal(
|
|
1417
|
+
getattr(request, "parent_handle", _MISSING),
|
|
1418
|
+
fence.parent_handle,
|
|
1419
|
+
)
|
|
1420
|
+
or not TrustedDerivedQtBridge._safe_equal(
|
|
1421
|
+
getattr(request, "continuation", _MISSING),
|
|
1422
|
+
fence.continuation,
|
|
1423
|
+
)
|
|
1424
|
+
):
|
|
1425
|
+
return False
|
|
1426
|
+
if fence.kind != "value":
|
|
1427
|
+
return True
|
|
1428
|
+
return TrustedDerivedQtBridge._safe_equal(
|
|
1429
|
+
getattr(request, "handle", _MISSING),
|
|
1430
|
+
fence.handle,
|
|
1431
|
+
) and TrustedDerivedQtBridge._safe_equal(
|
|
1432
|
+
getattr(request, "path", _MISSING),
|
|
1433
|
+
fence.path,
|
|
1434
|
+
)
|
|
1435
|
+
|
|
1436
|
+
@staticmethod
|
|
1437
|
+
def _safe_equal(left: object, right: object) -> bool:
|
|
1438
|
+
try:
|
|
1439
|
+
result = left == right
|
|
1440
|
+
except BaseException: # noqa: BLE001 - malformed requests are rejected
|
|
1441
|
+
return False
|
|
1442
|
+
return result if type(result) is bool else False
|
|
1443
|
+
|
|
1444
|
+
def _reject_snapshot_request(self, request: object) -> None:
|
|
1445
|
+
reject = getattr(self._window.infoBox, "reject_snapshot_request", None)
|
|
1446
|
+
if callable(reject):
|
|
1447
|
+
reject(request)
|
|
1448
|
+
|
|
1449
|
+
def _cancel_snapshot_session(self) -> None:
|
|
1450
|
+
"""Cancel source work and release the raw selected-run Snapshot source."""
|
|
1451
|
+
|
|
1452
|
+
self._require_owner()
|
|
1453
|
+
cancel = self._snapshot_session_cancel
|
|
1454
|
+
self._snapshot_source = None
|
|
1455
|
+
self._snapshot_session_token = None
|
|
1456
|
+
self._snapshot_session_cancel = None
|
|
1457
|
+
self._snapshot_session_serial += 1
|
|
1458
|
+
if cancel is None:
|
|
1459
|
+
return
|
|
1460
|
+
futures: list[Future[_SnapshotWorkerResult]] = []
|
|
1461
|
+
rejected_requests: list[object] = []
|
|
1462
|
+
|
|
1463
|
+
def retain_rejection(request: object) -> None:
|
|
1464
|
+
if all(existing is not request for existing in rejected_requests):
|
|
1465
|
+
rejected_requests.append(request)
|
|
1466
|
+
|
|
1467
|
+
with self._snapshot_work_lock:
|
|
1468
|
+
# Publish cancellation under the same lock used by completion.
|
|
1469
|
+
# A callback can therefore only queue before this point (and be
|
|
1470
|
+
# drained below), or observe cancellation after every retained
|
|
1471
|
+
# work descriptor has released its source and rejection.
|
|
1472
|
+
cancel.set()
|
|
1473
|
+
while True:
|
|
1474
|
+
try:
|
|
1475
|
+
outcome = self._snapshot_results.get_nowait()
|
|
1476
|
+
except queue.Empty:
|
|
1477
|
+
break
|
|
1478
|
+
if outcome.work.cancelled is cancel:
|
|
1479
|
+
retain_rejection(outcome.work.fence.request)
|
|
1480
|
+
outcome.work.source = None
|
|
1481
|
+
self._snapshot_slots.pop(outcome.work.work_id, None)
|
|
1482
|
+
for work_id, work in tuple(self._snapshot_slots.items()):
|
|
1483
|
+
if work.cancelled is not cancel:
|
|
1484
|
+
continue
|
|
1485
|
+
retain_rejection(work.fence.request)
|
|
1486
|
+
work.source = None
|
|
1487
|
+
future = self._snapshot_futures.get(work_id)
|
|
1488
|
+
if future is None:
|
|
1489
|
+
self._snapshot_slots.pop(work_id, None)
|
|
1490
|
+
else:
|
|
1491
|
+
futures.append(future)
|
|
1492
|
+
# Future.cancel() can invoke callbacks synchronously, so it must run
|
|
1493
|
+
# outside the lock those callbacks acquire.
|
|
1494
|
+
for future in futures:
|
|
1495
|
+
future.cancel()
|
|
1496
|
+
for request in rejected_requests:
|
|
1497
|
+
self._reject_snapshot_request(request)
|
|
1498
|
+
|
|
1499
|
+
def _drain_snapshot_results_locked(self, work_id: int) -> None:
|
|
1500
|
+
retained: list[_SnapshotWorkerResult] = []
|
|
1501
|
+
while True:
|
|
1502
|
+
try:
|
|
1503
|
+
outcome = self._snapshot_results.get_nowait()
|
|
1504
|
+
except queue.Empty:
|
|
1505
|
+
break
|
|
1506
|
+
if outcome.work.work_id != work_id:
|
|
1507
|
+
retained.append(outcome)
|
|
1508
|
+
for outcome in retained:
|
|
1509
|
+
self._snapshot_results.put_nowait(outcome)
|
|
1510
|
+
|
|
1511
|
+
def _shutdown_snapshot_executor(self) -> None:
|
|
1512
|
+
if self._snapshot_executor_closed:
|
|
1513
|
+
return
|
|
1514
|
+
self._snapshot_executor_closed = True
|
|
1515
|
+
executor = self._snapshot_executor
|
|
1516
|
+
self._snapshot_executor = None
|
|
1517
|
+
if executor is not None:
|
|
1518
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
1519
|
+
|
|
1520
|
+
def _invalidate_selected_detail_viewer(self) -> None:
|
|
1521
|
+
self._cancel_snapshot_session()
|
|
1522
|
+
invalidate_snapshot = getattr(
|
|
1523
|
+
self._window.infoBox,
|
|
1524
|
+
"invalidate_trusted_snapshot",
|
|
1525
|
+
None,
|
|
1526
|
+
)
|
|
1527
|
+
if callable(invalidate_snapshot):
|
|
1528
|
+
invalidate_snapshot()
|
|
1529
|
+
invalidate = getattr(
|
|
1530
|
+
self._window.infoBox,
|
|
1531
|
+
"invalidate_trusted_full_values",
|
|
1532
|
+
None,
|
|
1533
|
+
)
|
|
1534
|
+
if callable(invalidate):
|
|
1535
|
+
invalidate()
|
|
1536
|
+
|
|
1537
|
+
def _record_error(
|
|
1538
|
+
self,
|
|
1539
|
+
work: ScheduledWork,
|
|
1540
|
+
error: TrustedDerivedErrorRecord,
|
|
1541
|
+
) -> None:
|
|
1542
|
+
self._require_owner()
|
|
1543
|
+
if not self._accepting_publications:
|
|
1544
|
+
return
|
|
1545
|
+
self._last_errors[(work.key.run_guid, work.key.kind)] = error.message
|
|
1546
|
+
|
|
1547
|
+
def _decode_images(
|
|
1548
|
+
self,
|
|
1549
|
+
guid: str,
|
|
1550
|
+
payload: Mapping[str, object],
|
|
1551
|
+
) -> tuple[list[dict[str, object]], str | None]:
|
|
1552
|
+
previews: list[dict[str, object]] = []
|
|
1553
|
+
parameters = {
|
|
1554
|
+
parameter.name: parameter
|
|
1555
|
+
for parameter in self._parameters_by_guid.get(guid, ())
|
|
1556
|
+
}
|
|
1557
|
+
images = payload.get("images")
|
|
1558
|
+
if not isinstance(images, tuple):
|
|
1559
|
+
return previews, "The derived image collection is invalid."
|
|
1560
|
+
for raw in images:
|
|
1561
|
+
image_fields = self._pairs(raw)
|
|
1562
|
+
encoded = image_fields.get("bytes")
|
|
1563
|
+
width = image_fields.get("width")
|
|
1564
|
+
height = image_fields.get("height")
|
|
1565
|
+
dependent = image_fields.get("dependent")
|
|
1566
|
+
if (
|
|
1567
|
+
not isinstance(encoded, bytes)
|
|
1568
|
+
or type(width) is not int
|
|
1569
|
+
or type(height) is not int
|
|
1570
|
+
or not isinstance(dependent, str)
|
|
1571
|
+
):
|
|
1572
|
+
return [], "A derived image descriptor is invalid."
|
|
1573
|
+
image = QtGui.QImage.fromData(encoded, "PNG")
|
|
1574
|
+
if image.isNull() or image.width() != width or image.height() != height:
|
|
1575
|
+
return [], "A derived PNG could not be decoded safely."
|
|
1576
|
+
parameter = parameters.get(dependent)
|
|
1577
|
+
axes = list(parameter.depends_on) if parameter is not None else []
|
|
1578
|
+
title = dependent
|
|
1579
|
+
if axes:
|
|
1580
|
+
title += " vs " + ", ".join(axes)
|
|
1581
|
+
previews.append(
|
|
1582
|
+
{
|
|
1583
|
+
"parameter": dependent,
|
|
1584
|
+
"axes": axes,
|
|
1585
|
+
"dimension_count": len(axes),
|
|
1586
|
+
"title": title,
|
|
1587
|
+
"image": image,
|
|
1588
|
+
}
|
|
1589
|
+
)
|
|
1590
|
+
return previews, None
|
|
1591
|
+
|
|
1592
|
+
def _unavailable_preview(self, guid: str, description: str) -> dict[str, object]:
|
|
1593
|
+
parameters = self._parameters_by_guid.get(guid, ())
|
|
1594
|
+
parameter = next((item for item in parameters if item.depends_on), None)
|
|
1595
|
+
axes = list(parameter.depends_on) if parameter is not None else []
|
|
1596
|
+
return {
|
|
1597
|
+
"parameter": parameter.name if parameter is not None else "",
|
|
1598
|
+
"axes": axes,
|
|
1599
|
+
"dimension_count": len(axes),
|
|
1600
|
+
"title": description,
|
|
1601
|
+
"unsupported": True,
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
@staticmethod
|
|
1605
|
+
def _pairs(value: object) -> dict[object, object]:
|
|
1606
|
+
if not isinstance(value, tuple):
|
|
1607
|
+
return {}
|
|
1608
|
+
output: dict[object, object] = {}
|
|
1609
|
+
for item in value:
|
|
1610
|
+
if not isinstance(item, tuple) or len(item) != 2:
|
|
1611
|
+
return {}
|
|
1612
|
+
output[item[0]] = item[1]
|
|
1613
|
+
return output
|
|
1614
|
+
|
|
1615
|
+
@staticmethod
|
|
1616
|
+
def _thaw_payload_value(value: object) -> object:
|
|
1617
|
+
"""Restore immutable payload sequences to the run-list list contract."""
|
|
1618
|
+
|
|
1619
|
+
if isinstance(value, tuple):
|
|
1620
|
+
return [TrustedDerivedQtBridge._thaw_payload_value(item) for item in value]
|
|
1621
|
+
return value
|
|
1622
|
+
|
|
1623
|
+
@staticmethod
|
|
1624
|
+
def _parameter_views(value: object) -> tuple[TrustedParameterView, ...]:
|
|
1625
|
+
if not isinstance(value, tuple):
|
|
1626
|
+
return ()
|
|
1627
|
+
output: list[TrustedParameterView] = []
|
|
1628
|
+
for item in value:
|
|
1629
|
+
if not isinstance(item, tuple) or len(item) != 5:
|
|
1630
|
+
return ()
|
|
1631
|
+
name, label, unit, depends_on, paramtype = item
|
|
1632
|
+
if not all(
|
|
1633
|
+
isinstance(part, str) for part in (name, label, unit, paramtype)
|
|
1634
|
+
):
|
|
1635
|
+
return ()
|
|
1636
|
+
if not isinstance(depends_on, tuple) or not all(
|
|
1637
|
+
isinstance(axis, str) for axis in depends_on
|
|
1638
|
+
):
|
|
1639
|
+
return ()
|
|
1640
|
+
output.append(
|
|
1641
|
+
TrustedParameterView(name, label, unit, depends_on, paramtype)
|
|
1642
|
+
)
|
|
1643
|
+
return tuple(output)
|
|
1644
|
+
|
|
1645
|
+
@staticmethod
|
|
1646
|
+
def _setpoint_summaries(value: object) -> tuple[TrustedSetpointSummary, ...]:
|
|
1647
|
+
if not isinstance(value, tuple):
|
|
1648
|
+
return ()
|
|
1649
|
+
output: list[TrustedSetpointSummary] = []
|
|
1650
|
+
for item in value:
|
|
1651
|
+
if not isinstance(item, tuple) or len(item) != 4:
|
|
1652
|
+
return ()
|
|
1653
|
+
name, first, last, steps = item
|
|
1654
|
+
if not isinstance(name, str) or (
|
|
1655
|
+
steps is not None and type(steps) is not int
|
|
1656
|
+
):
|
|
1657
|
+
return ()
|
|
1658
|
+
output.append(TrustedSetpointSummary(name, first, last, steps))
|
|
1659
|
+
return tuple(output)
|
|
1660
|
+
|
|
1661
|
+
def _normalise_runs(
|
|
1662
|
+
self,
|
|
1663
|
+
database: DatabaseInstance,
|
|
1664
|
+
runs: Mapping[int, Mapping[str, object]],
|
|
1665
|
+
service: TrustedLiveReadService,
|
|
1666
|
+
) -> tuple[tuple[TrustedDerivedRun, ...], list[int], list[str]]:
|
|
1667
|
+
ordered: list[tuple[int, str]] = []
|
|
1668
|
+
for raw_run_id, metadata in runs.items():
|
|
1669
|
+
try:
|
|
1670
|
+
run_id = int(raw_run_id)
|
|
1671
|
+
except (TypeError, ValueError) as error:
|
|
1672
|
+
raise ValueError(
|
|
1673
|
+
"Trusted run ids must be positive integers."
|
|
1674
|
+
) from error
|
|
1675
|
+
guid = str(metadata.get("guid") or "")
|
|
1676
|
+
if run_id <= 0 or not guid:
|
|
1677
|
+
raise ValueError("Every trusted run requires an id and GUID.")
|
|
1678
|
+
ordered.append((run_id, guid))
|
|
1679
|
+
ordered.sort(key=lambda item: item[0])
|
|
1680
|
+
run_ids = [item[0] for item in ordered]
|
|
1681
|
+
run_guids = [item[1] for item in ordered]
|
|
1682
|
+
if len(run_ids) != len(set(run_ids)) or len(run_guids) != len(set(run_guids)):
|
|
1683
|
+
raise ValueError("Trusted run ids and GUIDs must be unique.")
|
|
1684
|
+
derived = tuple(
|
|
1685
|
+
self._derived_run(database, run_id, guid, service)
|
|
1686
|
+
for run_id, guid in ordered
|
|
1687
|
+
)
|
|
1688
|
+
return derived, run_ids, run_guids
|
|
1689
|
+
|
|
1690
|
+
@staticmethod
|
|
1691
|
+
def _derived_run(
|
|
1692
|
+
database: DatabaseInstance,
|
|
1693
|
+
run_id: int,
|
|
1694
|
+
guid: str,
|
|
1695
|
+
service: TrustedLiveReadService,
|
|
1696
|
+
) -> TrustedDerivedRun:
|
|
1697
|
+
provisional = repr(
|
|
1698
|
+
(
|
|
1699
|
+
"qplot-stage5c-provisional-source-v1",
|
|
1700
|
+
database.logical_path,
|
|
1701
|
+
database.resolved_path,
|
|
1702
|
+
database.identity,
|
|
1703
|
+
service.source_revision_namespace.nonce,
|
|
1704
|
+
run_id,
|
|
1705
|
+
guid,
|
|
1706
|
+
)
|
|
1707
|
+
).encode("utf-8", errors="surrogatepass")
|
|
1708
|
+
return TrustedDerivedRun(
|
|
1709
|
+
run_id,
|
|
1710
|
+
guid,
|
|
1711
|
+
TrustedSourceRevision(hashlib.sha256(provisional).digest()),
|
|
1712
|
+
)
|
|
1713
|
+
|
|
1714
|
+
@staticmethod
|
|
1715
|
+
def _work_formats(preview_size: int) -> dict[TrustedWorkKind, WorkFormat]:
|
|
1716
|
+
return {
|
|
1717
|
+
TrustedWorkKind.METADATA: WorkFormat(TRUSTED_DERIVED_RENDERER_VERSION),
|
|
1718
|
+
TrustedWorkKind.THUMBNAIL: WorkFormat(
|
|
1719
|
+
TRUSTED_DERIVED_RENDERER_VERSION,
|
|
1720
|
+
RenderingOptions.from_mapping(
|
|
1721
|
+
{"height": _THUMBNAIL_SIZE, "width": _THUMBNAIL_SIZE}
|
|
1722
|
+
),
|
|
1723
|
+
),
|
|
1724
|
+
TrustedWorkKind.PREVIEW: TrustedDerivedQtBridge._preview_format(
|
|
1725
|
+
preview_size
|
|
1726
|
+
),
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
@staticmethod
|
|
1730
|
+
def _preview_format(preview_size: int) -> WorkFormat:
|
|
1731
|
+
size = max(1, min(2_048, int(preview_size)))
|
|
1732
|
+
return WorkFormat(
|
|
1733
|
+
TRUSTED_DERIVED_RENDERER_VERSION,
|
|
1734
|
+
RenderingOptions.from_mapping({"height": size, "width": size}),
|
|
1735
|
+
)
|
|
1736
|
+
|
|
1737
|
+
@QtCore.pyqtSlot()
|
|
1738
|
+
def _reap_retiring(self) -> None:
|
|
1739
|
+
self._require_owner()
|
|
1740
|
+
remaining: list[TrustedWorkCoordinator] = []
|
|
1741
|
+
for coordinator in self._retiring:
|
|
1742
|
+
coordinator.poll()
|
|
1743
|
+
if not coordinator.wait_closed(0.0):
|
|
1744
|
+
remaining.append(coordinator)
|
|
1745
|
+
self._retiring = remaining
|
|
1746
|
+
if not remaining:
|
|
1747
|
+
self._retire_timer.stop()
|
|
1748
|
+
|
|
1749
|
+
def _require_owner(self) -> None:
|
|
1750
|
+
if (
|
|
1751
|
+
threading.get_ident() != self._owner_thread_id
|
|
1752
|
+
or QtCore.QThread.currentThread() != self.thread()
|
|
1753
|
+
):
|
|
1754
|
+
raise RuntimeError(
|
|
1755
|
+
"TrustedDerivedQtBridge must mutate state on its Qt owner thread."
|
|
1756
|
+
)
|
|
1757
|
+
|
|
1758
|
+
|
|
1759
|
+
__all__ = ["TrustedDerivedQtBridge"]
|