qplotter 1.6.0b3__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.
Files changed (119) hide show
  1. qplot/__init__.py +46 -0
  2. qplot/__main__.py +232 -0
  3. qplot/_auxiliary_paths.py +63 -0
  4. qplot/_metadata.py +12 -0
  5. qplot/_repair.py +14 -0
  6. qplot/_shutdown_supervisor.py +4049 -0
  7. qplot/_version.py +12 -0
  8. qplot/_windows_shutdown_job.py +1166 -0
  9. qplot/configuration/__init__.py +3 -0
  10. qplot/configuration/config.py +573 -0
  11. qplot/configuration/config_schema.json +319 -0
  12. qplot/configuration/scripts.py +255 -0
  13. qplot/configuration/themes/__init__.py +13 -0
  14. qplot/configuration/themes/_base.py +579 -0
  15. qplot/configuration/themes/blank.py +17 -0
  16. qplot/configuration/themes/dark.py +54 -0
  17. qplot/configuration/themes/light.py +54 -0
  18. qplot/datahandling/LoadFromDB.py +224 -0
  19. qplot/datahandling/__init__.py +47 -0
  20. qplot/datahandling/_trusted_live_helper.py +812 -0
  21. qplot/datahandling/_trusted_live_protocol.py +1745 -0
  22. qplot/datahandling/database.py +2330 -0
  23. qplot/datahandling/dimensions.py +43 -0
  24. qplot/datahandling/file_identity.py +461 -0
  25. qplot/datahandling/parameter_data.py +149 -0
  26. qplot/datahandling/plot_progress.py +17 -0
  27. qplot/datahandling/qcodes_cache.py +277 -0
  28. qplot/datahandling/qcodes_compat.py +14 -0
  29. qplot/datahandling/readSQL.py +2442 -0
  30. qplot/datahandling/readonly.py +2561 -0
  31. qplot/datahandling/source_activity.py +21 -0
  32. qplot/datahandling/trusted_array_preview.py +127 -0
  33. qplot/datahandling/trusted_derived_cache.py +1605 -0
  34. qplot/datahandling/trusted_derived_rendering.py +868 -0
  35. qplot/datahandling/trusted_heatmap.py +241 -0
  36. qplot/datahandling/trusted_live.py +2808 -0
  37. qplot/datahandling/trusted_live_queries.py +5907 -0
  38. qplot/datahandling/trusted_live_service.py +1894 -0
  39. qplot/datahandling/trusted_live_supervisor.py +2161 -0
  40. qplot/datahandling/trusted_plot.py +283 -0
  41. qplot/datahandling/trusted_presentation.py +1065 -0
  42. qplot/datahandling/trusted_snapshot.py +1132 -0
  43. qplot/datahandling/trusted_work_coordinator.py +2157 -0
  44. qplot/datahandling/trusted_work_scheduler.py +1204 -0
  45. qplot/diagnostics.py +241 -0
  46. qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
  47. qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
  48. qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
  49. qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
  50. qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
  51. qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
  52. qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
  53. qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
  54. qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
  55. qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
  56. qplot/testdata.py +1733 -0
  57. qplot/tools/__init__.py +13 -0
  58. qplot/tools/finite_means.py +114 -0
  59. qplot/tools/general.py +134 -0
  60. qplot/tools/heatmap_geometry.py +381 -0
  61. qplot/tools/operation_registry.py +179 -0
  62. qplot/tools/plot_tools.py +625 -0
  63. qplot/tools/sample_statistics.py +56 -0
  64. qplot/tools/worker.py +2787 -0
  65. qplot/windows/__init__.py +9 -0
  66. qplot/windows/_colorbar.py +422 -0
  67. qplot/windows/_commands.py +523 -0
  68. qplot/windows/_config_persistence.py +123 -0
  69. qplot/windows/_database_actions.py +4904 -0
  70. qplot/windows/_dataset_handle.py +221 -0
  71. qplot/windows/_dragdrop.py +71 -0
  72. qplot/windows/_export_paths.py +1167 -0
  73. qplot/windows/_help.py +169 -0
  74. qplot/windows/_native_averaging.py +173 -0
  75. qplot/windows/_native_transforms.py +389 -0
  76. qplot/windows/_plot1d_snap.py +608 -0
  77. qplot/windows/_plot1d_traces.py +2183 -0
  78. qplot/windows/_plot2d_colorbar.py +1126 -0
  79. qplot/windows/_plot2d_colorbar_dialog.py +786 -0
  80. qplot/windows/_plot2d_layers.py +2197 -0
  81. qplot/windows/_plot2d_sweeps.py +712 -0
  82. qplot/windows/_plotWin.py +1993 -0
  83. qplot/windows/_plot_actions.py +2695 -0
  84. qplot/windows/_plot_appearance.py +276 -0
  85. qplot/windows/_plot_axis_scaling.py +1485 -0
  86. qplot/windows/_plot_export.py +1445 -0
  87. qplot/windows/_plot_feedback.py +110 -0
  88. qplot/windows/_plot_marquee.py +656 -0
  89. qplot/windows/_plot_refresh.py +942 -0
  90. qplot/windows/_plot_state.py +239 -0
  91. qplot/windows/_plot_transform_labels.py +60 -0
  92. qplot/windows/_preferences.py +635 -0
  93. qplot/windows/_refresh_interval.py +70 -0
  94. qplot/windows/_run_controls.py +828 -0
  95. qplot/windows/_shortcuts.py +47 -0
  96. qplot/windows/_subplots/__init__.py +8 -0
  97. qplot/windows/_subplots/subplot1d.py +531 -0
  98. qplot/windows/_subplots/subplot2d.py +918 -0
  99. qplot/windows/_svg_export.py +209 -0
  100. qplot/windows/_trusted_derived_qt.py +1759 -0
  101. qplot/windows/_widgets/__init__.py +23 -0
  102. qplot/windows/_widgets/_run_formatting.py +387 -0
  103. qplot/windows/_widgets/details_tables.py +1119 -0
  104. qplot/windows/_widgets/dropbox.py +290 -0
  105. qplot/windows/_widgets/operations.py +460 -0
  106. qplot/windows/_widgets/preview.py +2997 -0
  107. qplot/windows/_widgets/run_list_items.py +488 -0
  108. qplot/windows/_widgets/toolbar.py +111 -0
  109. qplot/windows/_widgets/treeWidgets.py +2572 -0
  110. qplot/windows/_window_controls.py +413 -0
  111. qplot/windows/main.py +1635 -0
  112. qplot/windows/plot1d.py +311 -0
  113. qplot/windows/plot2d.py +1901 -0
  114. qplotter-1.6.0b3.dist-info/METADATA +318 -0
  115. qplotter-1.6.0b3.dist-info/RECORD +119 -0
  116. qplotter-1.6.0b3.dist-info/WHEEL +5 -0
  117. qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
  118. qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
  119. qplotter-1.6.0b3.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"]