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,4904 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+ import threading
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from time import monotonic, perf_counter
8
+
9
+ from PyQt6 import QtCore, QtGui
10
+ from PyQt6 import QtWidgets as qtw
11
+ from PyQt6.QtGui import QDesktopServices
12
+ from qcodes.dataset.sqlite.database import get_DB_location
13
+
14
+ from qplot.datahandling.database import (
15
+ DatabaseDetailWorker,
16
+ DatabaseExpensiveDetailWorker,
17
+ DatabaseInfoWorker,
18
+ DatabaseLoadWorker,
19
+ DatabaseRefreshWorker,
20
+ DatabaseSelectedRunWorker,
21
+ )
22
+ from qplot.datahandling.database import (
23
+ database_info_rows as database_info_rows,
24
+ )
25
+ from qplot.datahandling.file_identity import (
26
+ DatabaseInstance,
27
+ database_instance,
28
+ database_instances_differ,
29
+ database_publication_guard_path,
30
+ logical_database_path,
31
+ )
32
+ from qplot.datahandling.readonly import (
33
+ DatabaseInstanceChangedError,
34
+ UnverifiableDatabaseWalError,
35
+ quarantine_wal_for_replaced_database,
36
+ replacement_wal_is_quarantined,
37
+ set_qcodes_database_location,
38
+ )
39
+ from qplot.datahandling.trusted_live_service import (
40
+ SNAPSHOT_FALLBACK_MODE,
41
+ TRUSTED_LIVE_MODE,
42
+ )
43
+ from qplot.datahandling.trusted_presentation import bounded_presentation_error
44
+ from qplot.diagnostics import log_event, log_exception
45
+ from qplot.testdata import (
46
+ GenerationCancelled,
47
+ generate_database,
48
+ instruction_collection_contents,
49
+ read_specifications,
50
+ write_example_csv,
51
+ )
52
+
53
+ from ._config_persistence import persist_config_value
54
+ from ._dataset_handle import (
55
+ canonical_database_path,
56
+ database_file_identity,
57
+ )
58
+ from ._export_paths import (
59
+ choose_export_path,
60
+ prepare_export_destination,
61
+ write_export_atomically,
62
+ )
63
+ from ._widgets.details_tables import (
64
+ CopyableTableWidget,
65
+ copy_to_clipboard,
66
+ format_value,
67
+ )
68
+
69
+ _database_file_identity = database_file_identity
70
+
71
+
72
+ class _DatabaseLoadPublicationAborted(RuntimeError):
73
+ """A nested Qt event invalidated an in-progress UI publication."""
74
+
75
+
76
+ class _DatabaseRefreshPublicationAborted(RuntimeError):
77
+ """A nested Qt event invalidated an in-progress refresh publication."""
78
+
79
+
80
+ def _database_instances_differ(first, second):
81
+ """Return whether two available identities name different file instances."""
82
+ return first is not None and second is not None and first != second
83
+
84
+
85
+ def _database_paths_equal(first, second):
86
+ """Compare database paths using the platform's logical path spelling."""
87
+ if not first or not second:
88
+ return False
89
+ try:
90
+ return logical_database_path(first) == logical_database_path(second)
91
+ except (TypeError, ValueError, OSError):
92
+ return False
93
+
94
+
95
+ def _database_observations_differ(first, second):
96
+ """Compare saved observations, retaining compatibility with test harnesses."""
97
+
98
+ if isinstance(first, DatabaseInstance) and isinstance(second, DatabaseInstance):
99
+ return database_instances_differ(first, second)
100
+ first_identity = getattr(first, "identity", first)
101
+ second_identity = getattr(second, "identity", second)
102
+ return _database_instances_differ(first_identity, second_identity)
103
+
104
+
105
+ def _database_instances_or_sidecars_differ(first, second):
106
+ """Reject main/sidecar replacement while allowing later sidecar creation.
107
+
108
+ Stage 3 permits a writer or SQLite itself to create the exact WAL/SHM
109
+ sidecars between finite reader operations. Comparisons are directional:
110
+ ``first`` is the accepted observation and ``second`` is a later one. A
111
+ newly observed identity is therefore compatible, while removal or
112
+ replacement of any identity already bound to the accepted source is not.
113
+ """
114
+ if not isinstance(first, DatabaseInstance) or not isinstance(
115
+ second,
116
+ DatabaseInstance,
117
+ ):
118
+ return _database_observations_differ(first, second)
119
+ if database_instances_differ(first, second):
120
+ return True
121
+ return not first.sidecar_identities.issubset(second.sidecar_identities)
122
+
123
+
124
+ def _selected_run_detail_cache_key(instance, run_id, guid):
125
+ """Return a sidecar-aware immutable key for one plain detail view."""
126
+ if not isinstance(instance, DatabaseInstance):
127
+ return None
128
+ try:
129
+ run_id = int(run_id)
130
+ except (TypeError, ValueError, OverflowError):
131
+ return None
132
+ return (
133
+ instance.logical_path,
134
+ instance.resolved_path,
135
+ instance.identity,
136
+ frozenset(instance.sidecar_identities),
137
+ run_id,
138
+ str(guid),
139
+ )
140
+
141
+
142
+ def _selected_run_detail_is_stale(detail, metadata):
143
+ """Keep accepted live progress from regressing to an older observation."""
144
+ observed = detail.run.as_dict()
145
+ accepted_count = metadata.get("result_count")
146
+ observed_count = observed.get("result_count")
147
+ return (
148
+ accepted_count is not None
149
+ and (observed_count is None or observed_count < accepted_count)
150
+ ) or (
151
+ bool(metadata.get("is_completed"))
152
+ and not bool(observed.get("is_completed"))
153
+ )
154
+
155
+
156
+ def _database_publication_is_guarded(database_path):
157
+ """Fail closed if publication is in flight or its guard cannot be inspected."""
158
+ try:
159
+ database_publication_guard_path(database_path).lstat()
160
+ except FileNotFoundError:
161
+ return False
162
+ except OSError:
163
+ return True
164
+ return True
165
+
166
+
167
+ class DatabaseInfoDialog(qtw.QDialog):
168
+ """
169
+ Copyable table dialog for current database diagnostics.
170
+
171
+ """
172
+
173
+ def __init__(self, rows, parent=None):
174
+ super().__init__(parent)
175
+ self._rows = [(format_value(label), format_value(value)) for label, value in rows]
176
+ self.setObjectName("databaseInfoDialog")
177
+ self.setWindowTitle("Database Information")
178
+ self.setMinimumSize(520, 260)
179
+ self.resize(720, 360)
180
+
181
+ layout = qtw.QVBoxLayout(self)
182
+ layout.setContentsMargins(12, 12, 12, 12)
183
+ layout.setSpacing(10)
184
+
185
+ content_layout = qtw.QHBoxLayout()
186
+ content_layout.setSpacing(12)
187
+
188
+ icon_label = qtw.QLabel(self)
189
+ icon = self.style().standardIcon(qtw.QStyle.StandardPixmap.SP_MessageBoxInformation)
190
+ icon_label.setPixmap(icon.pixmap(32, 32))
191
+ icon_label.setAlignment(
192
+ QtCore.Qt.AlignmentFlag.AlignHCenter
193
+ | QtCore.Qt.AlignmentFlag.AlignTop
194
+ )
195
+ content_layout.addWidget(icon_label)
196
+
197
+ self.table = CopyableTableWidget(self)
198
+ self.table.setObjectName("databaseInfoTable")
199
+ self._setup_table()
200
+ content_layout.addWidget(self.table, 1)
201
+ layout.addLayout(content_layout, 1)
202
+
203
+ buttons = qtw.QDialogButtonBox(qtw.QDialogButtonBox.StandardButton.Close, self)
204
+ self.copyButton = buttons.addButton(
205
+ "Copy",
206
+ qtw.QDialogButtonBox.ButtonRole.ActionRole,
207
+ )
208
+ self.copyButton.setObjectName("databaseInfoCopyButton")
209
+ self.copyButton.clicked.connect(self.copyAll)
210
+ buttons.rejected.connect(self.reject)
211
+ layout.addWidget(buttons)
212
+
213
+
214
+ def _setup_table(self):
215
+ self.table.setColumnCount(2)
216
+ self.table.setHorizontalHeaderLabels(["Field", "Value"])
217
+ self.table.verticalHeader().hide()
218
+ self.table.verticalHeader().setMinimumSectionSize(16)
219
+ self.table.verticalHeader().setDefaultSectionSize(20)
220
+ self.table.horizontalHeader().setFixedHeight(22)
221
+ self.table.setAlternatingRowColors(True)
222
+ self.table.setEditTriggers(qtw.QAbstractItemView.EditTrigger.NoEditTriggers)
223
+ self.table.setSelectionBehavior(qtw.QAbstractItemView.SelectionBehavior.SelectRows)
224
+ self.table.setSelectionMode(qtw.QAbstractItemView.SelectionMode.ExtendedSelection)
225
+ self.table.setTextElideMode(QtCore.Qt.TextElideMode.ElideRight)
226
+ self.table.setWordWrap(False)
227
+ self.table.setRowCount(len(self._rows))
228
+
229
+ for row, (label, value) in enumerate(self._rows):
230
+ self.table.setItem(row, 0, self._table_item(label))
231
+ self.table.setItem(row, 1, self._table_item(value))
232
+
233
+ header = self.table.horizontalHeader()
234
+ header.setSectionResizeMode(0, qtw.QHeaderView.ResizeMode.ResizeToContents)
235
+ header.setSectionResizeMode(1, qtw.QHeaderView.ResizeMode.Stretch)
236
+ header.setStretchLastSection(False)
237
+ for row in range(self.table.rowCount()):
238
+ self.table.setRowHeight(row, 20)
239
+
240
+
241
+ def _table_item(self, value):
242
+ item = qtw.QTableWidgetItem(value)
243
+ item.setToolTip(value)
244
+ return item
245
+
246
+
247
+ def copyAll(self):
248
+ copy_to_clipboard(_database_info_rows_clipboard_text(self._rows))
249
+
250
+
251
+ def _database_info_rows_clipboard_text(rows):
252
+ return "\n".join("\t".join(row) for row in rows)
253
+
254
+
255
+ def reveal_file_in_file_manager(file_path):
256
+ """Reveal a generated file in Finder, or open its folder elsewhere."""
257
+ file_path = os.path.abspath(file_path)
258
+ if sys.platform == "darwin":
259
+ try:
260
+ result = subprocess.run(
261
+ ["open", "-R", file_path],
262
+ capture_output=True,
263
+ check=False,
264
+ timeout=5,
265
+ )
266
+ except (OSError, subprocess.TimeoutExpired):
267
+ return False
268
+ return result.returncode == 0
269
+
270
+ folder = os.path.dirname(file_path)
271
+ return QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(folder))
272
+
273
+
274
+ def _write_export_bytes(filename, content):
275
+ """Write one private staging file for the shared export transaction."""
276
+ with open(filename, "wb") as output_file:
277
+ output_file.write(content)
278
+ return True
279
+
280
+
281
+ class TestDatabaseGenerationSignals(QtCore.QObject):
282
+ """Signals emitted by background test-database generation."""
283
+
284
+ finished = QtCore.pyqtSignal(str, object, object, bool)
285
+
286
+
287
+ @dataclass(slots=True)
288
+ class _TestDatabaseReplacementState:
289
+ """Controller state retained while a loaded database may be replaced."""
290
+
291
+ database_path: str
292
+ original_instance: DatabaseInstance
293
+ selected_guid: str | None
294
+ selected_run_id: int | str | None
295
+ run_id_text: str
296
+ measurement_text: str
297
+ details_tab_index: int | None
298
+ monitor_was_active: bool
299
+ monitor_interval_ms: int
300
+ outcome: str | None = None
301
+
302
+
303
+ class TestDatabaseGenerationWorker(QtCore.QRunnable):
304
+ """Generate a test database without blocking the GUI thread."""
305
+
306
+ def __init__(self, specifications, database_path, overwrite=False):
307
+ super().__init__()
308
+ self.signals = TestDatabaseGenerationSignals()
309
+ self.specifications = list(specifications)
310
+ self.database_path = str(database_path)
311
+ self.overwrite = overwrite
312
+ self._cancelled = threading.Event()
313
+ self._publication_completed = threading.Event()
314
+
315
+ def cancel(self):
316
+ """Request cooperative cancellation and temporary-file cleanup."""
317
+ self._cancelled.set()
318
+
319
+ def run(self):
320
+ error = None
321
+ try:
322
+ generate_database(
323
+ self.specifications,
324
+ self.database_path,
325
+ overwrite=self.overwrite,
326
+ cancelled_callback=self._cancelled.is_set,
327
+ publication_callback=self._publication_completed.set,
328
+ )
329
+ except GenerationCancelled as err:
330
+ error = err
331
+ except Exception as err:
332
+ error = err
333
+ log_exception("Test database generation failed", err, __name__)
334
+
335
+ try:
336
+ self.signals.finished.emit(
337
+ self.database_path,
338
+ self.specifications,
339
+ error,
340
+ self._publication_completed.is_set(),
341
+ )
342
+ except RuntimeError as err:
343
+ if "wrapped C/C++ object" not in str(err):
344
+ raise
345
+
346
+
347
+ class DatabaseActionsMixin:
348
+ """
349
+ Database loading, refresh, recent-file, and database-status actions.
350
+
351
+ The mixin expects the owning window to provide the widgets and state created
352
+ by MainWindow, plus show_status(), show_error(), and openPlot().
353
+ """
354
+
355
+ _database_refresh_worker: DatabaseRefreshWorker | None
356
+ _database_refresh_instance: DatabaseInstance | None
357
+ _database_info_worker: DatabaseInfoWorker | None
358
+ _database_info_instance: DatabaseInstance | None
359
+ _test_database_generation_worker: TestDatabaseGenerationWorker | None
360
+
361
+ def _reap_retired_trusted_read_services(self):
362
+ """Zero-wait reap retired brokers and drive polling only while needed."""
363
+ retired = getattr(self, "_retired_trusted_read_services", None)
364
+ diagnostics = getattr(self, "_retired_service_reap_diagnostics", None)
365
+ if diagnostics is None:
366
+ diagnostics = {}
367
+ self._retired_service_reap_diagnostics = diagnostics
368
+ if retired is not None:
369
+ for service in tuple(retired):
370
+ if getattr(service, "_qplot_retained_by_plots", False):
371
+ if any(getattr(handle.dataset, "service", None) is service
372
+ for handle in getattr(self, "dataset_holder", {}).values()):
373
+ continue
374
+ service._qplot_retained_by_plots = False
375
+ service.close_async()
376
+ wait_closed = getattr(service, "wait_closed", None)
377
+ if not callable(wait_closed):
378
+ continue
379
+ try:
380
+ closed = wait_closed(0)
381
+ except BaseException as error:
382
+ diagnostics[id(service)] = (
383
+ "retired service zero-wait reap raised "
384
+ f"{type(error).__name__}: {error}"
385
+ )
386
+ continue
387
+ diagnostics.pop(id(service), None)
388
+ if not closed:
389
+ continue
390
+ retired.discard(service)
391
+ reaper_timer = getattr(self, "_retired_service_reaper_timer", None)
392
+ if reaper_timer is None:
393
+ return
394
+ if retired:
395
+ is_active = getattr(reaper_timer, "isActive", None)
396
+ if not callable(is_active) or not is_active():
397
+ reaper_timer.start()
398
+ else:
399
+ reaper_timer.stop()
400
+
401
+
402
+ def _retire_trusted_read_service(self, service, *, force=False):
403
+ """Begin prompt off-GUI shutdown while retaining the service object."""
404
+ if service is None:
405
+ return
406
+ if (
407
+ not force
408
+ and service is getattr(self, "_trusted_read_service", None)
409
+ ):
410
+ return
411
+ retired = getattr(self, "_retired_trusted_read_services", None)
412
+ if retired is None:
413
+ retired = set()
414
+ self._retired_trusted_read_services = retired
415
+ already_retiring = service in retired
416
+ retired.add(service)
417
+ keep_plots = not force and any(
418
+ getattr(handle.dataset, "service", None) is service
419
+ for handle in getattr(self, "dataset_holder", {}).values()
420
+ )
421
+ if keep_plots:
422
+ service._qplot_retained_by_plots = True
423
+ elif (not already_retiring
424
+ or getattr(service, "_qplot_retained_by_plots", False)):
425
+ service._qplot_retained_by_plots = False
426
+ service.close_async()
427
+ DatabaseActionsMixin._reap_retired_trusted_read_services(self)
428
+
429
+
430
+ def _retire_all_trusted_read_services(self):
431
+ """Invalidate active and pending sessions without blocking Qt."""
432
+ active = getattr(self, "_trusted_read_service", None)
433
+ self._trusted_read_service = None
434
+ pending = getattr(self, "_pending_trusted_read_services", {})
435
+ self._pending_trusted_read_services = {}
436
+ for service in {active, *pending.values()}:
437
+ if service is not None:
438
+ DatabaseActionsMixin._retire_trusted_read_service(
439
+ self,
440
+ service,
441
+ force=True,
442
+ )
443
+ self._database_access_mode = None
444
+ self._database_fallback_reason = None
445
+
446
+
447
+ def _active_trusted_service_for_instance(self, instance=None):
448
+ if getattr(self, "_database_access_mode", None) != TRUSTED_LIVE_MODE:
449
+ return None
450
+ service = getattr(self, "_trusted_read_service", None)
451
+ if (
452
+ service is None
453
+ or getattr(service, "closing", False)
454
+ or getattr(service, "closed", False)
455
+ ):
456
+ return None
457
+ if instance is None:
458
+ instance = getattr(self, "_loaded_database_instance", None)
459
+ if not isinstance(instance, DatabaseInstance):
460
+ return None
461
+ try:
462
+ service_instance = service.database_instance
463
+ except Exception:
464
+ return None
465
+ # Stage 3 reports its accepted source before SQLite opens it. The exact
466
+ # permitted ``-shm`` may therefore appear after this observation, so a
467
+ # service-to-UI comparison can bind only the main database instance.
468
+ if database_instances_differ(service_instance, instance):
469
+ return None
470
+ return service
471
+
472
+
473
+ def _advance_loaded_database_sidecar_baseline(self, current_instance):
474
+ """Retain compatible sidecars so a later swap cannot look like creation.
475
+
476
+ The first WAL/SHM observation after acceptance is allowed, but it must
477
+ immediately become part of the controller's accepted baseline. Every
478
+ later guard can then distinguish another legitimate addition from the
479
+ removal or replacement of a sidecar already observed for this view.
480
+ """
481
+ loaded_instance = getattr(self, "_loaded_database_instance", None)
482
+ if not isinstance(loaded_instance, DatabaseInstance) or not isinstance(
483
+ current_instance,
484
+ DatabaseInstance,
485
+ ):
486
+ return False
487
+ if _database_instances_or_sidecars_differ(
488
+ loaded_instance,
489
+ current_instance,
490
+ ):
491
+ return False
492
+ if loaded_instance.sidecar_identities != current_instance.sidecar_identities:
493
+ self._loaded_database_instance = current_instance
494
+ self._loaded_database_identity = current_instance.identity
495
+ return True
496
+
497
+
498
+ def _cancel_selected_run_detail(self):
499
+ worker = getattr(self, "_database_selected_run_worker", None)
500
+ if worker is not None:
501
+ worker.cancel()
502
+ self._database_selected_run_generation = (
503
+ getattr(self, "_database_selected_run_generation", 0) + 1
504
+ )
505
+ self._database_selected_run_worker = None
506
+ self._database_selected_run_instance = None
507
+ self._database_selected_run_mode = None
508
+
509
+ def _database_generation_transaction_blocks_path(self, database_path=None):
510
+ """Return whether a publication transaction blocks database actions.
511
+
512
+ A database-load publication is path-independent while its nested Qt
513
+ event yields stage a new run model. The test-database transaction
514
+ remains owned while the released view is being recovered, and while an
515
+ ambiguous publication guard prevents safe recovery. Comparing both
516
+ the selected logical path and the accepted resolved source keeps aliases
517
+ of the owned database behind the same gate without touching SQLite.
518
+ """
519
+ if (
520
+ getattr(self, "_database_load_publication_active", False)
521
+ or getattr(self, "_database_refresh_publication_active", False)
522
+ ):
523
+ return True
524
+ state = getattr(self, "_test_database_replacement_state", None)
525
+ if state is None:
526
+ return False
527
+
528
+ if database_path is None:
529
+ file_textbox = getattr(self, "fileTextbox", None)
530
+ database_path = file_textbox.text() if file_textbox is not None else ""
531
+ if not database_path:
532
+ return False
533
+
534
+ owned_path = getattr(state, "database_path", "")
535
+ if not owned_path:
536
+ return False
537
+ if logical_database_path(database_path) == logical_database_path(owned_path):
538
+ return True
539
+
540
+ candidate_resolved = canonical_database_path(database_path)
541
+ original_instance = getattr(state, "original_instance", None)
542
+ original_resolved = getattr(original_instance, "resolved_path", None)
543
+ return bool(
544
+ candidate_resolved == canonical_database_path(owned_path)
545
+ or (
546
+ original_resolved
547
+ and candidate_resolved == canonical_database_path(original_resolved)
548
+ )
549
+ )
550
+
551
+
552
+ def _database_generation_read_allowed(
553
+ self,
554
+ database_path=None,
555
+ *,
556
+ operation="accessing the database",
557
+ notify=True,
558
+ ):
559
+ """Central gate for actions that could acquire a database consumer."""
560
+ blocked = DatabaseActionsMixin._database_generation_transaction_blocks_path(
561
+ self,
562
+ database_path,
563
+ )
564
+ if blocked and notify:
565
+ if (
566
+ getattr(self, "_database_load_publication_active", False)
567
+ or getattr(self, "_database_refresh_publication_active", False)
568
+ ):
569
+ message = (
570
+ "Wait for the current database view to finish loading before "
571
+ f"{operation}."
572
+ )
573
+ else:
574
+ message = (
575
+ "Wait for test-database generation to finish before "
576
+ f"{operation}."
577
+ )
578
+ self.show_status(message, 5000)
579
+ return not blocked
580
+
581
+
582
+ def _sync_database_generation_controls(self):
583
+ """Keep read-producing controls aligned with the path transaction."""
584
+ blocked = DatabaseActionsMixin._database_generation_transaction_blocks_path(self)
585
+ load_active = bool(getattr(self, "_database_load_active", False))
586
+ enabled = not blocked and not load_active
587
+ for attr in (
588
+ "refreshDatabaseButton",
589
+ "databaseInfoButton",
590
+ "emptyStateRefreshButton",
591
+ "plotRunButton",
592
+ "exportCsvButton",
593
+ "run_idBox",
594
+ "measurementBox",
595
+ "RunList",
596
+ "infoBox",
597
+ "autoPlotBox",
598
+ ):
599
+ widget = getattr(self, attr, None)
600
+ set_enabled = getattr(widget, "setEnabled", None)
601
+ if callable(set_enabled):
602
+ set_enabled(enabled)
603
+
604
+ generation_action = getattr(self, "generateTestDatabaseAction", None)
605
+ set_generation_enabled = getattr(generation_action, "setEnabled", None)
606
+ if callable(set_generation_enabled):
607
+ set_generation_enabled(
608
+ not blocked
609
+ and not getattr(self, "_test_database_generation_active", False)
610
+ and getattr(self, "_test_database_replacement_state", None) is None
611
+ and not load_active
612
+ )
613
+
614
+
615
+ def _clear_test_database_generation_transaction(self):
616
+ """Release the path gate after recovery or a newer view takes over."""
617
+ self._test_database_replacement_state = None
618
+ self._database_view_released_for_generation = False
619
+ DatabaseActionsMixin._sync_database_generation_controls(self)
620
+
621
+
622
+ def _pending_database_load_targets_another_path(self, database_path):
623
+ if not getattr(self, "_database_load_active", False):
624
+ return False
625
+ state = getattr(self, "_database_load_state", None) or {}
626
+ pending_path = state.get("abspath")
627
+ return bool(
628
+ pending_path
629
+ and logical_database_path(pending_path)
630
+ != logical_database_path(database_path)
631
+ )
632
+
633
+
634
+ def _resume_test_database_generation_recovery(self):
635
+ """Resume a deferred terminal recovery after an unrelated load ends."""
636
+ state = getattr(self, "_test_database_replacement_state", None)
637
+ if state is None or getattr(self, "_test_database_generation_active", False):
638
+ return False
639
+ database_path = getattr(state, "database_path", "")
640
+ if not database_path or not (
641
+ DatabaseActionsMixin._database_generation_transaction_blocks_path(
642
+ self,
643
+ database_path,
644
+ )
645
+ ):
646
+ return False
647
+
648
+ file_textbox = getattr(self, "fileTextbox", None)
649
+ current_path = file_textbox.text() if file_textbox is not None else ""
650
+ if (
651
+ current_path
652
+ and not (
653
+ DatabaseActionsMixin._database_generation_transaction_blocks_path(
654
+ self,
655
+ current_path,
656
+ )
657
+ )
658
+ ):
659
+ DatabaseActionsMixin._clear_test_database_generation_transaction(self)
660
+ return False
661
+ if DatabaseActionsMixin._pending_database_load_targets_another_path(
662
+ self,
663
+ database_path,
664
+ ):
665
+ return False
666
+
667
+ outcome = getattr(state, "outcome", None)
668
+ if outcome == "replacement":
669
+ return self._reload_replaced_database(
670
+ database_path,
671
+ generation_recovery=True,
672
+ )
673
+ if outcome == "recovering-original":
674
+ return self.load_file(
675
+ database_path,
676
+ force=True,
677
+ generation_recovery=True,
678
+ )
679
+ return False
680
+
681
+ def load_startup_database(self):
682
+ """
683
+ Load the highest-priority available startup database.
684
+
685
+ An explicit path is always attempted so invalid command-line paths keep
686
+ their visible error. Missing saved paths fall back to QCoDeS' current
687
+ database when it exists.
688
+
689
+ """
690
+ startup_database_path = getattr(self, "startup_database_path", None)
691
+ if startup_database_path:
692
+ return self.load_database_path(startup_database_path)
693
+
694
+ recent_files = self.config.get("file.recent_file_paths")
695
+ if recent_files:
696
+ recent_file = os.path.abspath(recent_files[0])
697
+ if os.path.isfile(recent_file):
698
+ return self.load_database_path(recent_file)
699
+
700
+ qcodes_database = get_DB_location()
701
+ if qcodes_database and os.path.isfile(qcodes_database):
702
+ return self.load_database_path(qcodes_database)
703
+
704
+ return False
705
+
706
+
707
+ @QtCore.pyqtSlot()
708
+ def open_database_location(self):
709
+ """
710
+ Opens the current database folder in the system file browser.
711
+
712
+ """
713
+ database_path = self.fileTextbox.text()
714
+ if not database_path:
715
+ self.show_status("No database is loaded.", 5000)
716
+ return
717
+
718
+ folder = os.path.dirname(database_path)
719
+ if not os.path.isdir(folder):
720
+ self.show_error(
721
+ "Database Location Not Found",
722
+ "The current database folder could not be found.",
723
+ database_path,
724
+ )
725
+ return
726
+
727
+ opened = QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(folder))
728
+ if opened:
729
+ self.show_status(f"Opened database folder: {folder}", 5000)
730
+ else:
731
+ self.show_error(
732
+ "Open Folder Failed",
733
+ "The database folder could not be opened.",
734
+ folder,
735
+ )
736
+
737
+
738
+ @QtCore.pyqtSlot()
739
+ def create_test_database_csv(self):
740
+ """Create an example test-data CSV and open its folder."""
741
+ suggested_path = os.path.join(
742
+ self.database_open_directory(),
743
+ "qplot-test-runs.csv",
744
+ )
745
+ try:
746
+ destination = choose_export_path(
747
+ self,
748
+ caption="Create Test Database CSV",
749
+ suggested_path=suggested_path,
750
+ name_filter="CSV Files (*.csv)",
751
+ required_suffix=".csv",
752
+ replace_title="Replace Example CSV?",
753
+ file_description="example CSV file",
754
+ )
755
+ if destination is None:
756
+ self.show_status("Example CSV creation cancelled.", 3000)
757
+ return False
758
+ write_export_atomically(
759
+ destination,
760
+ lambda temporary: write_example_csv(temporary, overwrite=True),
761
+ )
762
+ except Exception as err:
763
+ log_exception("Example test-data CSV creation failed", err, __name__)
764
+ self.show_error(
765
+ "Example CSV Creation Failed",
766
+ "Could not create the example test-database CSV.",
767
+ str(err),
768
+ )
769
+ return False
770
+
771
+ csv_path = destination.filename
772
+ opened = reveal_file_in_file_manager(csv_path)
773
+ if not opened:
774
+ self.show_error(
775
+ "Open CSV Folder Failed",
776
+ "The example CSV was created, but its folder could not be opened.",
777
+ csv_path,
778
+ )
779
+ return True
780
+
781
+ self.show_status(f"Created example CSV and opened its folder: {csv_path}", 5000)
782
+ return True
783
+
784
+
785
+ @QtCore.pyqtSlot()
786
+ def export_test_database_csv_collection(self):
787
+ """Export the installed cumulative CSV collection and open its folder."""
788
+ directory = qtw.QFileDialog.getExistingDirectory(
789
+ self,
790
+ "Export Test Database CSV Collection",
791
+ self.database_open_directory(),
792
+ )
793
+ if not directory:
794
+ self.show_status("CSV collection export cancelled.", 3000)
795
+ return False
796
+
797
+ directory = os.path.abspath(directory)
798
+ try:
799
+ collection = instruction_collection_contents()
800
+ destinations = tuple(
801
+ prepare_export_destination(
802
+ self,
803
+ os.path.join(directory, filename),
804
+ )
805
+ for filename, _content in collection
806
+ )
807
+ for destination, (_filename, content) in zip(
808
+ destinations,
809
+ collection,
810
+ strict=True,
811
+ ):
812
+ write_export_atomically(
813
+ destination,
814
+ lambda temporary, data=content: (
815
+ _write_export_bytes(temporary, data)
816
+ ),
817
+ )
818
+ output_paths = tuple(
819
+ Path(destination.filename) for destination in destinations
820
+ )
821
+ except Exception as err:
822
+ log_exception("Test-data CSV collection export failed", err, __name__)
823
+ self.show_error(
824
+ "CSV Collection Export Failed",
825
+ "Could not export the test-database CSV collection.",
826
+ str(err),
827
+ )
828
+ return False
829
+
830
+ opened = reveal_file_in_file_manager(output_paths[0])
831
+ if not opened:
832
+ self.show_error(
833
+ "Open CSV Folder Failed",
834
+ "The CSV collection was exported, but its folder could not be opened.",
835
+ directory,
836
+ )
837
+ return True
838
+
839
+ self.show_status(
840
+ f"Exported {len(output_paths)} instruction CSV files and opened their "
841
+ f"folder: {directory}",
842
+ 5000,
843
+ )
844
+ return True
845
+
846
+
847
+ @QtCore.pyqtSlot()
848
+ def generate_test_database_from_csv(self):
849
+ """Choose a CSV and generate its QCoDeS database in the background."""
850
+ if getattr(self, "_database_load_publication_active", False):
851
+ self.show_status(
852
+ "Wait for the current database view to finish loading before "
853
+ "generating a test database.",
854
+ 5000,
855
+ )
856
+ return False
857
+ if getattr(self, "_test_database_generation_active", False):
858
+ self.show_status("A test database is already being generated.", 5000)
859
+ return False
860
+ if (
861
+ getattr(self, "_database_load_active", False)
862
+ or getattr(self, "_database_view_released_for_generation", False)
863
+ ):
864
+ self.show_status(
865
+ "Wait for the current database recovery to finish.",
866
+ 5000,
867
+ )
868
+ return False
869
+
870
+ csv_path = qtw.QFileDialog.getOpenFileName(
871
+ self,
872
+ "Select Test Database CSV",
873
+ self.database_open_directory(),
874
+ "CSV Files (*.csv)",
875
+ )[0]
876
+ if not csv_path:
877
+ self.show_status("Test database generation cancelled.", 3000)
878
+ return False
879
+
880
+ try:
881
+ specifications = read_specifications(csv_path)
882
+ except Exception as err:
883
+ log_exception("Test-data CSV validation failed", err, __name__)
884
+ self.show_error(
885
+ "Invalid Test Database CSV",
886
+ "The selected CSV could not be used to generate a database.",
887
+ str(err),
888
+ )
889
+ return False
890
+
891
+ suggested_path = str(os.path.splitext(os.path.abspath(csv_path))[0] + ".db")
892
+ database_path = qtw.QFileDialog.getSaveFileName(
893
+ self,
894
+ "Save Test Database",
895
+ suggested_path,
896
+ "QCoDeS Database (*.db)",
897
+ options=qtw.QFileDialog.Option.DontConfirmOverwrite,
898
+ )[0]
899
+ if not database_path:
900
+ self.show_status("Test database generation cancelled.", 3000)
901
+ return False
902
+ if not database_path.lower().endswith(".db"):
903
+ database_path += ".db"
904
+ database_path = os.path.abspath(database_path)
905
+ overwrite = os.path.exists(database_path)
906
+ if overwrite:
907
+ reply = qtw.QMessageBox.question(
908
+ self,
909
+ "Replace Test Database?",
910
+ f"{database_path} already exists.\n\nReplace it with the "
911
+ "generated test database?",
912
+ qtw.QMessageBox.StandardButton.Yes
913
+ | qtw.QMessageBox.StandardButton.No,
914
+ qtw.QMessageBox.StandardButton.No,
915
+ )
916
+ if reply != qtw.QMessageBox.StandardButton.Yes:
917
+ self.show_status("Test database generation cancelled.", 3000)
918
+ return False
919
+
920
+ file_textbox = getattr(self, "fileTextbox", None)
921
+ current_database = file_textbox.text() if file_textbox is not None else ""
922
+ if (
923
+ current_database
924
+ and canonical_database_path(current_database)
925
+ == canonical_database_path(database_path)
926
+ ):
927
+ # Capture the accepted source before releasing its handles. The
928
+ # worker stages elsewhere, so this is only a reversible release:
929
+ # the terminal callback alone may confirm replacement/quarantine.
930
+ prepare_replacement = getattr(
931
+ self,
932
+ "_prepare_test_database_replacement",
933
+ None,
934
+ )
935
+ if callable(prepare_replacement):
936
+ prepare_replacement(database_path)
937
+
938
+ self._test_database_generation_active = True
939
+ DatabaseActionsMixin._sync_database_generation_controls(self)
940
+ worker = TestDatabaseGenerationWorker(
941
+ specifications,
942
+ database_path,
943
+ overwrite=overwrite,
944
+ )
945
+ self._test_database_generation_worker = worker
946
+ worker.signals.finished.connect(self.test_database_generation_finished)
947
+ self.show_status(
948
+ f"Generating test database {os.path.basename(database_path)}...",
949
+ 0,
950
+ )
951
+ self.testDatabaseGenerationThreadPool.start(worker)
952
+ return True
953
+
954
+
955
+ @QtCore.pyqtSlot(str, object, object, bool)
956
+ def test_database_generation_finished(
957
+ self,
958
+ database_path,
959
+ specifications,
960
+ error,
961
+ publication_completed=False,
962
+ ):
963
+ """Apply the result of background test-database generation."""
964
+ self._test_database_generation_active = False
965
+ self._test_database_generation_worker = None
966
+
967
+ shutting_down = bool(
968
+ getattr(self, "_shutdown_started", False)
969
+ or getattr(self, "_shutdown_ready", False)
970
+ )
971
+ if shutting_down:
972
+ DatabaseActionsMixin._clear_test_database_generation_transaction(self)
973
+ return
974
+
975
+ cancelled = isinstance(error, GenerationCancelled)
976
+ if cancelled:
977
+ self.show_status("Test database generation cancelled.", 3000)
978
+ elif error is not None:
979
+ self.show_error(
980
+ "Test Database Generation Failed",
981
+ "Could not generate the test database.",
982
+ str(error),
983
+ )
984
+
985
+ if error is None:
986
+ run_count = len(specifications)
987
+ point_count = sum(
988
+ specification.point_count for specification in specifications
989
+ )
990
+ run_word = "run" if run_count == 1 else "runs"
991
+ self.show_status(
992
+ f"Generated {os.path.basename(database_path)} with {run_count} "
993
+ f"{run_word} and {point_count} points.",
994
+ 7000,
995
+ )
996
+
997
+ replacement_state = getattr(
998
+ self,
999
+ "_test_database_replacement_state",
1000
+ None,
1001
+ )
1002
+ state_matches = bool(
1003
+ replacement_state is not None
1004
+ and logical_database_path(replacement_state.database_path)
1005
+ == logical_database_path(database_path)
1006
+ )
1007
+ file_textbox = getattr(self, "fileTextbox", None)
1008
+ current_database = file_textbox.text() if file_textbox is not None else ""
1009
+ current_matches = bool(
1010
+ current_database
1011
+ and (
1012
+ DatabaseActionsMixin._database_generation_transaction_blocks_path(
1013
+ self,
1014
+ current_database,
1015
+ )
1016
+ if state_matches
1017
+ else canonical_database_path(current_database)
1018
+ == canonical_database_path(database_path)
1019
+ )
1020
+ )
1021
+ pending_unrelated_load = (
1022
+ DatabaseActionsMixin._pending_database_load_targets_another_path(
1023
+ self,
1024
+ database_path,
1025
+ )
1026
+ )
1027
+ if not state_matches and not current_matches:
1028
+ DatabaseActionsMixin._sync_database_generation_controls(self)
1029
+ return
1030
+
1031
+ instance_changed = False
1032
+ if state_matches:
1033
+ current_instance = database_instance(database_path)
1034
+ instance_changed = _database_observations_differ(
1035
+ replacement_state.original_instance,
1036
+ current_instance,
1037
+ )
1038
+
1039
+ replacement_confirmed = bool(
1040
+ error is None or publication_completed or instance_changed
1041
+ )
1042
+ guarded = _database_publication_is_guarded(database_path)
1043
+ if replacement_confirmed:
1044
+ # This transition is intentionally the first quarantine point.
1045
+ # It covers success as well as an exception raised after commit.
1046
+ quarantine_wal_for_replaced_database(database_path)
1047
+ if state_matches:
1048
+ replacement_state.outcome = "replacement"
1049
+ if guarded:
1050
+ if state_matches:
1051
+ replacement_state.outcome = "ambiguous"
1052
+ DatabaseActionsMixin._sync_database_generation_controls(self)
1053
+ return
1054
+ if state_matches and (not current_matches or pending_unrelated_load):
1055
+ if not current_matches and not pending_unrelated_load:
1056
+ DatabaseActionsMixin._clear_test_database_generation_transaction(
1057
+ self
1058
+ )
1059
+ else:
1060
+ DatabaseActionsMixin._sync_database_generation_controls(self)
1061
+ return
1062
+ if current_matches:
1063
+ if state_matches:
1064
+ self._reload_replaced_database(
1065
+ database_path,
1066
+ generation_recovery=True,
1067
+ )
1068
+ else:
1069
+ self._reload_replaced_database(database_path)
1070
+ else:
1071
+ DatabaseActionsMixin._sync_database_generation_controls(self)
1072
+ return
1073
+
1074
+ if guarded:
1075
+ # The publisher retained a guard because it could not prove a safe
1076
+ # rollback. Keep the released view invalid and do not guess which
1077
+ # main any sidecar belongs to.
1078
+ if state_matches:
1079
+ replacement_state.outcome = "ambiguous"
1080
+ DatabaseActionsMixin._sync_database_generation_controls(self)
1081
+ return
1082
+
1083
+ if state_matches:
1084
+ replacement_state.outcome = "recovering-original"
1085
+ if not current_matches and not pending_unrelated_load:
1086
+ DatabaseActionsMixin._clear_test_database_generation_transaction(self)
1087
+ return
1088
+ if pending_unrelated_load:
1089
+ DatabaseActionsMixin._sync_database_generation_controls(self)
1090
+ return
1091
+ # A rejected/cancelled publication left the accepted instance in
1092
+ # place. Force bypasses the ordinary same-file shortcut while the
1093
+ # released controller session is rebuilt from its read-only view.
1094
+ if state_matches:
1095
+ self.load_file(
1096
+ database_path,
1097
+ force=True,
1098
+ generation_recovery=True,
1099
+ )
1100
+ else:
1101
+ self.load_file(database_path, force=True)
1102
+
1103
+
1104
+ @QtCore.pyqtSlot()
1105
+ def copy_database_path(self):
1106
+ """
1107
+ Copies the full current database path to the clipboard.
1108
+
1109
+ """
1110
+ database_path = self.fileTextbox.text()
1111
+ if not database_path:
1112
+ self.show_status("No database path to copy.", 3000)
1113
+ return
1114
+
1115
+ qtw.QApplication.clipboard().setText(database_path)
1116
+ self.show_status("Copied database path.", 3000)
1117
+
1118
+
1119
+ def _clear_snapshot_setpoint_summary_cache(self):
1120
+ """Discard summaries captured from the previously accepted source."""
1121
+
1122
+ cache = getattr(self, "_snapshot_setpoint_summary_cache", None)
1123
+ if isinstance(cache, dict):
1124
+ cache.clear()
1125
+
1126
+
1127
+ def close_database(self, status=True):
1128
+ """
1129
+ Clears the current database from the main window state.
1130
+
1131
+ """
1132
+ worker = getattr(self, "_database_load_worker", None)
1133
+ if worker is not None:
1134
+ worker.cancel()
1135
+ derived_bridge = getattr(self, "_trusted_derived_bridge", None)
1136
+ if derived_bridge is not None:
1137
+ derived_bridge.clear_database()
1138
+ cancel_detail_load = getattr(self, "_cancel_database_detail_load", None)
1139
+ if callable(cancel_detail_load):
1140
+ cancel_detail_load()
1141
+ DatabaseActionsMixin._cancel_database_refresh(self)
1142
+ DatabaseActionsMixin._cancel_selected_run_detail(self)
1143
+ DatabaseActionsMixin._cancel_database_info(self)
1144
+ DatabaseActionsMixin._retire_all_trusted_read_services(self)
1145
+
1146
+ self._database_load_generation = getattr(self, "_database_load_generation", 0) + 1
1147
+ self._database_load_active = False
1148
+ self._database_load_state = None
1149
+ self._database_load_worker = None
1150
+ self._loaded_database_identity = None
1151
+ self._loaded_database_instance = None
1152
+ if getattr(self, "_test_database_replacement_state", None) is None:
1153
+ self._database_view_released_for_generation = False
1154
+ if hasattr(self, "_set_database_load_controls_enabled"):
1155
+ self._set_database_load_controls_enabled(True)
1156
+ if hasattr(self, "_hide_database_load_panel"):
1157
+ self._hide_database_load_panel()
1158
+
1159
+ apply_refresh_interval = getattr(self, "_apply_refresh_interval", None)
1160
+ if callable(apply_refresh_interval):
1161
+ apply_refresh_interval(self._main_refresh_interval())
1162
+ else:
1163
+ self.monitor.stop()
1164
+ self.fileTextbox.setText("")
1165
+ self.run_idBox.setText("")
1166
+ self.measurementBox.setText("*")
1167
+ self.selected_run_id = None
1168
+ self._selected_run_guid = None
1169
+ self._selected_run_detail_cache = {}
1170
+ self._selected_run_partial_detail_keys = set()
1171
+ release_selected = getattr(self, "_release_selected_dataset", None)
1172
+ if callable(release_selected):
1173
+ release_selected()
1174
+ else:
1175
+ self.ds = None
1176
+ self._selected_dataset_key = None
1177
+ self.localLastFile = None
1178
+
1179
+ close_handles = getattr(self, "_close_all_dataset_handles", None)
1180
+ if callable(close_handles):
1181
+ close_handles()
1182
+ else:
1183
+ for holder in self.dataset_holder.values():
1184
+ holder.cancel_delete_timer()
1185
+ self.dataset_holder.clear()
1186
+
1187
+ self.RunList.blockSignals(True)
1188
+ self.RunList.clearSelection()
1189
+ self.RunList.clear()
1190
+ self.RunList.watching = []
1191
+ self.RunList.maxRunId = 0
1192
+ self.RunList.blockSignals(False)
1193
+ self.RunList.scrollToTop()
1194
+
1195
+ self.infoBox.clear()
1196
+ clear_summary_cache = getattr(
1197
+ self,
1198
+ "_clear_snapshot_setpoint_summary_cache",
1199
+ None,
1200
+ )
1201
+ if callable(clear_summary_cache):
1202
+ clear_summary_cache()
1203
+ clear_database_cache = getattr(self.infoBox, "clear_database_cache", None)
1204
+ if callable(clear_database_cache):
1205
+ clear_database_cache()
1206
+ self.infoBox.preview.set_database_runs("", {})
1207
+ self.infoBox.scrollToTop()
1208
+ DatabaseActionsMixin._sync_database_generation_controls(self)
1209
+ self._sync_empty_state()
1210
+
1211
+ if status:
1212
+ self.show_status("Database closed.", 3000)
1213
+
1214
+
1215
+ @QtCore.pyqtSlot()
1216
+ def show_database_info(self):
1217
+ """
1218
+ Shows a diagnostic report for the current database.
1219
+
1220
+ """
1221
+ database_path = self.fileTextbox.text()
1222
+ if not database_path:
1223
+ self.show_status("No database is loaded.", 5000)
1224
+ return
1225
+ if getattr(self, "_database_load_active", False):
1226
+ self.show_status("Wait for database loading to finish before reading information.", 5000)
1227
+ return
1228
+ if not DatabaseActionsMixin._database_generation_read_allowed(
1229
+ self,
1230
+ database_path,
1231
+ operation="showing database information",
1232
+ ):
1233
+ return
1234
+ if DatabaseActionsMixin._reload_if_database_instance_changed(
1235
+ self,
1236
+ database_path,
1237
+ ):
1238
+ return
1239
+
1240
+ if getattr(self, "_database_info_active", False):
1241
+ self.show_status("Database information is already loading.", 3000)
1242
+ return
1243
+
1244
+ instance = getattr(self, "_loaded_database_instance", None)
1245
+ trusted_service = DatabaseActionsMixin._active_trusted_service_for_instance(
1246
+ self, instance,
1247
+ )
1248
+ if (
1249
+ getattr(self, "_database_access_mode", None) == TRUSTED_LIVE_MODE
1250
+ and trusted_service is None
1251
+ ):
1252
+ self.show_error(
1253
+ "Database Information Failed",
1254
+ "Could not read database information.",
1255
+ "The trusted reader session is unavailable. Reload the database.",
1256
+ )
1257
+ return
1258
+
1259
+ self._database_info_generation = getattr(self, "_database_info_generation", 0) + 1
1260
+ worker = DatabaseInfoWorker(
1261
+ self._database_info_generation,
1262
+ database_path,
1263
+ expected_database_instance=instance,
1264
+ deadline=monotonic() + 10.0,
1265
+ trusted_service=trusted_service,
1266
+ )
1267
+ self._database_info_worker = worker
1268
+ self._database_info_instance = worker.database_instance
1269
+ self._database_info_active = True
1270
+ worker.signals.finished.connect(self.database_info_finished)
1271
+ self.show_status("Reading database information...", 0)
1272
+ self.databaseDetailThreadPool.start(worker)
1273
+
1274
+
1275
+ def _cancel_database_info(self):
1276
+ worker = getattr(self, "_database_info_worker", None)
1277
+ if worker is not None:
1278
+ worker.cancel()
1279
+ self._database_info_generation = getattr(self, "_database_info_generation", 0) + 1
1280
+ self._database_info_worker = None
1281
+ self._database_info_instance = None
1282
+ self._database_info_active = False
1283
+
1284
+
1285
+ @QtCore.pyqtSlot(int, str, object, object)
1286
+ def database_info_finished(self, generation, database_path, rows, error):
1287
+ if generation != getattr(self, "_database_info_generation", 0):
1288
+ return
1289
+ instance = getattr(self, "_database_info_instance", None)
1290
+ self._database_info_worker = None
1291
+ self._database_info_instance = None
1292
+ self._database_info_active = False
1293
+ if (
1294
+ not _database_paths_equal(database_path, self.fileTextbox.text())
1295
+ or getattr(self, "_database_load_active", False)
1296
+ or getattr(self, "_shutdown_started", False)
1297
+ or getattr(self, "_shutdown_ready", False)
1298
+ or DatabaseActionsMixin._reload_if_worker_database_instance_changed(
1299
+ self, database_path, instance,
1300
+ )
1301
+ ):
1302
+ return
1303
+ if error is not None:
1304
+ log_exception("Database information failed", error, __name__)
1305
+ self.show_error(
1306
+ "Database Information Failed",
1307
+ "Could not read database information.",
1308
+ str(error),
1309
+ )
1310
+ return
1311
+
1312
+ self.show_status("Database information loaded.", 3000)
1313
+ dialog = DatabaseInfoDialog(rows, parent=self)
1314
+ dialog.copyButton.clicked.connect(
1315
+ lambda: self.show_status("Copied database information.", 3000)
1316
+ )
1317
+ dialog.exec()
1318
+
1319
+
1320
+ @QtCore.pyqtSlot()
1321
+ def refreshMain(self, automatic=False):
1322
+ """
1323
+ On self.monitor timer or force refresh, check for new runs in Database
1324
+
1325
+ """
1326
+ if automatic and getattr(self, "_loaded_database_instance", None) is None:
1327
+ # The automatic path is silent: its lifecycle guard has already
1328
+ # invalidated this timer, so do not turn a stale queued timeout
1329
+ # into repeated "Load a database" status messages.
1330
+ apply_refresh_interval = getattr(self, "_apply_refresh_interval", None)
1331
+ if callable(apply_refresh_interval):
1332
+ apply_refresh_interval(self._main_refresh_interval())
1333
+ return
1334
+
1335
+ if not self.fileTextbox.text():
1336
+ self.show_status("Load a database before refreshing.", 5000)
1337
+ return
1338
+
1339
+ database_path = self.fileTextbox.text()
1340
+ if not DatabaseActionsMixin._database_generation_read_allowed(
1341
+ self,
1342
+ database_path,
1343
+ operation="refreshing it",
1344
+ ):
1345
+ return
1346
+
1347
+ if (
1348
+ getattr(self, "_database_load_active", False)
1349
+ or getattr(self, "_database_load_publication_active", False)
1350
+ ):
1351
+ self.show_status("Database reload already in progress.", 3000)
1352
+ return
1353
+
1354
+ if DatabaseActionsMixin._reload_if_database_instance_changed(
1355
+ self,
1356
+ database_path,
1357
+ ):
1358
+ return
1359
+
1360
+ current_instance = database_instance(database_path)
1361
+ current_identity = current_instance.identity
1362
+
1363
+ if getattr(self, "_database_refresh_active", False):
1364
+ self._database_refresh_pending = True
1365
+ self.show_status("Database refresh queued.", 3000)
1366
+ return
1367
+
1368
+ self.show_status("Checking for new runs...", 0)
1369
+ self._database_refresh_generation = (
1370
+ getattr(self, "_database_refresh_generation", 0) + 1
1371
+ )
1372
+ generation = self._database_refresh_generation
1373
+ self._database_refresh_active = True
1374
+ self._database_refresh_pending = False
1375
+ self._database_refresh_identity = current_identity
1376
+ self._database_refresh_instance = current_instance
1377
+ self._database_refresh_staged_new_runs = {}
1378
+ watched_pairs = []
1379
+ watched_guids = []
1380
+ item_run_id = getattr(self.RunList, "_item_run_id", None)
1381
+ for run in list(self.RunList.watching):
1382
+ guid = getattr(run, "guid", None)
1383
+ if not guid:
1384
+ continue
1385
+ run_id = item_run_id(run) if callable(item_run_id) else None
1386
+ if run_id is None:
1387
+ continue
1388
+ try:
1389
+ run_id = int(run_id)
1390
+ except (TypeError, ValueError):
1391
+ continue
1392
+ watched_pairs.append((run_id, str(guid)))
1393
+ watched_guids.append(str(guid))
1394
+
1395
+ watched_by_run_id = dict(watched_pairs)
1396
+ watched_runs = []
1397
+ scheduled_run_ids = set()
1398
+
1399
+ def schedule_watched(run_id, guid, category):
1400
+ try:
1401
+ run_id = int(run_id)
1402
+ except (TypeError, ValueError):
1403
+ return
1404
+ if run_id in scheduled_run_ids or not guid:
1405
+ return
1406
+ scheduled_run_ids.add(run_id)
1407
+ watched_runs.append((run_id, str(guid), category))
1408
+
1409
+ selected_guid = getattr(self, "_selected_run_guid", None)
1410
+ if selected_guid:
1411
+ run_id_for_guid = getattr(self.RunList, "run_id_for_guid", None)
1412
+ selected_run_id = (
1413
+ run_id_for_guid(selected_guid)
1414
+ if callable(run_id_for_guid)
1415
+ else None
1416
+ )
1417
+ if selected_run_id is not None:
1418
+ schedule_watched(selected_run_id, selected_guid, "selected")
1419
+
1420
+ visible_run_ids = getattr(self.RunList, "visible_run_ids", None)
1421
+ if callable(visible_run_ids):
1422
+ for run_id in visible_run_ids():
1423
+ try:
1424
+ numeric_run_id = int(run_id)
1425
+ except (TypeError, ValueError):
1426
+ continue
1427
+ guid = watched_by_run_id.get(numeric_run_id)
1428
+ if guid is not None:
1429
+ schedule_watched(numeric_run_id, guid, "visible")
1430
+
1431
+ for run_id, guid in watched_pairs:
1432
+ schedule_watched(run_id, guid, "remaining")
1433
+
1434
+ refresh_kwargs = {
1435
+ "expected_database_instance": current_instance,
1436
+ }
1437
+ trusted_service = DatabaseActionsMixin._active_trusted_service_for_instance(
1438
+ self,
1439
+ current_instance,
1440
+ )
1441
+ if (
1442
+ getattr(self, "_database_access_mode", None) == TRUSTED_LIVE_MODE
1443
+ and trusted_service is None
1444
+ ):
1445
+ self._database_refresh_active = False
1446
+ self._database_refresh_worker = None
1447
+ self._database_refresh_identity = None
1448
+ self._database_refresh_instance = None
1449
+ self.show_error(
1450
+ "Trusted Refresh Unavailable",
1451
+ "The accepted trusted live-reader session is no longer usable.",
1452
+ "Reload the database to start a new trusted session. qPlot did "
1453
+ "not fall back to a snapshot after the accepted-session failure.",
1454
+ )
1455
+ return
1456
+ if trusted_service is not None:
1457
+ refresh_kwargs["trusted_service"] = trusted_service
1458
+ refresh_kwargs["derived_coordinator_owned"] = bool(
1459
+ getattr(self, "_trusted_derived_bridge", None)
1460
+ )
1461
+ worker = DatabaseRefreshWorker(
1462
+ generation,
1463
+ database_path,
1464
+ self.RunList.maxRunId,
1465
+ watched_runs if trusted_service is not None else watched_guids,
1466
+ require_publication_ack=trusted_service is not None,
1467
+ **refresh_kwargs,
1468
+ )
1469
+ self._database_refresh_worker = worker
1470
+ new_runs_ready = getattr(worker.signals, "new_runs_ready", None)
1471
+ if new_runs_ready is not None:
1472
+ new_runs_ready.connect(
1473
+ lambda *args, refresh_worker=worker:
1474
+ DatabaseActionsMixin.database_refresh_new_runs_ready(
1475
+ self,
1476
+ *args,
1477
+ refresh_worker,
1478
+ )
1479
+ )
1480
+ worker.signals.finished.connect(
1481
+ lambda *args: self.database_refresh_finished(*args)
1482
+ )
1483
+ self.databaseRefreshThreadPool.start(worker)
1484
+
1485
+
1486
+ @QtCore.pyqtSlot(int, str, object)
1487
+ def database_refresh_new_runs_ready(
1488
+ self,
1489
+ generation,
1490
+ database_path,
1491
+ new_runs,
1492
+ source_worker=None,
1493
+ ):
1494
+ """Publish trusted basic rows before any follow-up status/detail work."""
1495
+ if getattr(self, "_database_refresh_publication_active", False):
1496
+ error = _DatabaseRefreshPublicationAborted(
1497
+ "A second trusted refresh page arrived during UI publication."
1498
+ )
1499
+ reject = getattr(source_worker, "reject_new_runs_publication", None)
1500
+ if callable(reject):
1501
+ reject(error)
1502
+ return
1503
+
1504
+ publication_error = None
1505
+ publication_source_changed = False
1506
+ staged = None
1507
+ staged_before_publication = None
1508
+ refresh_instance = getattr(self, "_database_refresh_instance", None)
1509
+ loaded_identity = getattr(self, "_loaded_database_identity", None)
1510
+ publication_baseline_instance = getattr(
1511
+ self,
1512
+ "_loaded_database_instance",
1513
+ None,
1514
+ )
1515
+ accepted_selected_guid = str(
1516
+ getattr(self, "_selected_run_guid", "") or ""
1517
+ )
1518
+ self._database_refresh_publication_active = True
1519
+ DatabaseActionsMixin._sync_database_generation_controls(self)
1520
+
1521
+ def publication_is_current():
1522
+ return bool(
1523
+ getattr(self, "_database_refresh_publication_active", False)
1524
+ and generation == getattr(self, "_database_refresh_generation", 0)
1525
+ and getattr(self, "_database_refresh_active", False)
1526
+ and _database_paths_equal(database_path, self.fileTextbox.text())
1527
+ and not getattr(self, "_shutdown_started", False)
1528
+ and not getattr(self, "_shutdown_ready", False)
1529
+ )
1530
+
1531
+ def publication_source_is_current():
1532
+ """Perform the final source check inside RunList's rollback scope."""
1533
+
1534
+ nonlocal publication_source_changed
1535
+ if not publication_is_current():
1536
+ return False
1537
+ current_instance = database_instance(database_path)
1538
+ if (
1539
+ _database_instances_or_sidecars_differ(
1540
+ refresh_instance,
1541
+ current_instance,
1542
+ )
1543
+ or _database_instances_or_sidecars_differ(
1544
+ publication_baseline_instance or loaded_identity,
1545
+ current_instance,
1546
+ )
1547
+ ):
1548
+ publication_source_changed = True
1549
+ return False
1550
+ if not DatabaseActionsMixin._advance_loaded_database_sidecar_baseline(
1551
+ self,
1552
+ current_instance,
1553
+ ):
1554
+ publication_source_changed = True
1555
+ return False
1556
+ return True
1557
+
1558
+ try:
1559
+ if generation != getattr(self, "_database_refresh_generation", 0):
1560
+ raise _DatabaseRefreshPublicationAborted(
1561
+ "The trusted refresh generation changed before publication."
1562
+ )
1563
+ if not getattr(self, "_database_refresh_active", False):
1564
+ raise _DatabaseRefreshPublicationAborted(
1565
+ "The trusted refresh ended before publication."
1566
+ )
1567
+ if not _database_paths_equal(database_path, self.fileTextbox.text()):
1568
+ raise _DatabaseRefreshPublicationAborted(
1569
+ "The selected database changed before refresh publication."
1570
+ )
1571
+ if DatabaseActionsMixin._reload_if_worker_database_instance_changed(
1572
+ self,
1573
+ database_path,
1574
+ refresh_instance,
1575
+ ):
1576
+ raise _DatabaseRefreshPublicationAborted(
1577
+ "The database instance changed before refresh publication."
1578
+ )
1579
+ # The preflight may legitimately bind a newly appeared WAL/SHM.
1580
+ # Freeze that promoted observation for the nested-event commit
1581
+ # guard so removal/replacement cannot look like another addition.
1582
+ publication_baseline_instance = getattr(
1583
+ self,
1584
+ "_loaded_database_instance",
1585
+ publication_baseline_instance,
1586
+ )
1587
+ staged = getattr(self, "_database_refresh_staged_new_runs", None)
1588
+ if staged is None:
1589
+ staged = {}
1590
+ self._database_refresh_staged_new_runs = staged
1591
+ staged_before_publication = dict(staged)
1592
+ staged.update(new_runs or {})
1593
+ published = DatabaseActionsMixin._apply_basic_new_runs(
1594
+ self,
1595
+ new_runs or {},
1596
+ continue_loading=publication_is_current,
1597
+ commit_check=publication_source_is_current,
1598
+ )
1599
+ if published is False:
1600
+ if publication_source_changed:
1601
+ raise DatabaseInstanceChangedError(
1602
+ "The database changed during trusted refresh publication."
1603
+ )
1604
+ raise _DatabaseRefreshPublicationAborted(
1605
+ "Trusted refresh basic-row publication was invalidated."
1606
+ )
1607
+ sync_empty_state = getattr(self, "_sync_empty_state", None)
1608
+ if callable(sync_empty_state):
1609
+ sync_empty_state()
1610
+ except Exception as error:
1611
+ publication_error = error
1612
+ if (
1613
+ staged is not None
1614
+ and staged_before_publication is not None
1615
+ and getattr(self, "_database_refresh_staged_new_runs", None)
1616
+ is staged
1617
+ ):
1618
+ staged.clear()
1619
+ staged.update(staged_before_publication)
1620
+ log_exception(
1621
+ "Trusted refresh basic-row publication failed",
1622
+ error,
1623
+ __name__,
1624
+ )
1625
+ finally:
1626
+ self._database_refresh_publication_active = False
1627
+ DatabaseActionsMixin._sync_database_generation_controls(self)
1628
+ try:
1629
+ selected_items = getattr(self.RunList, "selectedItems", None)
1630
+ if callable(selected_items):
1631
+ items = selected_items()
1632
+ selected_guid = ""
1633
+ if len(items) == 1:
1634
+ selected_guid = str(
1635
+ getattr(items[0], "guid", "") or ""
1636
+ )
1637
+ if selected_guid != accepted_selected_guid:
1638
+ if selected_guid:
1639
+ update_selected = getattr(self, "updateSelected", None)
1640
+ if callable(update_selected):
1641
+ update_selected(selected_guid)
1642
+ else:
1643
+ clear_selection = getattr(
1644
+ self,
1645
+ "clear_non_single_run_selection",
1646
+ None,
1647
+ )
1648
+ if callable(clear_selection):
1649
+ clear_selection()
1650
+ except Exception as selection_error:
1651
+ # Selection replay is controller repair after the basic-row
1652
+ # transaction. It must never suppress the worker's page
1653
+ # acknowledgement or rejection.
1654
+ log_exception(
1655
+ "Refresh selection reconciliation failed",
1656
+ selection_error,
1657
+ __name__,
1658
+ )
1659
+ if publication_error is None:
1660
+ acknowledge = getattr(
1661
+ source_worker,
1662
+ "acknowledge_new_runs_published",
1663
+ None,
1664
+ )
1665
+ if callable(acknowledge):
1666
+ acknowledge()
1667
+ else:
1668
+ reject = getattr(
1669
+ source_worker,
1670
+ "reject_new_runs_publication",
1671
+ None,
1672
+ )
1673
+ if callable(reject):
1674
+ reject(publication_error)
1675
+ if publication_source_changed:
1676
+ self._reload_replaced_database(database_path)
1677
+
1678
+
1679
+ @QtCore.pyqtSlot(int, str, object, object, object)
1680
+ def database_refresh_finished(
1681
+ self,
1682
+ generation,
1683
+ database_path,
1684
+ new_runs,
1685
+ statuses,
1686
+ error,
1687
+ ):
1688
+ if generation != getattr(self, "_database_refresh_generation", 0):
1689
+ return
1690
+ if not getattr(self, "_database_refresh_active", False):
1691
+ return
1692
+ if DatabaseActionsMixin._database_generation_transaction_blocks_path(
1693
+ self,
1694
+ database_path,
1695
+ ):
1696
+ DatabaseActionsMixin._cancel_database_refresh(self)
1697
+ return
1698
+
1699
+ try:
1700
+ if not _database_paths_equal(database_path, self.fileTextbox.text()):
1701
+ return
1702
+ if isinstance(error, DatabaseInstanceChangedError):
1703
+ DatabaseActionsMixin._cancel_database_refresh(self)
1704
+ self._reload_replaced_database(database_path)
1705
+ return
1706
+ refresh_identity = getattr(self, "_database_refresh_identity", None)
1707
+ refresh_instance = getattr(self, "_database_refresh_instance", None)
1708
+ current_instance = database_instance(database_path)
1709
+ loaded_identity = getattr(self, "_loaded_database_identity", None)
1710
+ loaded_instance = getattr(self, "_loaded_database_instance", None)
1711
+ if (
1712
+ _database_instances_or_sidecars_differ(
1713
+ refresh_instance or refresh_identity,
1714
+ current_instance,
1715
+ )
1716
+ or _database_instances_or_sidecars_differ(
1717
+ loaded_instance or loaded_identity,
1718
+ current_instance,
1719
+ )
1720
+ ):
1721
+ self._reload_replaced_database(database_path)
1722
+ return
1723
+ DatabaseActionsMixin._advance_loaded_database_sidecar_baseline(
1724
+ self,
1725
+ current_instance,
1726
+ )
1727
+ if error is not None:
1728
+ log_exception("Main-window refresh failed", error, __name__)
1729
+ if isinstance(error, UnverifiableDatabaseWalError):
1730
+ self.show_error(
1731
+ "Unverifiable Database WAL",
1732
+ "qPlot cannot verify this database's WAL. Close every "
1733
+ "owning QCoDeS/SQLite connection cleanly, or checkpoint "
1734
+ "with the owning writer, then refresh.",
1735
+ str(error),
1736
+ )
1737
+ return
1738
+ self.show_error(
1739
+ "Refresh Failed",
1740
+ "Could not refresh the run list.",
1741
+ str(error),
1742
+ )
1743
+ return
1744
+
1745
+ effective_new_runs = dict(
1746
+ getattr(self, "_database_refresh_staged_new_runs", {})
1747
+ )
1748
+ effective_new_runs.update(new_runs or {})
1749
+ self._apply_database_refresh_result(effective_new_runs, statuses or {})
1750
+ if DatabaseActionsMixin._reload_if_worker_database_instance_changed(
1751
+ self,
1752
+ database_path,
1753
+ refresh_instance,
1754
+ discard_stale_publication=True,
1755
+ ):
1756
+ return
1757
+ finally:
1758
+ self._database_refresh_active = False
1759
+ self._database_refresh_worker = None
1760
+ self._database_refresh_identity = None
1761
+ self._database_refresh_instance = None
1762
+ self._database_refresh_staged_new_runs = {}
1763
+ pending = bool(getattr(self, "_database_refresh_pending", False))
1764
+ self._database_refresh_pending = False
1765
+ if pending and _database_paths_equal(
1766
+ database_path,
1767
+ self.fileTextbox.text(),
1768
+ ):
1769
+ QtCore.QTimer.singleShot(0, self.refreshMain)
1770
+
1771
+
1772
+ def _apply_database_refresh_result(self, new_runs, statuses):
1773
+ if DatabaseActionsMixin._database_generation_transaction_blocks_path(self):
1774
+ return
1775
+ updated_runs = self.RunList.checkWatching(statuses) or {}
1776
+ update_runs = getattr(self.RunList, "updateRuns", None)
1777
+ run_id_for_guid = getattr(self.RunList, "run_id_for_guid", None)
1778
+ if callable(update_runs) and callable(run_id_for_guid):
1779
+ updated_guids = {
1780
+ str(metadata.get("guid"))
1781
+ for metadata in updated_runs.values()
1782
+ if metadata.get("guid")
1783
+ }
1784
+ remaining_statuses = {}
1785
+ for guid, status in (statuses or {}).items():
1786
+ if not status or str(guid) in updated_guids:
1787
+ continue
1788
+ run_id = run_id_for_guid(guid)
1789
+ if run_id is None:
1790
+ continue
1791
+ metadata = dict(status)
1792
+ metadata.setdefault("guid", str(guid))
1793
+ remaining_statuses[run_id] = metadata
1794
+ updated_runs.update(update_runs(remaining_statuses))
1795
+ if updated_runs:
1796
+ if getattr(self, "_database_access_mode", None) == TRUSTED_LIVE_MODE:
1797
+ bridge = getattr(self, "_trusted_derived_bridge", None)
1798
+ if bridge is not None:
1799
+ bridge.source_changed(tuple(updated_runs))
1800
+ else:
1801
+ self.infoBox.preview.add_runs(updated_runs)
1802
+ prioritize_previews = getattr(self, "_prioritize_preview_runs", None)
1803
+ if callable(prioritize_previews):
1804
+ prioritize_previews()
1805
+ self._refresh_selected_run_details(updated_runs, live_only=True)
1806
+
1807
+ if new_runs:
1808
+ DatabaseActionsMixin._apply_basic_new_runs(self, new_runs)
1809
+
1810
+ self._sync_empty_state()
1811
+ if not new_runs:
1812
+ if self.RunList.topLevelItemCount() == 0:
1813
+ self.show_status(self._empty_database_refresh_status(), 3000)
1814
+ else:
1815
+ self.show_status("No new runs found.", 3000)
1816
+ return
1817
+
1818
+ count = len(new_runs)
1819
+ noun = "run" if count == 1 else "runs"
1820
+ self.show_status(f"Found {count} new {noun}.", 5000)
1821
+
1822
+ if (
1823
+ getattr(self, "_database_access_mode", None) != TRUSTED_LIVE_MODE
1824
+ and self.autoPlotBox.isChecked()
1825
+ ):
1826
+ for run in new_runs.values():
1827
+ self.openPlot(run["guid"])
1828
+
1829
+
1830
+ def _apply_basic_new_runs(
1831
+ self,
1832
+ new_runs,
1833
+ *,
1834
+ continue_loading=None,
1835
+ commit_check=None,
1836
+ ):
1837
+ """Add bounded basic rows immediately, then queue their metadata work."""
1838
+ if not new_runs:
1839
+ return True
1840
+ all_run_metadata = getattr(self.RunList, "all_run_metadata", None)
1841
+ existing = all_run_metadata() if callable(all_run_metadata) else {}
1842
+
1843
+ def run_key(value):
1844
+ try:
1845
+ return int(value)
1846
+ except (TypeError, ValueError):
1847
+ return str(value)
1848
+
1849
+ existing_ids = {run_key(run_id) for run_id in existing}
1850
+ existing_guids = {
1851
+ str(metadata.get("guid"))
1852
+ for metadata in existing.values()
1853
+ if metadata.get("guid")
1854
+ }
1855
+ additions = {
1856
+ run_id: metadata
1857
+ for run_id, metadata in new_runs.items()
1858
+ if (
1859
+ run_key(run_id) not in existing_ids
1860
+ and str(metadata.get("guid") or "") not in existing_guids
1861
+ )
1862
+ }
1863
+ if not additions:
1864
+ return True
1865
+ numeric_run_ids = []
1866
+ for run_id in additions:
1867
+ try:
1868
+ numeric_run_ids.append(int(run_id))
1869
+ except (TypeError, ValueError):
1870
+ continue
1871
+ previous_max_run_id = self.RunList.maxRunId
1872
+ publication_complete = False
1873
+ try:
1874
+ if continue_loading is None and commit_check is None:
1875
+ published = self.RunList.addRuns(additions)
1876
+ else:
1877
+ published = self.RunList.addRuns(
1878
+ additions,
1879
+ continue_loading=continue_loading,
1880
+ commit_check=commit_check,
1881
+ )
1882
+ if published is False:
1883
+ return False
1884
+ if numeric_run_ids:
1885
+ self.RunList.maxRunId = max(
1886
+ previous_max_run_id,
1887
+ max(numeric_run_ids),
1888
+ )
1889
+ publication_complete = True
1890
+ finally:
1891
+ if not publication_complete:
1892
+ self.RunList.maxRunId = previous_max_run_id
1893
+ if getattr(self, "_database_access_mode", None) != TRUSTED_LIVE_MODE:
1894
+ self.infoBox.preview.add_runs(additions)
1895
+ prioritize_previews = getattr(self, "_prioritize_preview_runs", None)
1896
+ if callable(prioritize_previews):
1897
+ prioritize_previews()
1898
+ if getattr(self, "_database_access_mode", None) == TRUSTED_LIVE_MODE:
1899
+ bridge = getattr(self, "_trusted_derived_bridge", None)
1900
+ if bridge is not None:
1901
+ bridge.reconcile_runs(self.RunList.all_run_metadata())
1902
+ else:
1903
+ DatabaseActionsMixin._queue_incremental_trusted_detail_runs(
1904
+ self,
1905
+ tuple(additions),
1906
+ )
1907
+ else:
1908
+ restart_details = getattr(
1909
+ self,
1910
+ "_restart_database_detail_load_for_current_runs",
1911
+ None,
1912
+ )
1913
+ if callable(restart_details):
1914
+ restart_details()
1915
+ return True
1916
+
1917
+
1918
+ def _restart_database_detail_load_for_current_runs(self):
1919
+ database_path = self.fileTextbox.text()
1920
+ if not database_path:
1921
+ return
1922
+ runs = self.RunList.all_run_metadata()
1923
+ self._cancel_database_detail_load()
1924
+ self._start_database_detail_load(database_path, runs)
1925
+
1926
+
1927
+ def _queue_incremental_trusted_detail_runs(self, run_ids):
1928
+ """Compatibility hook routing trusted additions only to Stage 5C."""
1929
+
1930
+ bridge = getattr(self, "_trusted_derived_bridge", None)
1931
+ if bridge is not None:
1932
+ bridge.reconcile_runs(self.RunList.all_run_metadata())
1933
+ return
1934
+ normalised = []
1935
+ for run_id in run_ids or ():
1936
+ try:
1937
+ run_id = int(run_id)
1938
+ except (TypeError, ValueError):
1939
+ continue
1940
+ if run_id > 0:
1941
+ normalised.append(run_id)
1942
+ if not normalised:
1943
+ return
1944
+
1945
+ database_path = self.fileTextbox.text()
1946
+ detail_instance = getattr(self, "_loaded_database_instance", None)
1947
+ if not database_path or not isinstance(detail_instance, DatabaseInstance):
1948
+ return
1949
+ trusted_service = DatabaseActionsMixin._active_trusted_service_for_instance(
1950
+ self,
1951
+ detail_instance,
1952
+ )
1953
+ if trusted_service is None:
1954
+ return
1955
+ worker_kwargs = {
1956
+ "expected_database_instance": detail_instance,
1957
+ "trusted_service": trusted_service,
1958
+ }
1959
+ cheap_worker = getattr(self, "_database_detail_worker", None)
1960
+ add_cheap = getattr(cheap_worker, "add_run_ids", None)
1961
+ if not (
1962
+ getattr(self, "_database_detail_active", False)
1963
+ and callable(add_cheap)
1964
+ and add_cheap(normalised)
1965
+ ):
1966
+ DatabaseActionsMixin._start_database_cheap_detail_worker(
1967
+ self,
1968
+ database_path,
1969
+ normalised,
1970
+ detail_instance,
1971
+ worker_kwargs,
1972
+ )
1973
+ expensive_worker = getattr(self, "_database_expensive_detail_worker", None)
1974
+ add_expensive = getattr(expensive_worker, "add_run_ids", None)
1975
+ if not (
1976
+ getattr(self, "_database_expensive_detail_active", False)
1977
+ and callable(add_expensive)
1978
+ and add_expensive(normalised)
1979
+ ):
1980
+ DatabaseActionsMixin._start_database_expensive_detail_worker(
1981
+ self,
1982
+ database_path,
1983
+ normalised,
1984
+ detail_instance,
1985
+ worker_kwargs,
1986
+ )
1987
+ self._prioritize_database_detail_runs()
1988
+
1989
+
1990
+ def _cancel_database_refresh(self):
1991
+ worker = getattr(self, "_database_refresh_worker", None)
1992
+ if worker is not None:
1993
+ worker.cancel()
1994
+ self._database_refresh_generation = (
1995
+ getattr(self, "_database_refresh_generation", 0) + 1
1996
+ )
1997
+ self._database_refresh_active = False
1998
+ self._database_refresh_pending = False
1999
+ self._database_refresh_worker = None
2000
+ self._database_refresh_identity = None
2001
+ self._database_refresh_instance = None
2002
+ self._database_refresh_staged_new_runs = {}
2003
+
2004
+
2005
+ def _reload_replaced_database(
2006
+ self,
2007
+ database_path,
2008
+ *,
2009
+ generation_recovery=False,
2010
+ load_started_at=None,
2011
+ ):
2012
+ """Invalidate one replaced database instance and force a safe reload."""
2013
+ if not generation_recovery:
2014
+ return self.load_file(
2015
+ database_path,
2016
+ load_started_at,
2017
+ force=True,
2018
+ replacement=True,
2019
+ )
2020
+ return self.load_file(
2021
+ database_path,
2022
+ load_started_at,
2023
+ force=True,
2024
+ replacement=True,
2025
+ generation_recovery=True,
2026
+ )
2027
+
2028
+
2029
+ def _retry_replaced_database_if_current(
2030
+ self,
2031
+ retry_generation,
2032
+ database_path,
2033
+ *,
2034
+ generation_recovery=False,
2035
+ load_started_at=None,
2036
+ ):
2037
+ """Run a queued replacement retry only for its still-current generation."""
2038
+ if (
2039
+ retry_generation != getattr(self, "_database_load_generation", 0)
2040
+ or getattr(self, "_shutdown_started", False)
2041
+ or getattr(self, "_shutdown_ready", False)
2042
+ ):
2043
+ return False
2044
+ reload_replaced_database = getattr(
2045
+ self,
2046
+ "_reload_replaced_database",
2047
+ None,
2048
+ )
2049
+ if not callable(reload_replaced_database):
2050
+ reload_replaced_database = (
2051
+ lambda path, **kwargs:
2052
+ DatabaseActionsMixin._reload_replaced_database(
2053
+ self,
2054
+ path,
2055
+ **kwargs,
2056
+ )
2057
+ )
2058
+ return reload_replaced_database(
2059
+ database_path,
2060
+ generation_recovery=generation_recovery,
2061
+ load_started_at=load_started_at,
2062
+ )
2063
+
2064
+
2065
+ def _reload_if_database_instance_changed(
2066
+ self,
2067
+ database_path,
2068
+ *,
2069
+ discard_stale_publication=False,
2070
+ ):
2071
+ """Start replacement reload before any action reads a changed source."""
2072
+
2073
+ if DatabaseActionsMixin._database_generation_transaction_blocks_path(
2074
+ self,
2075
+ database_path,
2076
+ ):
2077
+ if discard_stale_publication:
2078
+ DatabaseActionsMixin._discard_stale_worker_publication(self)
2079
+ return True
2080
+
2081
+ loaded_identity = getattr(self, "_loaded_database_identity", None)
2082
+ loaded_instance = getattr(self, "_loaded_database_instance", None)
2083
+ if loaded_instance is None:
2084
+ changed = _database_instances_differ(
2085
+ loaded_identity,
2086
+ _database_file_identity(database_path),
2087
+ )
2088
+ else:
2089
+ current_instance = database_instance(database_path)
2090
+ changed = _database_instances_or_sidecars_differ(
2091
+ loaded_instance,
2092
+ current_instance,
2093
+ )
2094
+ if not changed:
2095
+ if loaded_instance is not None:
2096
+ DatabaseActionsMixin._advance_loaded_database_sidecar_baseline(
2097
+ self,
2098
+ current_instance,
2099
+ )
2100
+ return False
2101
+ if discard_stale_publication:
2102
+ DatabaseActionsMixin._discard_stale_worker_publication(self)
2103
+ self._reload_replaced_database(database_path)
2104
+ return True
2105
+
2106
+
2107
+ def _reload_if_worker_database_instance_changed(
2108
+ self,
2109
+ database_path,
2110
+ expected_instance,
2111
+ *,
2112
+ discard_stale_publication=False,
2113
+ ):
2114
+ """Reject a metadata callback not bound to the accepted DB instance."""
2115
+ if not isinstance(expected_instance, DatabaseInstance):
2116
+ return DatabaseActionsMixin._reload_if_database_instance_changed(
2117
+ self,
2118
+ database_path,
2119
+ discard_stale_publication=discard_stale_publication,
2120
+ )
2121
+
2122
+ current_instance = database_instance(expected_instance.logical_path)
2123
+ loaded_instance = getattr(self, "_loaded_database_instance", None)
2124
+ if (
2125
+ _database_instances_or_sidecars_differ(
2126
+ expected_instance,
2127
+ current_instance,
2128
+ )
2129
+ or (
2130
+ isinstance(loaded_instance, DatabaseInstance)
2131
+ and _database_instances_or_sidecars_differ(
2132
+ loaded_instance,
2133
+ current_instance,
2134
+ )
2135
+ )
2136
+ ):
2137
+ if discard_stale_publication:
2138
+ DatabaseActionsMixin._discard_stale_worker_publication(self)
2139
+ self._reload_replaced_database(database_path)
2140
+ return True
2141
+ DatabaseActionsMixin._advance_loaded_database_sidecar_baseline(
2142
+ self,
2143
+ current_instance,
2144
+ )
2145
+ return False
2146
+
2147
+
2148
+ def _discard_stale_worker_publication(self):
2149
+ """Fail closed after a widget mutation races source replacement.
2150
+
2151
+ Replacement reload owns worker cancellation, session retirement, and
2152
+ generation changes. This local step only removes the callback's
2153
+ visible model before that reload is requested, so it cannot leave a
2154
+ mixed-source view when reload startup is asynchronous or overridden.
2155
+ """
2156
+ run_id_box = getattr(self, "run_idBox", None)
2157
+ run_list = getattr(self, "RunList", None)
2158
+ run_id_signals_blocked = None
2159
+ run_list_signals_blocked = None
2160
+ if run_id_box is not None:
2161
+ block_signals = getattr(run_id_box, "blockSignals", None)
2162
+ if callable(block_signals):
2163
+ run_id_signals_blocked = block_signals(True)
2164
+ if run_list is not None:
2165
+ block_signals = getattr(run_list, "blockSignals", None)
2166
+ if callable(block_signals):
2167
+ run_list_signals_blocked = block_signals(True)
2168
+
2169
+ try:
2170
+ if run_id_box is not None:
2171
+ set_text = getattr(run_id_box, "setText", None)
2172
+ if callable(set_text):
2173
+ set_text("")
2174
+ measurement_box = getattr(self, "measurementBox", None)
2175
+ set_measurement = getattr(measurement_box, "setText", None)
2176
+ if callable(set_measurement):
2177
+ set_measurement("*")
2178
+
2179
+ self.selected_run_id = None
2180
+ self._selected_run_guid = None
2181
+ self._selected_run_detail_cache = {}
2182
+ self._selected_run_partial_detail_keys = set()
2183
+ release_selected = getattr(self, "_release_selected_dataset", None)
2184
+ if callable(release_selected):
2185
+ release_selected()
2186
+ else:
2187
+ self.ds = None
2188
+ self._selected_dataset_key = None
2189
+
2190
+ if run_list is not None:
2191
+ clear_selection = getattr(run_list, "clearSelection", None)
2192
+ if callable(clear_selection):
2193
+ clear_selection()
2194
+ clear = getattr(run_list, "clear", None)
2195
+ if callable(clear):
2196
+ clear()
2197
+ if hasattr(run_list, "watching"):
2198
+ run_list.watching = []
2199
+ if hasattr(run_list, "maxRunId"):
2200
+ run_list.maxRunId = 0
2201
+ scroll_to_top = getattr(run_list, "scrollToTop", None)
2202
+ if callable(scroll_to_top):
2203
+ scroll_to_top()
2204
+
2205
+ info_box = getattr(self, "infoBox", None)
2206
+ clear_info = getattr(info_box, "clear", None)
2207
+ if callable(clear_info):
2208
+ clear_info()
2209
+ clear_summary_cache = getattr(
2210
+ self,
2211
+ "_clear_snapshot_setpoint_summary_cache",
2212
+ None,
2213
+ )
2214
+ if callable(clear_summary_cache):
2215
+ clear_summary_cache()
2216
+ else:
2217
+ summary_cache = getattr(
2218
+ self,
2219
+ "_snapshot_setpoint_summary_cache",
2220
+ None,
2221
+ )
2222
+ if isinstance(summary_cache, dict):
2223
+ summary_cache.clear()
2224
+ clear_database_cache = getattr(info_box, "clear_database_cache", None)
2225
+ if callable(clear_database_cache):
2226
+ clear_database_cache()
2227
+ preview = getattr(info_box, "preview", None)
2228
+ clear_preview_source = getattr(preview, "set_database_runs", None)
2229
+ if callable(clear_preview_source):
2230
+ clear_preview_source("", {})
2231
+ scroll_info_to_top = getattr(info_box, "scrollToTop", None)
2232
+ if callable(scroll_info_to_top):
2233
+ scroll_info_to_top()
2234
+ finally:
2235
+ if run_list is not None and run_list_signals_blocked is not None:
2236
+ run_list.blockSignals(run_list_signals_blocked)
2237
+ if run_id_box is not None and run_id_signals_blocked is not None:
2238
+ run_id_box.blockSignals(run_id_signals_blocked)
2239
+
2240
+ sync_empty_state = getattr(self, "_sync_empty_state", None)
2241
+ if callable(sync_empty_state):
2242
+ sync_empty_state()
2243
+
2244
+
2245
+ @QtCore.pyqtSlot()
2246
+ def getfile(self):
2247
+ """
2248
+ Handles event for load action in file menu to load new database.
2249
+
2250
+ """
2251
+ filename = qtw.QFileDialog.getOpenFileName(
2252
+ self,
2253
+ "Open file",
2254
+ self.database_open_directory(),
2255
+ "Data Base File (*.db)",
2256
+ )[0]
2257
+
2258
+ if os.path.isfile(filename):
2259
+ self.load_database_path(filename)
2260
+ else:
2261
+ self.show_status("Database load cancelled.", 3000)
2262
+
2263
+
2264
+ def database_open_directory(self):
2265
+ """
2266
+ Returns the directory the database-open dialog should start in.
2267
+
2268
+ """
2269
+ current_database = self.fileTextbox.text()
2270
+ if current_database:
2271
+ current_directory = os.path.dirname(os.path.abspath(current_database))
2272
+ if os.path.isdir(current_directory):
2273
+ return current_directory
2274
+
2275
+ default_load_path = self.config.get("file.default_load_path")
2276
+
2277
+ if os.path.isdir(default_load_path):
2278
+ return default_load_path
2279
+
2280
+ return os.getcwd()
2281
+
2282
+
2283
+ @QtCore.pyqtSlot()
2284
+ def change_default_file(self):
2285
+ """
2286
+ Chooses the default database-open location.
2287
+
2288
+ """
2289
+ if os.path.isdir(self.config.get("file.default_load_path")):
2290
+ openDir = self.config.get("file.default_load_path")
2291
+ else:
2292
+ openDir = os.getcwd()
2293
+
2294
+ foldername = qtw.QFileDialog.getExistingDirectory(
2295
+ self,
2296
+ "Select Folder",
2297
+ openDir,
2298
+ )
2299
+
2300
+ if os.path.isdir(foldername):
2301
+ if persist_config_value(
2302
+ self,
2303
+ self.config,
2304
+ "file.default_load_path",
2305
+ foldername,
2306
+ "the default database folder",
2307
+ ):
2308
+ self.show_status(f"Default load folder set to {foldername}", 5000)
2309
+ return True
2310
+ return False
2311
+ else:
2312
+ self.show_status("Default load folder unchanged.", 3000)
2313
+ return False
2314
+
2315
+
2316
+ @QtCore.pyqtSlot(str)
2317
+ def load_database_path(self, filename):
2318
+ """
2319
+ Load a database path chosen from the file dialog or dropped by the user.
2320
+
2321
+ """
2322
+ load_started_at = perf_counter()
2323
+ log_event("Database load requested: %s", filename, logger_name=__name__)
2324
+
2325
+ if not DatabaseActionsMixin._database_generation_read_allowed(
2326
+ self,
2327
+ filename,
2328
+ operation="reloading that path",
2329
+ ):
2330
+ return False
2331
+
2332
+ if not os.path.isfile(filename):
2333
+ self.show_error(
2334
+ "Database Load Failed",
2335
+ "The selected database file could not be found.",
2336
+ str(filename),
2337
+ )
2338
+ return False
2339
+
2340
+ abspath = os.path.abspath(filename)
2341
+ if not abspath.lower().endswith(".db"):
2342
+ self.show_error(
2343
+ "Database Load Failed",
2344
+ "qPlot can only load QCoDeS .db database files.",
2345
+ abspath,
2346
+ )
2347
+ return False
2348
+
2349
+ return self.load_file(abspath, load_started_at)
2350
+
2351
+
2352
+ def recent_database_paths(self):
2353
+ """
2354
+ Returns recent database paths, newest first.
2355
+
2356
+ """
2357
+ paths = list(self.config.get("file.recent_file_paths"))
2358
+
2359
+ deduped = []
2360
+ seen = set()
2361
+ for path in paths:
2362
+ abspath = os.path.abspath(path)
2363
+ if abspath in seen:
2364
+ continue
2365
+ seen.add(abspath)
2366
+ deduped.append(abspath)
2367
+
2368
+ return deduped[:10]
2369
+
2370
+
2371
+ def remember_loaded_database(self, filename):
2372
+ """
2373
+ Persists the successfully loaded database path.
2374
+
2375
+ """
2376
+ abspath = os.path.abspath(filename)
2377
+ current_paths = list(self.config.get("file.recent_file_paths"))
2378
+
2379
+ paths = [path for path in self.recent_database_paths() if path != abspath]
2380
+ paths.insert(0, abspath)
2381
+ paths = paths[:10]
2382
+
2383
+ if current_paths == paths:
2384
+ return True
2385
+
2386
+ if not persist_config_value(
2387
+ self,
2388
+ self.config,
2389
+ "file.recent_file_paths",
2390
+ paths,
2391
+ "the recent database list",
2392
+ ):
2393
+ return False
2394
+
2395
+ self.refresh_recent_database_menu()
2396
+ return True
2397
+
2398
+
2399
+ def refresh_recent_database_menu(self):
2400
+ """
2401
+ Rebuilds the File -> Load Recent Database menu.
2402
+
2403
+ """
2404
+ if not hasattr(self, "recentDatabaseMenu"):
2405
+ return
2406
+
2407
+ self.recentDatabaseMenu.clear()
2408
+ paths = self.recent_database_paths()
2409
+ self.recentDatabaseMenu.setEnabled(bool(paths))
2410
+
2411
+ if not paths:
2412
+ empty_action = QtGui.QAction("No Recent Databases", self)
2413
+ empty_action.setEnabled(False)
2414
+ self.recentDatabaseMenu.addAction(empty_action)
2415
+ return
2416
+
2417
+ for index, path in enumerate(paths, start=1):
2418
+ label = f"{index}. {os.path.basename(path) or path}"
2419
+ action = QtGui.QAction(label, self)
2420
+ action.setToolTip(path)
2421
+ action.setStatusTip(path)
2422
+ action.setEnabled(os.path.isfile(path))
2423
+ action.triggered.connect(
2424
+ lambda _, filename=path: self.load_database_path(filename)
2425
+ )
2426
+ self.recentDatabaseMenu.addAction(action)
2427
+
2428
+
2429
+ def load_file(
2430
+ self,
2431
+ abspath,
2432
+ load_started_at=None,
2433
+ *,
2434
+ force=False,
2435
+ replacement=False,
2436
+ generation_recovery=False,
2437
+ ):
2438
+ """
2439
+ Updates the database for RunList display and loading datasets.
2440
+
2441
+ """
2442
+ if load_started_at is None:
2443
+ load_started_at = perf_counter()
2444
+ log_event("Loading database file: %s", abspath, logger_name=__name__)
2445
+
2446
+ transaction_blocks = (
2447
+ DatabaseActionsMixin._database_generation_transaction_blocks_path(
2448
+ self,
2449
+ abspath,
2450
+ )
2451
+ )
2452
+ if transaction_blocks and not generation_recovery:
2453
+ DatabaseActionsMixin._database_generation_read_allowed(
2454
+ self,
2455
+ abspath,
2456
+ operation="reloading that path",
2457
+ )
2458
+ return False
2459
+ if generation_recovery and not transaction_blocks:
2460
+ return False
2461
+
2462
+ if (
2463
+ self._database_load_active
2464
+ or getattr(self, "_database_load_publication_active", False)
2465
+ ):
2466
+ self.show_status("Wait for the current database load to finish.", 5000)
2467
+ return False
2468
+
2469
+ qcodes_database = get_DB_location()
2470
+ displayed_database = self.fileTextbox.text()
2471
+ same_loaded_database = bool(
2472
+ qcodes_database
2473
+ and displayed_database
2474
+ and logical_database_path(abspath)
2475
+ == logical_database_path(qcodes_database)
2476
+ == logical_database_path(displayed_database)
2477
+ )
2478
+ current_instance = database_instance(abspath)
2479
+ current_identity = current_instance.identity
2480
+ loaded_identity = getattr(self, "_loaded_database_identity", None)
2481
+ loaded_instance = getattr(self, "_loaded_database_instance", None)
2482
+ if current_identity is None and os.path.isfile(abspath):
2483
+ if same_loaded_database and loaded_instance is not None:
2484
+ self._prepare_replaced_database_reload(abspath)
2485
+ self._loaded_database_identity = None
2486
+ self._loaded_database_instance = None
2487
+ self.show_error(
2488
+ "Database Identity Unavailable",
2489
+ "qPlot cannot safely monitor this database for replacement.",
2490
+ (
2491
+ "The filesystem did not provide a stable file identity. "
2492
+ "The database was not loaded because qPlot cannot safely "
2493
+ "distinguish normal live writes from a replaced file."
2494
+ ),
2495
+ )
2496
+ return False
2497
+ replacement = bool(
2498
+ replacement
2499
+ or (
2500
+ same_loaded_database
2501
+ and _database_instances_or_sidecars_differ(
2502
+ loaded_instance or loaded_identity,
2503
+ current_instance,
2504
+ )
2505
+ )
2506
+ )
2507
+ if same_loaded_database and not replacement:
2508
+ DatabaseActionsMixin._advance_loaded_database_sidecar_baseline(
2509
+ self,
2510
+ current_instance,
2511
+ )
2512
+
2513
+ if (
2514
+ same_loaded_database
2515
+ and not replacement
2516
+ and not force
2517
+ and not getattr(
2518
+ self,
2519
+ "_database_view_released_for_generation",
2520
+ False,
2521
+ )
2522
+ and loaded_identity is not None
2523
+ and current_identity == loaded_identity
2524
+ and (
2525
+ getattr(self, "_database_access_mode", None)
2526
+ != TRUSTED_LIVE_MODE
2527
+ or DatabaseActionsMixin._active_trusted_service_for_instance(
2528
+ self,
2529
+ current_instance,
2530
+ )
2531
+ is not None
2532
+ )
2533
+ ):
2534
+ if getattr(self, "_database_access_mode", None) == TRUSTED_LIVE_MODE:
2535
+ service = DatabaseActionsMixin._active_trusted_service_for_instance(
2536
+ self,
2537
+ current_instance,
2538
+ )
2539
+ bridge = getattr(self, "_trusted_derived_bridge", None)
2540
+ refresh = getattr(bridge, "refresh_active_database", None)
2541
+ refreshed = bool(
2542
+ service is not None
2543
+ and callable(refresh)
2544
+ and refresh(
2545
+ getattr(self, "_loaded_database_instance", current_instance),
2546
+ self.RunList.all_run_metadata(),
2547
+ service,
2548
+ )
2549
+ )
2550
+ if not refreshed and bridge is not None:
2551
+ generation = getattr(self, "_database_load_generation", 0)
2552
+ QtCore.QTimer.singleShot(
2553
+ 0,
2554
+ lambda: self._start_trusted_derived_bridge(
2555
+ generation,
2556
+ abspath,
2557
+ ),
2558
+ )
2559
+ elif not self.infoBox.preview.has_database(abspath):
2560
+ self.infoBox.preview.set_database_runs(
2561
+ abspath,
2562
+ self.RunList.all_run_metadata(),
2563
+ )
2564
+ elapsed = perf_counter() - load_started_at
2565
+ self.show_status(f"Database is already loaded ({elapsed:.2f} s).", 3000)
2566
+ self.remember_loaded_database(abspath)
2567
+ return True
2568
+
2569
+ DatabaseActionsMixin._cancel_database_refresh(self)
2570
+ DatabaseActionsMixin._cancel_database_info(self)
2571
+ if replacement:
2572
+ self._prepare_replaced_database_reload(abspath)
2573
+
2574
+ if replacement:
2575
+ load_message = (
2576
+ f"Database was replaced; reloading {os.path.basename(abspath)}..."
2577
+ )
2578
+ else:
2579
+ load_message = f"Loading database {os.path.basename(abspath)}..."
2580
+
2581
+ self._database_load_generation += 1
2582
+ generation = self._database_load_generation
2583
+ self._database_load_active = True
2584
+ self._database_load_state = {
2585
+ "abspath": abspath,
2586
+ "load_started_at": load_started_at,
2587
+ "reload_same_path": force or same_loaded_database,
2588
+ "load_identity": current_identity,
2589
+ "load_instance": current_instance,
2590
+ "replacement_reload": replacement,
2591
+ "generation_recovery": generation_recovery,
2592
+ }
2593
+ apply_refresh_interval = getattr(self, "_apply_refresh_interval", None)
2594
+ if callable(apply_refresh_interval):
2595
+ apply_refresh_interval(self._main_refresh_interval())
2596
+
2597
+ self._set_database_load_controls_enabled(False)
2598
+ self._show_database_load_panel(load_message)
2599
+
2600
+ cloud_sync_timeout = self.config.get(
2601
+ "runtime_settings.cloud_sync_timeout"
2602
+ )
2603
+
2604
+ # Bootstrap resets the adapter's schema cache and pagination cursors.
2605
+ # A reload therefore needs an isolated pending service: borrowing the
2606
+ # committed service would mutate A before the replacement view has
2607
+ # successfully published. The unchanged-instance fast path above is
2608
+ # the only load path that may continue using the active service.
2609
+ worker_kwargs = {
2610
+ "expected_database_instance": current_instance,
2611
+ }
2612
+ worker = DatabaseLoadWorker(
2613
+ generation,
2614
+ abspath,
2615
+ cloud_sync_timeout,
2616
+ **worker_kwargs,
2617
+ )
2618
+ self._database_load_worker = worker
2619
+ pending_service = getattr(worker, "trusted_service", None)
2620
+ owns_pending_service = bool(
2621
+ pending_service is not None
2622
+ and pending_service is not getattr(self, "_trusted_read_service", None)
2623
+ )
2624
+ if owns_pending_service:
2625
+ pending_services = getattr(
2626
+ self,
2627
+ "_pending_trusted_read_services",
2628
+ None,
2629
+ )
2630
+ if pending_services is None:
2631
+ pending_services = {}
2632
+ self._pending_trusted_read_services = pending_services
2633
+ pending_services[generation] = pending_service
2634
+ self._database_load_state["worker"] = worker
2635
+ self._database_load_state["trusted_service"] = pending_service
2636
+ self._database_load_state["owns_trusted_service"] = owns_pending_service
2637
+ worker.signals.status.connect(self.database_load_status)
2638
+ worker.signals.finished.connect(
2639
+ lambda callback_generation, callback_path, runs, error,
2640
+ load_worker=worker: self.database_load_finished(
2641
+ callback_generation,
2642
+ callback_path,
2643
+ runs,
2644
+ error,
2645
+ load_worker,
2646
+ )
2647
+ )
2648
+ self.databaseLoadThreadPool.start(worker)
2649
+ return True
2650
+
2651
+
2652
+ def _prepare_test_database_replacement(self, abspath):
2653
+ """Capture and release a loaded source before tentative publication."""
2654
+ monitor = getattr(self, "monitor", None)
2655
+ monitor_is_active = getattr(monitor, "isActive", None)
2656
+ if callable(monitor_is_active):
2657
+ monitor_was_active = bool(monitor_is_active())
2658
+ else:
2659
+ monitor_was_active = self._main_refresh_interval() > 0
2660
+ monitor_interval = getattr(monitor, "interval", None)
2661
+ if callable(monitor_interval):
2662
+ monitor_interval_ms = int(monitor_interval())
2663
+ else:
2664
+ monitor_interval_ms = max(
2665
+ 1,
2666
+ round(self._main_refresh_interval() * 1000),
2667
+ )
2668
+
2669
+ selected_dataset = getattr(self, "ds", None)
2670
+ selected_guid = getattr(self, "_selected_run_guid", None)
2671
+ if selected_guid is None:
2672
+ selected_guid = getattr(selected_dataset, "guid", None)
2673
+ if selected_guid is None:
2674
+ selected_items = getattr(self.RunList, "selectedItems", None)
2675
+ if callable(selected_items):
2676
+ items = selected_items()
2677
+ if len(items) == 1:
2678
+ selected_guid = getattr(items[0], "guid", None)
2679
+
2680
+ run_id_box = getattr(self, "run_idBox", None)
2681
+ measurement_box = getattr(self, "measurementBox", None)
2682
+ details_tab = getattr(self, "infoBox", None)
2683
+ current_tab_index = getattr(details_tab, "currentIndex", None)
2684
+ loaded_instance = getattr(self, "_loaded_database_instance", None)
2685
+ if (
2686
+ not isinstance(loaded_instance, DatabaseInstance)
2687
+ or loaded_instance.logical_path != logical_database_path(abspath)
2688
+ ):
2689
+ loaded_instance = database_instance(abspath)
2690
+
2691
+ state = _TestDatabaseReplacementState(
2692
+ database_path=str(abspath),
2693
+ original_instance=loaded_instance,
2694
+ selected_guid=selected_guid,
2695
+ selected_run_id=getattr(self, "selected_run_id", None),
2696
+ run_id_text=run_id_box.text() if run_id_box is not None else "",
2697
+ measurement_text=(
2698
+ measurement_box.text() if measurement_box is not None else "*"
2699
+ ),
2700
+ details_tab_index=(
2701
+ int(current_tab_index()) if callable(current_tab_index) else None
2702
+ ),
2703
+ monitor_was_active=monitor_was_active,
2704
+ monitor_interval_ms=monitor_interval_ms,
2705
+ )
2706
+ self._test_database_replacement_state = state
2707
+ self._database_view_released_for_generation = True
2708
+ self._release_database_runtime_state(abspath)
2709
+ DatabaseActionsMixin._sync_database_generation_controls(self)
2710
+ return state
2711
+
2712
+
2713
+ def _prepare_replaced_database_reload(self, abspath):
2714
+ """Quarantine and discard objects after replacement is confirmed."""
2715
+ quarantine_wal_for_replaced_database(abspath)
2716
+ self._release_database_runtime_state(abspath)
2717
+
2718
+
2719
+ def _release_database_runtime_state(self, abspath):
2720
+ """Release database consumers without asserting that publication won."""
2721
+ old_instance = getattr(self, "_loaded_database_instance", None)
2722
+ derived_bridge = getattr(self, "_trusted_derived_bridge", None)
2723
+ if derived_bridge is not None:
2724
+ derived_bridge.clear_database()
2725
+ stop_refresh_timer = getattr(self, "_stop_automatic_refresh_timer", None)
2726
+ if callable(stop_refresh_timer):
2727
+ stop_refresh_timer()
2728
+ else:
2729
+ self.monitor.stop()
2730
+ # Once consumers have been released, this is no longer a committed
2731
+ # source even though the path remains displayed while replacement
2732
+ # loading is in progress.
2733
+ self._loaded_database_identity = None
2734
+ self._loaded_database_instance = None
2735
+ DatabaseActionsMixin._cancel_database_refresh(self)
2736
+ self._cancel_database_detail_load()
2737
+ DatabaseActionsMixin._cancel_selected_run_detail(self)
2738
+ DatabaseActionsMixin._cancel_database_info(self)
2739
+ active_service = getattr(self, "_trusted_read_service", None)
2740
+ self._trusted_read_service = None
2741
+ DatabaseActionsMixin._retire_trusted_read_service(
2742
+ self,
2743
+ active_service,
2744
+ force=True,
2745
+ )
2746
+ self._database_access_mode = None
2747
+ self._database_fallback_reason = None
2748
+ self._selected_run_detail_cache = {}
2749
+ self._selected_run_partial_detail_keys = set()
2750
+
2751
+ cancel_plot_work = getattr(self, "_cancel_plot_work", None)
2752
+ if callable(cancel_plot_work):
2753
+ cancel_plot_work()
2754
+
2755
+ invalidate_runtime_state = getattr(
2756
+ self,
2757
+ "_invalidate_database_runtime_state",
2758
+ None,
2759
+ )
2760
+ if callable(invalidate_runtime_state):
2761
+ invalidate_runtime_state(old_instance or abspath)
2762
+
2763
+ run_id_signals_blocked = self.run_idBox.blockSignals(True)
2764
+ run_list_signals_blocked = self.RunList.blockSignals(True)
2765
+ try:
2766
+ self._prepare_database_load_ui(abspath)
2767
+ self.infoBox.preview.set_database_runs(abspath, {})
2768
+ finally:
2769
+ self.RunList.blockSignals(run_list_signals_blocked)
2770
+ self.run_idBox.blockSignals(run_id_signals_blocked)
2771
+ self._sync_empty_state()
2772
+
2773
+
2774
+ def _prepare_database_load_ui(
2775
+ self,
2776
+ abspath,
2777
+ *,
2778
+ release_selected=True,
2779
+ clear_details=True,
2780
+ update_path=True,
2781
+ ):
2782
+ """
2783
+ Replaces the main-window state with a successfully loaded database.
2784
+
2785
+ """
2786
+ self.run_idBox.setText("")
2787
+ self.measurementBox.setText("*")
2788
+ self.selected_run_id = None
2789
+ self._selected_run_guid = None
2790
+ if release_selected:
2791
+ release_selected_dataset = getattr(
2792
+ self,
2793
+ "_release_selected_dataset",
2794
+ None,
2795
+ )
2796
+ if callable(release_selected_dataset):
2797
+ release_selected_dataset()
2798
+ else:
2799
+ self.ds = None
2800
+ self._selected_dataset_key = None
2801
+
2802
+ self.RunList.clearSelection()
2803
+ self.RunList.clear()
2804
+ self.RunList.watching = []
2805
+ self.RunList.maxRunId = 0
2806
+ self.RunList.scrollToTop()
2807
+
2808
+ if clear_details:
2809
+ self.infoBox.clear()
2810
+ clear_summary_cache = getattr(
2811
+ self,
2812
+ "_clear_snapshot_setpoint_summary_cache",
2813
+ None,
2814
+ )
2815
+ if callable(clear_summary_cache):
2816
+ clear_summary_cache()
2817
+ clear_database_cache = getattr(self.infoBox, "clear_database_cache", None)
2818
+ if callable(clear_database_cache):
2819
+ clear_database_cache()
2820
+ self.infoBox.scrollToTop()
2821
+
2822
+ if update_path:
2823
+ if self.fileTextbox.text() and self.fileTextbox.text() != self.localLastFile:
2824
+ self.localLastFile = self.fileTextbox.text()
2825
+
2826
+ self.fileTextbox.setText(abspath)
2827
+
2828
+
2829
+ def _capture_database_load_publication(self):
2830
+ """Capture the committed view while a pending database is staged."""
2831
+ preview = self.infoBox.preview
2832
+ preview_path = getattr(preview, "database_path", None)
2833
+ if preview_path is None:
2834
+ database_runs = getattr(preview, "database_runs", ("", {}))
2835
+ preview_path = database_runs[0] if database_runs else ""
2836
+ return {
2837
+ "database_path": self.fileTextbox.text(),
2838
+ "runs": dict(self.RunList.all_run_metadata()),
2839
+ "run_id_text": self.run_idBox.text(),
2840
+ "measurement_text": self.measurementBox.text(),
2841
+ "selected_run_id": getattr(self, "selected_run_id", None),
2842
+ "selected_run_guid": getattr(self, "_selected_run_guid", None),
2843
+ "selected_dataset": getattr(self, "ds", None),
2844
+ "selected_dataset_key": getattr(self, "_selected_dataset_key", None),
2845
+ "local_last_file": getattr(self, "localLastFile", ""),
2846
+ "preview_path": preview_path or "",
2847
+ "trusted_service": getattr(self, "_trusted_read_service", None),
2848
+ "access_mode": getattr(self, "_database_access_mode", None),
2849
+ "fallback_reason": getattr(self, "_database_fallback_reason", None),
2850
+ "database_identity": getattr(self, "_loaded_database_identity", None),
2851
+ "database_instance": getattr(self, "_loaded_database_instance", None),
2852
+ "qcodes_database": get_DB_location(),
2853
+ }
2854
+
2855
+
2856
+ def _restore_database_load_publication(
2857
+ self,
2858
+ snapshot,
2859
+ *,
2860
+ continue_loading=None,
2861
+ ):
2862
+ """Restore A after B fails before its publication transaction commits."""
2863
+ self._trusted_read_service = snapshot["trusted_service"]
2864
+ self._database_access_mode = snapshot["access_mode"]
2865
+ self._database_fallback_reason = snapshot["fallback_reason"]
2866
+ self._loaded_database_identity = snapshot["database_identity"]
2867
+ self._loaded_database_instance = snapshot["database_instance"]
2868
+ self.selected_run_id = snapshot["selected_run_id"]
2869
+ self._selected_run_guid = snapshot["selected_run_guid"]
2870
+ self.ds = snapshot["selected_dataset"]
2871
+ self._selected_dataset_key = snapshot["selected_dataset_key"]
2872
+ self.localLastFile = snapshot["local_last_file"]
2873
+ self.run_idBox.setText(snapshot["run_id_text"])
2874
+ self.measurementBox.setText(snapshot["measurement_text"])
2875
+ self.RunList.clearSelection()
2876
+ self.RunList.clear()
2877
+ self.RunList.watching = []
2878
+ restored = self.RunList.addRuns(
2879
+ snapshot["runs"],
2880
+ continue_loading=continue_loading,
2881
+ )
2882
+ if restored is False:
2883
+ return False
2884
+ self.fileTextbox.setText(snapshot["database_path"])
2885
+ self.infoBox.preview.set_database_runs(
2886
+ snapshot["preview_path"],
2887
+ snapshot["runs"],
2888
+ )
2889
+ set_qcodes_database_location(snapshot["qcodes_database"])
2890
+ return True
2891
+
2892
+
2893
+ def _set_database_load_controls_enabled(self, enabled):
2894
+ """
2895
+ Enables or disables controls that start overlapping database actions.
2896
+
2897
+ """
2898
+ for attr in (
2899
+ "loadDatabaseButton",
2900
+ "openDatabaseFolderButton",
2901
+ ):
2902
+ widget = getattr(self, attr, None)
2903
+ if widget is not None:
2904
+ widget.setEnabled(enabled)
2905
+ DatabaseActionsMixin._sync_database_generation_controls(self)
2906
+
2907
+
2908
+ def _show_database_load_panel(self, message):
2909
+ """
2910
+ Shows the inline database-load progress panel.
2911
+
2912
+ """
2913
+ if hasattr(self, "databaseLoadLabel"):
2914
+ self.databaseLoadLabel.setText(message)
2915
+ self.databaseLoadLabel.setToolTip(message)
2916
+ if hasattr(self, "databaseLoadFrame"):
2917
+ self.databaseLoadFrame.setVisible(True)
2918
+ self.show_status(message, 0)
2919
+
2920
+
2921
+ def _hide_database_load_panel(self):
2922
+ """
2923
+ Hides the inline database-load progress panel.
2924
+
2925
+ """
2926
+ if hasattr(self, "databaseLoadLabel"):
2927
+ self.databaseLoadLabel.setText("")
2928
+ self.databaseLoadLabel.setToolTip("")
2929
+ if hasattr(self, "databaseLoadFrame"):
2930
+ self.databaseLoadFrame.setVisible(False)
2931
+
2932
+
2933
+ @QtCore.pyqtSlot()
2934
+ def cancel_database_load(self):
2935
+ """
2936
+ Cancels the pending database load without changing the current view.
2937
+
2938
+ """
2939
+ if not getattr(self, "_database_load_active", False):
2940
+ self._hide_database_load_panel()
2941
+ return
2942
+
2943
+ worker = getattr(self, "_database_load_worker", None)
2944
+ if worker is not None:
2945
+ worker.cancel()
2946
+
2947
+ cancelled_generation = getattr(self, "_database_load_generation", 0)
2948
+ pending_service = getattr(
2949
+ self,
2950
+ "_pending_trusted_read_services",
2951
+ {},
2952
+ ).pop(cancelled_generation, None)
2953
+ DatabaseActionsMixin._retire_trusted_read_service(
2954
+ self,
2955
+ pending_service,
2956
+ )
2957
+
2958
+ self._database_load_generation += 1
2959
+ self._database_load_active = False
2960
+ self._database_load_state = None
2961
+ self._database_load_worker = None
2962
+ self._set_database_load_controls_enabled(True)
2963
+
2964
+ self._hide_database_load_panel()
2965
+ self.show_status("Database load cancelled.", 3000)
2966
+ DatabaseActionsMixin._resume_test_database_generation_recovery(self)
2967
+ apply_refresh_interval = getattr(self, "_apply_refresh_interval", None)
2968
+ if callable(apply_refresh_interval):
2969
+ apply_refresh_interval(self._main_refresh_interval())
2970
+
2971
+
2972
+ @QtCore.pyqtSlot(int, str)
2973
+ def database_load_status(self, generation, message):
2974
+ """
2975
+ Shows progress from the active database load.
2976
+
2977
+ """
2978
+ if generation != self._database_load_generation or not self._database_load_active:
2979
+ return
2980
+
2981
+ self._show_database_load_panel(message)
2982
+
2983
+
2984
+ @QtCore.pyqtSlot(int, str, object, object)
2985
+ def database_load_finished(
2986
+ self,
2987
+ generation,
2988
+ abspath,
2989
+ runs,
2990
+ error,
2991
+ load_worker=None,
2992
+ ):
2993
+ """
2994
+ Applies the background database load result on the GUI thread.
2995
+
2996
+ """
2997
+ pending_service = getattr(
2998
+ self,
2999
+ "_pending_trusted_read_services",
3000
+ {},
3001
+ ).pop(generation, None)
3002
+ if generation != self._database_load_generation:
3003
+ DatabaseActionsMixin._retire_trusted_read_service(
3004
+ self,
3005
+ pending_service,
3006
+ )
3007
+ return
3008
+ if not getattr(self, "_database_load_active", False):
3009
+ DatabaseActionsMixin._retire_trusted_read_service(
3010
+ self,
3011
+ pending_service,
3012
+ )
3013
+ return
3014
+
3015
+ state = self._database_load_state or {}
3016
+ if state.get("abspath") != abspath:
3017
+ DatabaseActionsMixin._retire_trusted_read_service(
3018
+ self,
3019
+ pending_service,
3020
+ )
3021
+ return
3022
+ if load_worker is None:
3023
+ load_worker = state.get("worker")
3024
+ if pending_service is None:
3025
+ pending_service = state.get("trusted_service")
3026
+ owns_pending_service = bool(
3027
+ state.get(
3028
+ "owns_trusted_service",
3029
+ pending_service is not None
3030
+ and pending_service
3031
+ is not getattr(self, "_trusted_read_service", None),
3032
+ )
3033
+ )
3034
+ access_mode = getattr(
3035
+ load_worker,
3036
+ "access_mode",
3037
+ SNAPSHOT_FALLBACK_MODE,
3038
+ ) or SNAPSHOT_FALLBACK_MODE
3039
+ fallback_reason = getattr(load_worker, "fallback_reason", None)
3040
+
3041
+ self._database_load_active = False
3042
+ self._database_load_state = None
3043
+ self._database_load_worker = None
3044
+ self._set_database_load_controls_enabled(True)
3045
+ self._hide_database_load_panel()
3046
+ load_started_at = state.get("load_started_at") or perf_counter()
3047
+ load_identity = state.get("load_identity")
3048
+ load_instance = state.get("load_instance")
3049
+ current_instance = database_instance(abspath)
3050
+
3051
+ if (
3052
+ isinstance(error, DatabaseInstanceChangedError)
3053
+ or _database_instances_or_sidecars_differ(
3054
+ load_instance or load_identity,
3055
+ current_instance,
3056
+ )
3057
+ ):
3058
+ if owns_pending_service:
3059
+ DatabaseActionsMixin._retire_trusted_read_service(
3060
+ self,
3061
+ pending_service,
3062
+ )
3063
+ self._database_load_generation += 1
3064
+ retry_generation = self._database_load_generation
3065
+ self.show_status("Database changed while loading; retrying...", 0)
3066
+ generation_recovery = bool(state.get("generation_recovery"))
3067
+ QtCore.QTimer.singleShot(
3068
+ 0,
3069
+ lambda: DatabaseActionsMixin._retry_replaced_database_if_current(
3070
+ self,
3071
+ retry_generation,
3072
+ abspath,
3073
+ generation_recovery=generation_recovery,
3074
+ load_started_at=load_started_at,
3075
+ ),
3076
+ )
3077
+ return
3078
+
3079
+ if error is not None:
3080
+ if owns_pending_service:
3081
+ DatabaseActionsMixin._retire_trusted_read_service(
3082
+ self,
3083
+ pending_service,
3084
+ )
3085
+ log_exception("Database load failed", error, __name__)
3086
+ if isinstance(error, UnverifiableDatabaseWalError):
3087
+ self.show_error(
3088
+ "Unverifiable Database WAL",
3089
+ "qPlot refused to load this database because its WAL "
3090
+ "cannot be verified. Close every owning QCoDeS/SQLite "
3091
+ "connection cleanly, or checkpoint with the owning writer, "
3092
+ "then retry loading the database.",
3093
+ str(error),
3094
+ )
3095
+ DatabaseActionsMixin._resume_test_database_generation_recovery(self)
3096
+ apply_refresh_interval = getattr(self, "_apply_refresh_interval", None)
3097
+ if callable(apply_refresh_interval):
3098
+ apply_refresh_interval(self._main_refresh_interval())
3099
+ return
3100
+ self.show_error(
3101
+ "Database Load Failed",
3102
+ f"Could not load database {abspath}.",
3103
+ str(error),
3104
+ )
3105
+ DatabaseActionsMixin._resume_test_database_generation_recovery(self)
3106
+ apply_refresh_interval = getattr(self, "_apply_refresh_interval", None)
3107
+ if callable(apply_refresh_interval):
3108
+ apply_refresh_interval(self._main_refresh_interval())
3109
+ return
3110
+
3111
+ # This observation is the transaction's publication point. A failed
3112
+ # or stale B load returns above while A and its service are still
3113
+ # committed. Only a B instance revalidated here may replace A; any
3114
+ # later source change is a new replacement event caught by the final
3115
+ # post-publication guard below.
3116
+ accepted_instance = current_instance
3117
+ accepted_identity = accepted_instance.identity
3118
+ if access_mode == TRUSTED_LIVE_MODE:
3119
+ if (
3120
+ pending_service is None
3121
+ or not getattr(pending_service, "accepted", False)
3122
+ # The helper accepts its source before SQLite may create the
3123
+ # permitted exact SHM. Directional comparison accepts that
3124
+ # first appearance, but never normalises removal or
3125
+ # replacement of a sidecar the helper already accepted.
3126
+ or _database_instances_or_sidecars_differ(
3127
+ pending_service.database_instance,
3128
+ current_instance,
3129
+ )
3130
+ ):
3131
+ if owns_pending_service:
3132
+ DatabaseActionsMixin._retire_trusted_read_service(
3133
+ self,
3134
+ pending_service,
3135
+ )
3136
+ self.show_error(
3137
+ "Trusted Database Load Failed",
3138
+ "The trusted reader did not remain bound to the accepted "
3139
+ "database instance.",
3140
+ "The pending session was retired without changing the "
3141
+ "currently displayed database.",
3142
+ )
3143
+ DatabaseActionsMixin._resume_test_database_generation_recovery(self)
3144
+ apply_refresh_interval = getattr(
3145
+ self,
3146
+ "_apply_refresh_interval",
3147
+ None,
3148
+ )
3149
+ if callable(apply_refresh_interval):
3150
+ apply_refresh_interval(self._main_refresh_interval())
3151
+ return
3152
+ elif access_mode != SNAPSHOT_FALLBACK_MODE:
3153
+ if owns_pending_service:
3154
+ DatabaseActionsMixin._retire_trusted_read_service(
3155
+ self,
3156
+ pending_service,
3157
+ )
3158
+ self.show_error(
3159
+ "Database Load Failed",
3160
+ "The database worker returned an unknown access mode.",
3161
+ str(access_mode),
3162
+ )
3163
+ DatabaseActionsMixin._resume_test_database_generation_recovery(self)
3164
+ apply_refresh_interval = getattr(
3165
+ self,
3166
+ "_apply_refresh_interval",
3167
+ None,
3168
+ )
3169
+ if callable(apply_refresh_interval):
3170
+ apply_refresh_interval(self._main_refresh_interval())
3171
+ return
3172
+
3173
+ publication_snapshot = (
3174
+ DatabaseActionsMixin._capture_database_load_publication(self)
3175
+ )
3176
+ old_service = publication_snapshot["trusted_service"]
3177
+ new_service = (
3178
+ pending_service if access_mode == TRUSTED_LIVE_MODE else None
3179
+ )
3180
+ runs = runs or {}
3181
+ publication_error = None
3182
+ publication_source_changed = False
3183
+ publication_aborted = False
3184
+ publication_restored = False
3185
+ publication_committed = False
3186
+ publication_rollback_error = None
3187
+ publication_rollback_source_changed = False
3188
+
3189
+ # A nested event yield must not let A's already-running metadata jobs
3190
+ # merge into the staged B model. Their public cancellation is prompt;
3191
+ # rollback below explicitly restarts A's progressive work.
3192
+ self._cancel_database_detail_load()
3193
+ DatabaseActionsMixin._cancel_selected_run_detail(self)
3194
+ DatabaseActionsMixin._cancel_database_info(self)
3195
+ derived_bridge = getattr(self, "_trusted_derived_bridge", None)
3196
+ if derived_bridge is not None:
3197
+ derived_bridge.suspend_publications()
3198
+
3199
+ # Keep every owned pending session reachable by close_database()/quit
3200
+ # while addRuns deliberately pumps Qt events. Snapshot fallback has
3201
+ # already closed its rejected broker, but qPlot must still retain that
3202
+ # object until shutdown completion is observable.
3203
+ if pending_service is not None and owns_pending_service:
3204
+ self._pending_trusted_read_services[generation] = pending_service
3205
+
3206
+ def publication_is_current():
3207
+ return bool(
3208
+ getattr(self, "_database_load_publication_active", False)
3209
+ and generation == getattr(self, "_database_load_generation", 0)
3210
+ and not getattr(self, "_shutdown_started", False)
3211
+ and not getattr(self, "_shutdown_ready", False)
3212
+ )
3213
+
3214
+ self._database_load_publication_active = True
3215
+ DatabaseActionsMixin._sync_database_generation_controls(self)
3216
+ run_id_signals_blocked = self.run_idBox.blockSignals(True)
3217
+ run_list_signals_blocked = self.RunList.blockSignals(True)
3218
+ set_preview_publication_suspended = getattr(
3219
+ self.RunList,
3220
+ "set_preview_publication_suspended",
3221
+ None,
3222
+ )
3223
+ preview_publication_was_suspended = False
3224
+ preview_publication_gate_set = False
3225
+ try:
3226
+ if callable(set_preview_publication_suspended):
3227
+ preview_publication_was_suspended = (
3228
+ set_preview_publication_suspended(True)
3229
+ )
3230
+ preview_publication_gate_set = True
3231
+ try:
3232
+ # Stage B's run model while every action remains gated and A is
3233
+ # still the committed service/instance. Only after staging and
3234
+ # one final source observation do the plain controller fields
3235
+ # advance together to B.
3236
+ self._prepare_database_load_ui(
3237
+ abspath,
3238
+ release_selected=False,
3239
+ clear_details=False,
3240
+ update_path=False,
3241
+ )
3242
+ staged = self.RunList.addRuns(
3243
+ runs,
3244
+ continue_loading=publication_is_current,
3245
+ )
3246
+ if staged is False or not publication_is_current():
3247
+ raise _DatabaseLoadPublicationAborted(
3248
+ "The database view was closed while B was staged."
3249
+ )
3250
+ staged_instance = database_instance(abspath)
3251
+ if _database_instances_or_sidecars_differ(
3252
+ accepted_instance,
3253
+ staged_instance,
3254
+ ):
3255
+ raise DatabaseInstanceChangedError(
3256
+ "The database changed while its loaded view was staged."
3257
+ )
3258
+
3259
+ set_qcodes_database_location(abspath)
3260
+ self._trusted_read_service = new_service
3261
+ self._database_access_mode = access_mode
3262
+ self._database_fallback_reason = fallback_reason
3263
+ self._loaded_database_identity = accepted_identity
3264
+ # A WAL/SHM that appeared while B was staged is compatible,
3265
+ # but it is now an accepted identity that later guards must
3266
+ # retain in order to detect replacement or ABA.
3267
+ self._loaded_database_instance = staged_instance
3268
+ prior_path = publication_snapshot["database_path"]
3269
+ if prior_path and prior_path != self.localLastFile:
3270
+ self.localLastFile = prior_path
3271
+ self.fileTextbox.setText(abspath)
3272
+ if access_mode == TRUSTED_LIVE_MODE:
3273
+ self.infoBox.preview.set_database_runs("", {})
3274
+ show_run_preview_placeholders = getattr(
3275
+ self.RunList,
3276
+ "show_run_preview_placeholders",
3277
+ None,
3278
+ )
3279
+ if callable(show_run_preview_placeholders):
3280
+ show_run_preview_placeholders()
3281
+ else:
3282
+ self.infoBox.preview.set_database_runs(abspath, runs)
3283
+ self.infoBox.clear()
3284
+ clear_summary_cache = getattr(
3285
+ self,
3286
+ "_clear_snapshot_setpoint_summary_cache",
3287
+ None,
3288
+ )
3289
+ if callable(clear_summary_cache):
3290
+ clear_summary_cache()
3291
+ clear_database_cache = getattr(
3292
+ self.infoBox,
3293
+ "clear_database_cache",
3294
+ None,
3295
+ )
3296
+ if callable(clear_database_cache):
3297
+ clear_database_cache()
3298
+ self.infoBox.scrollToTop()
3299
+ release_selected = getattr(
3300
+ self,
3301
+ "_release_selected_dataset",
3302
+ None,
3303
+ )
3304
+ if callable(release_selected):
3305
+ release_selected()
3306
+ else:
3307
+ self.ds = None
3308
+ self._selected_dataset_key = None
3309
+ publication_committed = True
3310
+ except _DatabaseLoadPublicationAborted:
3311
+ publication_aborted = True
3312
+ except DatabaseInstanceChangedError as publication_exception:
3313
+ publication_error = publication_exception
3314
+ publication_source_changed = True
3315
+ except Exception as publication_exception:
3316
+ publication_error = publication_exception
3317
+
3318
+ if publication_committed:
3319
+ # The controller fields now name B. Transfer every remaining
3320
+ # service immediately, before signal restoration or control
3321
+ # synchronisation in the enclosing finally can raise.
3322
+ if old_service is not new_service:
3323
+ # The old coordinator may still own a bounded read against
3324
+ # A. Retire it at the same commit boundary that transfers
3325
+ # the trusted service, before the queued Stage 5C bind
3326
+ # creates B's coordinator. This keeps stale work harmless
3327
+ # and promptly releases Windows reader locks.
3328
+ clear_derived = getattr(
3329
+ derived_bridge,
3330
+ "clear_database",
3331
+ None,
3332
+ )
3333
+ if callable(clear_derived):
3334
+ clear_derived()
3335
+ DatabaseActionsMixin._retire_trusted_read_service(
3336
+ self,
3337
+ old_service,
3338
+ force=False,
3339
+ )
3340
+ if access_mode != TRUSTED_LIVE_MODE and owns_pending_service:
3341
+ DatabaseActionsMixin._retire_trusted_read_service(
3342
+ self,
3343
+ pending_service,
3344
+ force=True,
3345
+ )
3346
+
3347
+ if publication_error is not None:
3348
+ try:
3349
+ rollback_path = publication_snapshot["database_path"]
3350
+ rollback_instance = publication_snapshot["database_instance"]
3351
+ rollback_current_instance = None
3352
+ if (
3353
+ rollback_path
3354
+ and isinstance(rollback_instance, DatabaseInstance)
3355
+ ):
3356
+ rollback_current_instance = database_instance(rollback_path)
3357
+ if _database_instances_or_sidecars_differ(
3358
+ rollback_instance,
3359
+ rollback_current_instance,
3360
+ ):
3361
+ publication_rollback_source_changed = True
3362
+ raise DatabaseInstanceChangedError(
3363
+ "The previously displayed database changed while "
3364
+ "the replacement view was staged."
3365
+ )
3366
+ restored = (
3367
+ DatabaseActionsMixin._restore_database_load_publication(
3368
+ self,
3369
+ publication_snapshot,
3370
+ continue_loading=publication_is_current,
3371
+ )
3372
+ )
3373
+ if restored is False or not publication_is_current():
3374
+ publication_error = None
3375
+ publication_source_changed = False
3376
+ publication_aborted = True
3377
+ else:
3378
+ if rollback_current_instance is not None:
3379
+ rollback_final_instance = database_instance(rollback_path)
3380
+ if _database_instances_or_sidecars_differ(
3381
+ rollback_current_instance,
3382
+ rollback_final_instance,
3383
+ ):
3384
+ publication_rollback_source_changed = True
3385
+ raise DatabaseInstanceChangedError(
3386
+ "The previously displayed database changed "
3387
+ "while its view was restored."
3388
+ )
3389
+ DatabaseActionsMixin._advance_loaded_database_sidecar_baseline(
3390
+ self,
3391
+ rollback_final_instance,
3392
+ )
3393
+ publication_restored = True
3394
+ except Exception as rollback_exception:
3395
+ publication_rollback_error = rollback_exception
3396
+ publication_error = None
3397
+ publication_source_changed = False
3398
+ publication_aborted = True
3399
+ # A partially restored controller must not retain either
3400
+ # reader or publish a mixture of A and B. This path uses
3401
+ # only normal controller teardown and filesystem identity
3402
+ # observations; it performs no database read.
3403
+ DatabaseActionsMixin._retire_trusted_read_service(
3404
+ self,
3405
+ old_service,
3406
+ force=True,
3407
+ )
3408
+ self.close_database(status=False)
3409
+
3410
+ if publication_aborted:
3411
+ # close_database() may have cleared the tree during a nested
3412
+ # event and addRuns may then have appended one final row before
3413
+ # observing cancellation. Leave the closed view truly empty.
3414
+ self.RunList.clearSelection()
3415
+ self.RunList.clear()
3416
+ self.RunList.watching = []
3417
+ self.RunList.maxRunId = 0
3418
+ finally:
3419
+ owned_pending = getattr(
3420
+ self,
3421
+ "_pending_trusted_read_services",
3422
+ {},
3423
+ ).pop(generation, None)
3424
+ if not publication_committed and owned_pending is not None:
3425
+ DatabaseActionsMixin._retire_trusted_read_service(
3426
+ self,
3427
+ owned_pending,
3428
+ force=True,
3429
+ )
3430
+ try:
3431
+ self.RunList.blockSignals(run_list_signals_blocked)
3432
+ self.run_idBox.blockSignals(run_id_signals_blocked)
3433
+ finally:
3434
+ try:
3435
+ if (
3436
+ preview_publication_gate_set
3437
+ and callable(set_preview_publication_suspended)
3438
+ ):
3439
+ set_preview_publication_suspended(
3440
+ preview_publication_was_suspended,
3441
+ )
3442
+ finally:
3443
+ self._database_load_publication_active = False
3444
+ DatabaseActionsMixin._sync_database_generation_controls(self)
3445
+
3446
+ if publication_aborted:
3447
+ if publication_rollback_error is not None:
3448
+ log_exception(
3449
+ "Database view rollback failed",
3450
+ publication_rollback_error,
3451
+ __name__,
3452
+ )
3453
+ self.show_error(
3454
+ "Database Recovery Failed",
3455
+ "qPlot closed the database view because its previous "
3456
+ "state could not be restored safely.",
3457
+ str(publication_rollback_error),
3458
+ )
3459
+ if publication_rollback_source_changed:
3460
+ retry_generation = getattr(
3461
+ self,
3462
+ "_database_load_generation",
3463
+ 0,
3464
+ )
3465
+ rollback_path = publication_snapshot["database_path"]
3466
+ self.show_status(
3467
+ "Previously displayed database changed; retrying...",
3468
+ 0,
3469
+ )
3470
+ QtCore.QTimer.singleShot(
3471
+ 0,
3472
+ lambda: (
3473
+ DatabaseActionsMixin
3474
+ ._retry_replaced_database_if_current(
3475
+ self,
3476
+ retry_generation,
3477
+ rollback_path,
3478
+ load_started_at=load_started_at,
3479
+ )
3480
+ ),
3481
+ )
3482
+ return
3483
+
3484
+ if publication_restored:
3485
+ if derived_bridge is not None:
3486
+ derived_bridge.resume_publications()
3487
+ rollback_path = publication_snapshot["database_path"]
3488
+ rollback_runs = publication_snapshot["runs"]
3489
+ if rollback_path:
3490
+ self._start_database_detail_load(rollback_path, rollback_runs)
3491
+ if (
3492
+ publication_snapshot["access_mode"] == TRUSTED_LIVE_MODE
3493
+ and publication_snapshot["selected_run_guid"]
3494
+ and callable(
3495
+ getattr(self.RunList, "_item_for_guid", None)
3496
+ )
3497
+ ):
3498
+ self._update_trusted_selected_run(
3499
+ publication_snapshot["selected_run_guid"]
3500
+ )
3501
+
3502
+ if publication_error is not None:
3503
+ if owns_pending_service and pending_service is not old_service:
3504
+ DatabaseActionsMixin._retire_trusted_read_service(
3505
+ self,
3506
+ pending_service,
3507
+ )
3508
+ if publication_source_changed:
3509
+ self._database_load_generation += 1
3510
+ retry_generation = self._database_load_generation
3511
+ self.show_status("Database changed while loading; retrying...", 0)
3512
+ QtCore.QTimer.singleShot(
3513
+ 0,
3514
+ lambda: DatabaseActionsMixin._retry_replaced_database_if_current(
3515
+ self,
3516
+ retry_generation,
3517
+ abspath,
3518
+ generation_recovery=bool(state.get("generation_recovery")),
3519
+ load_started_at=load_started_at,
3520
+ ),
3521
+ )
3522
+ return
3523
+ log_exception(
3524
+ "Database view publication failed",
3525
+ publication_error,
3526
+ __name__,
3527
+ )
3528
+ self.show_error(
3529
+ "Database Load Failed",
3530
+ f"Could not publish database {abspath}.",
3531
+ str(publication_error),
3532
+ )
3533
+ DatabaseActionsMixin._resume_test_database_generation_recovery(self)
3534
+ apply_refresh_interval = getattr(self, "_apply_refresh_interval", None)
3535
+ if callable(apply_refresh_interval):
3536
+ apply_refresh_interval(self._current_refresh_interval())
3537
+ return
3538
+
3539
+ final_instance = database_instance(abspath)
3540
+ if _database_instances_or_sidecars_differ(staged_instance, final_instance):
3541
+ self._reload_replaced_database(
3542
+ abspath,
3543
+ generation_recovery=bool(state.get("generation_recovery")),
3544
+ )
3545
+ return
3546
+ DatabaseActionsMixin._advance_loaded_database_sidecar_baseline(
3547
+ self,
3548
+ final_instance,
3549
+ )
3550
+
3551
+ self._selected_run_detail_cache = {}
3552
+ self._selected_run_partial_detail_keys = set()
3553
+ if state.get("reload_same_path") and not state.get("replacement_reload"):
3554
+ invalidate_runtime_state = getattr(
3555
+ self,
3556
+ "_invalidate_database_runtime_state",
3557
+ None,
3558
+ )
3559
+ if callable(invalidate_runtime_state):
3560
+ invalidate_runtime_state(
3561
+ publication_snapshot["database_instance"] or abspath
3562
+ )
3563
+ replacement_state = getattr(
3564
+ self,
3565
+ "_test_database_replacement_state",
3566
+ None,
3567
+ )
3568
+ transaction_matches = bool(
3569
+ replacement_state is not None
3570
+ and logical_database_path(replacement_state.database_path)
3571
+ == logical_database_path(abspath)
3572
+ )
3573
+ recovering_original = bool(
3574
+ transaction_matches
3575
+ and replacement_state.outcome == "recovering-original"
3576
+ and not _database_observations_differ(
3577
+ replacement_state.original_instance,
3578
+ accepted_instance,
3579
+ )
3580
+ )
3581
+ detached_terminal_transaction = bool(
3582
+ replacement_state is not None
3583
+ and not transaction_matches
3584
+ and not getattr(self, "_test_database_generation_active", False)
3585
+ and getattr(replacement_state, "outcome", None) is not None
3586
+ )
3587
+ if transaction_matches or detached_terminal_transaction:
3588
+ DatabaseActionsMixin._clear_test_database_generation_transaction(self)
3589
+ else:
3590
+ DatabaseActionsMixin._sync_database_generation_controls(self)
3591
+ if recovering_original:
3592
+ self._restore_test_database_replacement_selection(replacement_state)
3593
+ else:
3594
+ self.select_default_run()
3595
+ prioritize_previews = getattr(self, "_prioritize_preview_runs", None)
3596
+ if (
3597
+ access_mode != TRUSTED_LIVE_MODE
3598
+ and callable(prioritize_previews)
3599
+ ):
3600
+ prioritize_previews()
3601
+ self._sync_empty_state()
3602
+ apply_refresh_interval = getattr(self, "_apply_refresh_interval", None)
3603
+ if callable(apply_refresh_interval):
3604
+ apply_refresh_interval(self._current_refresh_interval())
3605
+
3606
+ elapsed = perf_counter() - load_started_at
3607
+ self.remember_loaded_database(abspath)
3608
+ run_count = self.RunList.topLevelItemCount()
3609
+ if state.get("replacement_reload"):
3610
+ run_word = "run" if run_count == 1 else "runs"
3611
+ status = (
3612
+ f"Database was replaced and reloaded: "
3613
+ f"{os.path.basename(abspath)} ({run_count} {run_word})."
3614
+ )
3615
+ if replacement_wal_is_quarantined(abspath):
3616
+ status += " WAL sidecars are ignored for this replacement view."
3617
+ elif run_count == 0:
3618
+ status = self._loaded_empty_database_status(abspath, elapsed)
3619
+ else:
3620
+ status = (
3621
+ f"Loaded {run_count} runs from "
3622
+ f"{os.path.basename(abspath)} in {elapsed:.2f} s."
3623
+ )
3624
+ self.show_status(status, 5000)
3625
+ log_event(
3626
+ "Loaded %s runs from %s in %.2f s",
3627
+ run_count,
3628
+ abspath,
3629
+ elapsed,
3630
+ logger_name=__name__,
3631
+ )
3632
+ if access_mode == TRUSTED_LIVE_MODE:
3633
+ if derived_bridge is not None:
3634
+ QtCore.QTimer.singleShot(
3635
+ 0,
3636
+ lambda: DatabaseActionsMixin._start_trusted_derived_bridge(
3637
+ self,
3638
+ generation,
3639
+ abspath,
3640
+ ),
3641
+ )
3642
+ else:
3643
+ self._start_database_detail_load(abspath, runs)
3644
+ else:
3645
+ if derived_bridge is not None:
3646
+ derived_bridge.clear_database()
3647
+ self._start_database_detail_load(abspath, runs)
3648
+
3649
+
3650
+ def _start_trusted_derived_bridge(self, generation, database_path):
3651
+ """Start Stage 5C only after the cheap run-list commit returned to Qt."""
3652
+
3653
+ if generation != getattr(self, "_database_load_generation", 0):
3654
+ return
3655
+ if getattr(self, "_database_access_mode", None) != TRUSTED_LIVE_MODE:
3656
+ return
3657
+ if not _database_paths_equal(database_path, self.fileTextbox.text()):
3658
+ return
3659
+ if (
3660
+ getattr(self, "_shutdown_started", False)
3661
+ or getattr(self, "_shutdown_ready", False)
3662
+ ):
3663
+ return
3664
+ instance = getattr(self, "_loaded_database_instance", None)
3665
+ service = DatabaseActionsMixin._active_trusted_service_for_instance(
3666
+ self,
3667
+ instance,
3668
+ )
3669
+ bridge = getattr(self, "_trusted_derived_bridge", None)
3670
+ if (
3671
+ not isinstance(instance, DatabaseInstance)
3672
+ or service is None
3673
+ or bridge is None
3674
+ ):
3675
+ return
3676
+ current = database_instance(database_path)
3677
+ if _database_instances_or_sidecars_differ(instance, current):
3678
+ self._reload_replaced_database(database_path)
3679
+ return
3680
+ DatabaseActionsMixin._advance_loaded_database_sidecar_baseline(
3681
+ self,
3682
+ current,
3683
+ )
3684
+ bridge.bind_database(
3685
+ getattr(self, "_loaded_database_instance", current),
3686
+ self.RunList.all_run_metadata(),
3687
+ service,
3688
+ )
3689
+
3690
+
3691
+ def _restore_test_database_replacement_selection(self, state):
3692
+ """Restore the unchanged source's selected run and controller fields."""
3693
+ measurement_box = getattr(self, "measurementBox", None)
3694
+ if measurement_box is not None:
3695
+ measurement_box.setText(state.measurement_text)
3696
+
3697
+ item = None
3698
+ item_for_guid = getattr(self.RunList, "_item_for_guid", None)
3699
+ if state.selected_guid and callable(item_for_guid):
3700
+ item = item_for_guid(state.selected_guid)
3701
+ if item is None and state.selected_run_id is not None:
3702
+ find_items = getattr(self.RunList, "findItems", None)
3703
+ if callable(find_items):
3704
+ matches = find_items(
3705
+ str(state.selected_run_id),
3706
+ QtCore.Qt.MatchFlag.MatchExactly,
3707
+ 0,
3708
+ )
3709
+ if matches:
3710
+ item = matches[0]
3711
+
3712
+ if item is not None:
3713
+ self.RunList.setCurrentItem(item)
3714
+ self.RunList.scrollToItem(
3715
+ item,
3716
+ qtw.QAbstractItemView.ScrollHint.PositionAtCenter,
3717
+ )
3718
+ elif state.run_id_text:
3719
+ self.run_idBox.setText(state.run_id_text)
3720
+ else:
3721
+ self.select_default_run()
3722
+
3723
+ if state.details_tab_index is not None:
3724
+ set_current_index = getattr(self.infoBox, "setCurrentIndex", None)
3725
+ if callable(set_current_index):
3726
+ set_current_index(state.details_tab_index)
3727
+
3728
+
3729
+ def _cancel_database_detail_load(self):
3730
+ worker = getattr(self, "_database_detail_worker", None)
3731
+ if worker is not None:
3732
+ worker.cancel()
3733
+ expensive_worker = getattr(self, "_database_expensive_detail_worker", None)
3734
+ if expensive_worker is not None:
3735
+ expensive_worker.cancel()
3736
+
3737
+ self._database_detail_generation = (
3738
+ getattr(self, "_database_detail_generation", 0) + 1
3739
+ )
3740
+ self._database_detail_active = False
3741
+ self._database_detail_worker = None
3742
+ self._database_detail_instance = None
3743
+ self._database_expensive_detail_generation = (
3744
+ getattr(self, "_database_expensive_detail_generation", 0) + 1
3745
+ )
3746
+ self._database_expensive_detail_active = False
3747
+ self._database_expensive_detail_worker = None
3748
+ self._database_expensive_detail_instance = None
3749
+
3750
+
3751
+ def _start_database_detail_load(self, abspath, runs):
3752
+ if DatabaseActionsMixin._database_generation_transaction_blocks_path(
3753
+ self,
3754
+ abspath,
3755
+ ):
3756
+ return
3757
+ if getattr(self, "_database_access_mode", None) == TRUSTED_LIVE_MODE:
3758
+ bridge = getattr(self, "_trusted_derived_bridge", None)
3759
+ if bridge is not None:
3760
+ if bridge.coordinator is not None:
3761
+ bridge.reconcile_runs(self.RunList.all_run_metadata())
3762
+ bridge.request_priority_update()
3763
+ return
3764
+ run_ids = self._database_detail_run_order(runs)
3765
+ if not run_ids:
3766
+ return
3767
+
3768
+ detail_instance = getattr(self, "_loaded_database_instance", None)
3769
+ if not isinstance(detail_instance, DatabaseInstance):
3770
+ detail_instance = database_instance(abspath)
3771
+
3772
+ trusted_service = DatabaseActionsMixin._active_trusted_service_for_instance(
3773
+ self,
3774
+ detail_instance,
3775
+ )
3776
+ if (
3777
+ getattr(self, "_database_access_mode", None) == TRUSTED_LIVE_MODE
3778
+ and trusted_service is None
3779
+ ):
3780
+ DatabaseActionsMixin._cancel_database_detail_load(self)
3781
+ self.show_error(
3782
+ "Trusted Details Unavailable",
3783
+ "The accepted trusted live-reader session is no longer usable.",
3784
+ "Reload the database to start a new trusted session. qPlot did "
3785
+ "not fall back to snapshot metadata after the accepted-session "
3786
+ "failure.",
3787
+ )
3788
+ return
3789
+ worker_kwargs = {
3790
+ "expected_database_instance": detail_instance,
3791
+ }
3792
+ if trusted_service is not None:
3793
+ worker_kwargs["trusted_service"] = trusted_service
3794
+
3795
+ DatabaseActionsMixin._start_database_cheap_detail_worker(
3796
+ self,
3797
+ abspath,
3798
+ run_ids,
3799
+ detail_instance,
3800
+ worker_kwargs,
3801
+ )
3802
+ DatabaseActionsMixin._start_database_expensive_detail_worker(
3803
+ self,
3804
+ abspath,
3805
+ run_ids,
3806
+ detail_instance,
3807
+ worker_kwargs,
3808
+ )
3809
+ QtCore.QTimer.singleShot(0, self._prioritize_database_detail_runs)
3810
+
3811
+
3812
+ def _start_database_cheap_detail_worker(
3813
+ self,
3814
+ abspath,
3815
+ run_ids,
3816
+ detail_instance,
3817
+ worker_kwargs,
3818
+ ):
3819
+ if (
3820
+ getattr(self, "_database_access_mode", None) == TRUSTED_LIVE_MODE
3821
+ and (
3822
+ getattr(self, "_trusted_derived_bridge", None) is not None
3823
+ or worker_kwargs.get("trusted_service") is None
3824
+ )
3825
+ ):
3826
+ return
3827
+ prior_worker = getattr(self, "_database_detail_worker", None)
3828
+ if prior_worker is not None:
3829
+ prior_worker.cancel()
3830
+ self._database_detail_generation = (
3831
+ getattr(self, "_database_detail_generation", 0) + 1
3832
+ )
3833
+ generation = self._database_detail_generation
3834
+ self._database_detail_active = True
3835
+ self._database_detail_instance = detail_instance
3836
+
3837
+ worker = DatabaseDetailWorker(
3838
+ generation,
3839
+ abspath,
3840
+ run_ids,
3841
+ batch_size=100,
3842
+ **worker_kwargs,
3843
+ )
3844
+ self._database_detail_worker = worker
3845
+ priority_run_ids = self._database_detail_priority_run_ids()
3846
+ worker.prioritize_run_ids(priority_run_ids)
3847
+ worker.signals.status.connect(self.database_detail_status)
3848
+ worker.signals.batch_ready.connect(self.database_detail_batch_ready)
3849
+ worker.signals.finished.connect(self.database_detail_finished)
3850
+ thread_pool = getattr(
3851
+ self,
3852
+ "databaseDetailThreadPool",
3853
+ self.databaseLoadThreadPool,
3854
+ )
3855
+ thread_pool.start(worker)
3856
+
3857
+
3858
+ def _start_database_expensive_detail_worker(
3859
+ self,
3860
+ abspath,
3861
+ run_ids,
3862
+ detail_instance,
3863
+ worker_kwargs,
3864
+ ):
3865
+ if (
3866
+ getattr(self, "_database_access_mode", None) == TRUSTED_LIVE_MODE
3867
+ and (
3868
+ getattr(self, "_trusted_derived_bridge", None) is not None
3869
+ or worker_kwargs.get("trusted_service") is None
3870
+ )
3871
+ ):
3872
+ return
3873
+ prior_worker = getattr(self, "_database_expensive_detail_worker", None)
3874
+ if prior_worker is not None:
3875
+ prior_worker.cancel()
3876
+ self._database_expensive_detail_generation = (
3877
+ getattr(self, "_database_expensive_detail_generation", 0) + 1
3878
+ )
3879
+ expensive_generation = self._database_expensive_detail_generation
3880
+ self._database_expensive_detail_active = True
3881
+ self._database_expensive_detail_instance = detail_instance
3882
+ priority_run_ids = self._database_detail_priority_run_ids()
3883
+ expensive_worker = DatabaseExpensiveDetailWorker(
3884
+ expensive_generation,
3885
+ abspath,
3886
+ run_ids,
3887
+ batch_size=100,
3888
+ **worker_kwargs,
3889
+ )
3890
+ self._database_expensive_detail_worker = expensive_worker
3891
+ expensive_worker.prioritize_run_ids(priority_run_ids)
3892
+ expensive_worker.signals.status.connect(self.database_expensive_detail_status)
3893
+ expensive_worker.signals.batch_ready.connect(
3894
+ self.database_expensive_detail_batch_ready
3895
+ )
3896
+ expensive_worker.signals.finished.connect(
3897
+ self.database_expensive_detail_finished
3898
+ )
3899
+ expensive_thread_pool = getattr(
3900
+ self,
3901
+ "databaseExpensiveDetailThreadPool",
3902
+ getattr(self, "databaseDetailThreadPool", self.databaseLoadThreadPool),
3903
+ )
3904
+ expensive_thread_pool.start(expensive_worker)
3905
+
3906
+
3907
+ def _database_detail_run_order(self, runs):
3908
+ def sort_key(run_id):
3909
+ try:
3910
+ return int(run_id)
3911
+ except (TypeError, ValueError):
3912
+ return 0
3913
+
3914
+ return sorted((runs or {}).keys(), key=sort_key, reverse=True)
3915
+
3916
+
3917
+ def _prioritize_database_detail_runs(self, run_ids=None):
3918
+ if getattr(self, "_database_access_mode", None) == TRUSTED_LIVE_MODE:
3919
+ bridge = getattr(self, "_trusted_derived_bridge", None)
3920
+ if bridge is not None:
3921
+ bridge.request_priority_update()
3922
+ return
3923
+ if not (
3924
+ getattr(self, "_database_detail_active", False)
3925
+ or getattr(self, "_database_expensive_detail_active", False)
3926
+ ):
3927
+ return
3928
+
3929
+ priority_run_ids = self._database_detail_priority_run_ids(run_ids=run_ids)
3930
+ for active_attr, worker_attr in (
3931
+ ("_database_detail_active", "_database_detail_worker"),
3932
+ (
3933
+ "_database_expensive_detail_active",
3934
+ "_database_expensive_detail_worker",
3935
+ ),
3936
+ ):
3937
+ if not getattr(self, active_attr, False):
3938
+ continue
3939
+
3940
+ worker = getattr(self, worker_attr, None)
3941
+ if worker is None or not hasattr(worker, "prioritize_run_ids"):
3942
+ continue
3943
+
3944
+ worker.prioritize_run_ids(priority_run_ids)
3945
+
3946
+
3947
+ def _database_detail_priority_run_ids(self, run_ids=None):
3948
+ priority_ids = []
3949
+ seen = set()
3950
+
3951
+ def add(candidate):
3952
+ if candidate is None:
3953
+ return
3954
+ try:
3955
+ key = int(candidate)
3956
+ except (TypeError, ValueError):
3957
+ key = candidate
3958
+ if key in seen:
3959
+ return
3960
+ priority_ids.append(candidate)
3961
+ seen.add(key)
3962
+
3963
+ if isinstance(run_ids, (list, tuple, set)):
3964
+ for run_id in run_ids:
3965
+ add(run_id)
3966
+ else:
3967
+ add(run_ids)
3968
+
3969
+ run_list = getattr(self, "RunList", None)
3970
+ selected_run_ids = getattr(run_list, "selected_run_ids", None)
3971
+ if callable(selected_run_ids):
3972
+ for run_id in selected_run_ids():
3973
+ add(run_id)
3974
+
3975
+ visible_run_ids = getattr(run_list, "visible_run_ids", None)
3976
+ if callable(visible_run_ids):
3977
+ for run_id in visible_run_ids():
3978
+ add(run_id)
3979
+
3980
+ return priority_ids
3981
+
3982
+
3983
+ @QtCore.pyqtSlot(int, str)
3984
+ def database_detail_status(self, generation, message):
3985
+ if generation != getattr(self, "_database_detail_generation", 0):
3986
+ return
3987
+ if not getattr(self, "_database_detail_active", False):
3988
+ return
3989
+
3990
+ self.show_status(message, 0)
3991
+
3992
+
3993
+ @QtCore.pyqtSlot(int, str, object)
3994
+ def database_detail_batch_ready(self, generation, abspath, runs):
3995
+ if generation != getattr(self, "_database_detail_generation", 0):
3996
+ return
3997
+ if not getattr(self, "_database_detail_active", False):
3998
+ return
3999
+ if DatabaseActionsMixin._database_generation_transaction_blocks_path(
4000
+ self,
4001
+ abspath,
4002
+ ):
4003
+ return
4004
+ if not _database_paths_equal(abspath, self.fileTextbox.text()):
4005
+ return
4006
+ if DatabaseActionsMixin._reload_if_worker_database_instance_changed(
4007
+ self,
4008
+ abspath,
4009
+ getattr(self, "_database_detail_instance", None),
4010
+ ):
4011
+ return
4012
+
4013
+ detail_instance = getattr(self, "_database_detail_instance", None)
4014
+ self._apply_database_detail_batch(runs)
4015
+ DatabaseActionsMixin._reload_if_worker_database_instance_changed(
4016
+ self,
4017
+ abspath,
4018
+ detail_instance,
4019
+ discard_stale_publication=True,
4020
+ )
4021
+
4022
+
4023
+ @QtCore.pyqtSlot(int, str)
4024
+ def database_expensive_detail_status(self, generation, message):
4025
+ if generation != getattr(self, "_database_expensive_detail_generation", 0):
4026
+ return
4027
+ if not getattr(self, "_database_expensive_detail_active", False):
4028
+ return
4029
+
4030
+ self.show_status(message, 0)
4031
+
4032
+
4033
+ @QtCore.pyqtSlot(int, str, object)
4034
+ def database_expensive_detail_batch_ready(self, generation, abspath, runs):
4035
+ if generation != getattr(self, "_database_expensive_detail_generation", 0):
4036
+ return
4037
+ if not getattr(self, "_database_expensive_detail_active", False):
4038
+ return
4039
+ if DatabaseActionsMixin._database_generation_transaction_blocks_path(
4040
+ self,
4041
+ abspath,
4042
+ ):
4043
+ return
4044
+ if not _database_paths_equal(abspath, self.fileTextbox.text()):
4045
+ return
4046
+ if DatabaseActionsMixin._reload_if_worker_database_instance_changed(
4047
+ self,
4048
+ abspath,
4049
+ getattr(self, "_database_expensive_detail_instance", None),
4050
+ ):
4051
+ return
4052
+
4053
+ detail_instance = getattr(
4054
+ self,
4055
+ "_database_expensive_detail_instance",
4056
+ None,
4057
+ )
4058
+ self._apply_database_detail_batch(runs)
4059
+ DatabaseActionsMixin._reload_if_worker_database_instance_changed(
4060
+ self,
4061
+ abspath,
4062
+ detail_instance,
4063
+ discard_stale_publication=True,
4064
+ )
4065
+
4066
+
4067
+ def _apply_database_detail_batch(self, runs):
4068
+ if DatabaseActionsMixin._database_generation_transaction_blocks_path(self):
4069
+ return
4070
+ updated_runs = self.RunList.updateRuns(runs)
4071
+ if not updated_runs:
4072
+ return
4073
+
4074
+ if getattr(self, "_database_access_mode", None) != TRUSTED_LIVE_MODE:
4075
+ self.infoBox.preview.add_runs(updated_runs, queue_previews=False)
4076
+ prioritize_previews = getattr(self, "_prioritize_preview_runs", None)
4077
+ if callable(prioritize_previews):
4078
+ prioritize_previews()
4079
+ self._refresh_selected_run_details(updated_runs)
4080
+
4081
+ @QtCore.pyqtSlot(int, str, object)
4082
+ def database_detail_finished(self, generation, abspath, error):
4083
+ if generation != getattr(self, "_database_detail_generation", 0):
4084
+ return
4085
+
4086
+ detail_instance = getattr(self, "_database_detail_instance", None)
4087
+ self._database_detail_active = False
4088
+ self._database_detail_worker = None
4089
+ self._database_detail_instance = None
4090
+
4091
+ if not _database_paths_equal(abspath, self.fileTextbox.text()):
4092
+ return
4093
+
4094
+ if isinstance(error, DatabaseInstanceChangedError):
4095
+ DatabaseActionsMixin._cancel_database_detail_load(self)
4096
+ self._reload_replaced_database(abspath)
4097
+ return
4098
+ if DatabaseActionsMixin._reload_if_worker_database_instance_changed(
4099
+ self,
4100
+ abspath,
4101
+ detail_instance,
4102
+ ):
4103
+ return
4104
+
4105
+ if error is not None:
4106
+ log_exception("Database detail load failed", error, __name__)
4107
+ self.show_status(f"Run detail loading failed: {error}", 5000)
4108
+ return
4109
+
4110
+ if not getattr(self, "_database_expensive_detail_active", False):
4111
+ self.show_status("Run details loaded.", 5000)
4112
+
4113
+ @QtCore.pyqtSlot(int, str, object)
4114
+ def database_expensive_detail_finished(self, generation, abspath, error):
4115
+ if generation != getattr(self, "_database_expensive_detail_generation", 0):
4116
+ return
4117
+
4118
+ detail_instance = getattr(
4119
+ self,
4120
+ "_database_expensive_detail_instance",
4121
+ None,
4122
+ )
4123
+ self._database_expensive_detail_active = False
4124
+ self._database_expensive_detail_worker = None
4125
+ self._database_expensive_detail_instance = None
4126
+
4127
+ if not _database_paths_equal(abspath, self.fileTextbox.text()):
4128
+ return
4129
+
4130
+ if isinstance(error, DatabaseInstanceChangedError):
4131
+ DatabaseActionsMixin._cancel_database_detail_load(self)
4132
+ self._reload_replaced_database(abspath)
4133
+ return
4134
+ if DatabaseActionsMixin._reload_if_worker_database_instance_changed(
4135
+ self,
4136
+ abspath,
4137
+ detail_instance,
4138
+ ):
4139
+ return
4140
+
4141
+ if error is not None:
4142
+ log_exception("Expensive database detail load failed", error, __name__)
4143
+ self.show_status(f"Setpoint and size loading failed: {error}", 5000)
4144
+ return
4145
+
4146
+ if not getattr(self, "_database_detail_active", False):
4147
+ self.show_status("Run details loaded.", 5000)
4148
+
4149
+
4150
+ def _refresh_selected_run_details(self, runs, *, live_only=False):
4151
+ if getattr(self, "_database_access_mode", None) == TRUSTED_LIVE_MODE:
4152
+ if not live_only:
4153
+ return
4154
+ # Refresh affects cached runs even when another run (or no run)
4155
+ # is selected. Include partial keys without a remaining cache entry.
4156
+ refreshed_guids = {
4157
+ str(metadata["guid"])
4158
+ for metadata in runs.values()
4159
+ if metadata.get("guid")
4160
+ }
4161
+ cache = getattr(self, "_selected_run_detail_cache", {})
4162
+ partial_keys = getattr(self, "_selected_run_partial_detail_keys", set())
4163
+ for key in set(cache) | partial_keys:
4164
+ if key and key[-1] in refreshed_guids:
4165
+ cache.pop(key, None)
4166
+ partial_keys.discard(key)
4167
+
4168
+ guid = getattr(self, "_selected_run_guid", None)
4169
+ selected_key = getattr(self, "_selected_dataset_key", None)
4170
+ if not guid:
4171
+ guid = getattr(selected_key, "guid", None)
4172
+ if not guid:
4173
+ guid = getattr(getattr(self, "ds", None), "guid", None)
4174
+ if not guid:
4175
+ return
4176
+
4177
+ for metadata in runs.values():
4178
+ if metadata.get("guid") == guid:
4179
+ if getattr(self, "_database_access_mode", None) == TRUSTED_LIVE_MODE:
4180
+ self.updateSelected(guid)
4181
+ elif live_only:
4182
+ update_live = getattr(
4183
+ self.infoBox,
4184
+ "update_live_run_details",
4185
+ None,
4186
+ )
4187
+ if callable(update_live):
4188
+ update_live(metadata)
4189
+ else:
4190
+ self.updateSelected(guid)
4191
+ return
4192
+
4193
+
4194
+ def _selected_run_publication_instance(
4195
+ self,
4196
+ instance,
4197
+ guid,
4198
+ run_id,
4199
+ generation,
4200
+ ):
4201
+ """Return the currently bound source for one still-current selection.
4202
+
4203
+ Widget setters and run-list updates can enter a nested Qt event loop.
4204
+ The generation/GUID/run checks therefore belong on every side of each
4205
+ publication boundary, not only at worker callback dispatch.
4206
+ """
4207
+ if generation != getattr(self, "_database_selected_run_generation", 0):
4208
+ return None
4209
+ if str(guid) != getattr(self, "_selected_run_guid", None):
4210
+ return None
4211
+ try:
4212
+ selected_run_id = int(getattr(self, "selected_run_id", -1))
4213
+ expected_run_id = int(run_id)
4214
+ except (TypeError, ValueError, OverflowError):
4215
+ return None
4216
+ if selected_run_id != expected_run_id:
4217
+ return None
4218
+ if not isinstance(instance, DatabaseInstance):
4219
+ return None
4220
+ if not _database_paths_equal(
4221
+ self.fileTextbox.text(),
4222
+ instance.logical_path,
4223
+ ):
4224
+ return None
4225
+ if DatabaseActionsMixin._reload_if_worker_database_instance_changed(
4226
+ self,
4227
+ instance.logical_path,
4228
+ instance,
4229
+ discard_stale_publication=True,
4230
+ ):
4231
+ return None
4232
+ current_instance = getattr(self, "_loaded_database_instance", None)
4233
+ if not isinstance(current_instance, DatabaseInstance):
4234
+ return None
4235
+ return current_instance
4236
+
4237
+
4238
+ def _restore_current_selected_run_view(self):
4239
+ """Re-render the newest selection after a nested stale publication.
4240
+
4241
+ A stale setter may have resumed after selection changed underneath it.
4242
+ Reapplying the current cached detail (or its immediate loading model)
4243
+ prevents the older detail from remaining visible. This method never
4244
+ opens SQLite and never starts another worker.
4245
+ """
4246
+ if getattr(self, "_restoring_selected_run_publication", False):
4247
+ self._selected_run_restore_pending = True
4248
+ return False
4249
+ self._restoring_selected_run_publication = True
4250
+ try:
4251
+ for _attempt in range(4):
4252
+ self._selected_run_restore_pending = False
4253
+ generation = getattr(
4254
+ self,
4255
+ "_database_selected_run_generation",
4256
+ 0,
4257
+ )
4258
+ guid = getattr(self, "_selected_run_guid", None)
4259
+ run_id = getattr(self, "selected_run_id", None)
4260
+ instance = getattr(self, "_loaded_database_instance", None)
4261
+ if not guid or not isinstance(instance, DatabaseInstance):
4262
+ break
4263
+
4264
+ item_for_guid = getattr(self.RunList, "_item_for_guid", None)
4265
+ item = item_for_guid(guid) if callable(item_for_guid) else None
4266
+ metadata = dict(getattr(item, "run_metadata", {}) or {})
4267
+ metadata.setdefault("guid", str(guid))
4268
+ metadata.setdefault("run_id", run_id)
4269
+ cache_key = _selected_run_detail_cache_key(instance, run_id, guid)
4270
+ detail = getattr(self, "_selected_run_detail_cache", {}).get(
4271
+ cache_key
4272
+ )
4273
+ access_mode = getattr(self, "_database_access_mode", None)
4274
+ if access_mode == SNAPSHOT_FALLBACK_MODE:
4275
+ setter_name = "set_snapshot_run_unavailable"
4276
+ setter_value = metadata
4277
+ elif detail is not None and not _selected_run_detail_is_stale(
4278
+ detail, metadata):
4279
+ setter_name = "set_trusted_run_detail"
4280
+ setter_value = detail
4281
+ else:
4282
+ setter_name = "set_trusted_run_loading"
4283
+ setter_value = metadata
4284
+ setter = getattr(self.infoBox, setter_name, None)
4285
+ if not callable(setter):
4286
+ break
4287
+ setter(setter_value)
4288
+ if (
4289
+ not getattr(self, "_selected_run_restore_pending", False)
4290
+ and DatabaseActionsMixin._selected_run_publication_instance(
4291
+ self,
4292
+ instance,
4293
+ guid,
4294
+ run_id,
4295
+ generation,
4296
+ ) is not None
4297
+ ):
4298
+ return True
4299
+
4300
+ clear = getattr(self.infoBox, "clear", None)
4301
+ if callable(clear):
4302
+ clear()
4303
+ return False
4304
+ finally:
4305
+ self._selected_run_restore_pending = False
4306
+ self._restoring_selected_run_publication = False
4307
+
4308
+
4309
+ def _update_snapshot_selected_run(self, guid):
4310
+ """Publish cached basics only for one snapshot-fallback selection.
4311
+
4312
+ Opening the retained fallback reader prepares a complete private
4313
+ snapshot. Ordinary selection must never pay that potentially enormous
4314
+ copy cost, even on a worker thread. Rich fallback detail therefore
4315
+ remains unavailable until an explicit action (or a later stage).
4316
+ """
4317
+ instance = getattr(self, "_loaded_database_instance", None)
4318
+ database_path = (
4319
+ instance.logical_path
4320
+ if isinstance(instance, DatabaseInstance)
4321
+ else self.fileTextbox.text()
4322
+ )
4323
+ if (
4324
+ database_path
4325
+ and DatabaseActionsMixin._reload_if_database_instance_changed(
4326
+ self,
4327
+ database_path,
4328
+ )
4329
+ ):
4330
+ return True
4331
+ instance = getattr(self, "_loaded_database_instance", instance)
4332
+
4333
+ item = self.RunList._item_for_guid(guid)
4334
+ if item is None:
4335
+ return False
4336
+ run_id = self.RunList.run_id_for_guid(guid)
4337
+ try:
4338
+ run_id = int(run_id)
4339
+ except (TypeError, ValueError):
4340
+ return False
4341
+
4342
+ DatabaseActionsMixin._cancel_selected_run_detail(self)
4343
+ generation = self._database_selected_run_generation
4344
+ metadata = dict(getattr(item, "run_metadata", {}) or {})
4345
+ metadata.setdefault("run_id", run_id)
4346
+ metadata.setdefault("guid", str(guid))
4347
+
4348
+ self._selected_run_guid = str(guid)
4349
+ self.selected_run_id = run_id
4350
+ release_selected = getattr(self, "_release_selected_dataset", None)
4351
+ if callable(release_selected):
4352
+ release_selected()
4353
+ else:
4354
+ self.ds = None
4355
+ self._selected_dataset_key = None
4356
+ blocked = self.run_idBox.blockSignals(True)
4357
+ try:
4358
+ self.run_idBox.setText(str(run_id))
4359
+ finally:
4360
+ self.run_idBox.blockSignals(blocked)
4361
+
4362
+ prioritize_previews = getattr(self, "_prioritize_preview_runs", None)
4363
+ if callable(prioritize_previews):
4364
+ prioritize_previews([run_id])
4365
+
4366
+ if isinstance(instance, DatabaseInstance):
4367
+ instance = DatabaseActionsMixin._selected_run_publication_instance(
4368
+ self,
4369
+ instance,
4370
+ guid,
4371
+ run_id,
4372
+ generation,
4373
+ )
4374
+ if instance is None:
4375
+ DatabaseActionsMixin._restore_current_selected_run_view(self)
4376
+ return True
4377
+
4378
+ if not isinstance(instance, DatabaseInstance):
4379
+ set_error = getattr(self.infoBox, "set_snapshot_run_error", None)
4380
+ if callable(set_error):
4381
+ set_error(
4382
+ "The accepted snapshot source is no longer available. "
4383
+ "Reload the database to continue.",
4384
+ metadata,
4385
+ )
4386
+ return True
4387
+
4388
+ set_unavailable = getattr(
4389
+ self.infoBox,
4390
+ "set_snapshot_run_unavailable",
4391
+ None,
4392
+ )
4393
+ if callable(set_unavailable):
4394
+ set_unavailable(metadata)
4395
+ else:
4396
+ set_error = getattr(self.infoBox, "set_snapshot_run_error", None)
4397
+ if callable(set_error):
4398
+ set_error(
4399
+ "Detailed run metadata is unavailable during ordinary "
4400
+ "snapshot-fallback selection. Use an explicit plot or "
4401
+ "CSV action to materialise this run.",
4402
+ metadata,
4403
+ )
4404
+ instance = DatabaseActionsMixin._selected_run_publication_instance(
4405
+ self,
4406
+ instance,
4407
+ guid,
4408
+ run_id,
4409
+ generation,
4410
+ )
4411
+ if instance is None:
4412
+ DatabaseActionsMixin._restore_current_selected_run_view(self)
4413
+ return True
4414
+ self._show_selected_run_status(run_id, self._run_point_count(metadata))
4415
+ if DatabaseActionsMixin._selected_run_publication_instance(
4416
+ self,
4417
+ instance,
4418
+ guid,
4419
+ run_id,
4420
+ generation,
4421
+ ) is None:
4422
+ DatabaseActionsMixin._restore_current_selected_run_view(self)
4423
+ return True
4424
+
4425
+
4426
+ def _update_trusted_selected_run(self, guid):
4427
+ """Select and progressively render one run without opening a DataSet."""
4428
+ instance = getattr(self, "_loaded_database_instance", None)
4429
+ database_path = (
4430
+ instance.logical_path
4431
+ if isinstance(instance, DatabaseInstance)
4432
+ else self.fileTextbox.text()
4433
+ )
4434
+ if (
4435
+ database_path
4436
+ and DatabaseActionsMixin._reload_if_database_instance_changed(
4437
+ self,
4438
+ database_path,
4439
+ )
4440
+ ):
4441
+ return True
4442
+ instance = getattr(self, "_loaded_database_instance", instance)
4443
+
4444
+ item = self.RunList._item_for_guid(guid)
4445
+ if item is None:
4446
+ return False
4447
+ run_id = self.RunList.run_id_for_guid(guid)
4448
+ try:
4449
+ run_id = int(run_id)
4450
+ except (TypeError, ValueError):
4451
+ return False
4452
+
4453
+ DatabaseActionsMixin._cancel_selected_run_detail(self)
4454
+ generation = self._database_selected_run_generation
4455
+ metadata = dict(getattr(item, "run_metadata", {}) or {})
4456
+ metadata.setdefault("run_id", run_id)
4457
+ metadata.setdefault("guid", str(guid))
4458
+
4459
+ self._selected_run_guid = str(guid)
4460
+ self.selected_run_id = run_id
4461
+ release_selected = getattr(self, "_release_selected_dataset", None)
4462
+ if callable(release_selected):
4463
+ release_selected()
4464
+ else:
4465
+ self.ds = None
4466
+ self._selected_dataset_key = None
4467
+ blocked = self.run_idBox.blockSignals(True)
4468
+ try:
4469
+ self.run_idBox.setText(str(run_id))
4470
+ finally:
4471
+ self.run_idBox.blockSignals(blocked)
4472
+
4473
+ set_loading = getattr(self.infoBox, "set_trusted_run_loading", None)
4474
+ if callable(set_loading):
4475
+ set_loading(metadata)
4476
+ bridge = getattr(self, "_trusted_derived_bridge", None)
4477
+ if bridge is None:
4478
+ self._prioritize_database_detail_runs([run_id])
4479
+
4480
+ if isinstance(instance, DatabaseInstance):
4481
+ instance = DatabaseActionsMixin._selected_run_publication_instance(
4482
+ self,
4483
+ instance,
4484
+ guid,
4485
+ run_id,
4486
+ generation,
4487
+ )
4488
+ if instance is None:
4489
+ DatabaseActionsMixin._restore_current_selected_run_view(self)
4490
+ return True
4491
+
4492
+ service = DatabaseActionsMixin._active_trusted_service_for_instance(
4493
+ self,
4494
+ instance,
4495
+ )
4496
+ if not isinstance(instance, DatabaseInstance) or service is None:
4497
+ set_error = getattr(self.infoBox, "set_trusted_run_error", None)
4498
+ if callable(set_error):
4499
+ set_error(
4500
+ "The accepted trusted session is no longer available. "
4501
+ "Reload the database to continue.",
4502
+ metadata,
4503
+ )
4504
+ return True
4505
+ if bridge is not None:
4506
+ bridge.select_run(str(guid))
4507
+ return True
4508
+
4509
+ # Non-MainWindow compatibility harnesses retain the Stage 4 path.
4510
+ # Every production MainWindow owns the Stage 5C bridge, so this branch
4511
+ # cannot compete with coordinator-owned trusted work in the app.
4512
+ cache_key = _selected_run_detail_cache_key(instance, run_id, guid)
4513
+ cache = getattr(self, "_selected_run_detail_cache", {})
4514
+ cached = cache.get(cache_key)
4515
+ if cached is not None and _selected_run_detail_is_stale(
4516
+ cached, self._run_metadata_for_guid(guid)):
4517
+ cache.pop(cache_key, None)
4518
+ getattr(self, "_selected_run_partial_detail_keys", set()).discard(cache_key)
4519
+ cached = None
4520
+ if cached is not None:
4521
+ if not DatabaseActionsMixin._publish_trusted_selected_run_detail(
4522
+ self,
4523
+ instance,
4524
+ guid,
4525
+ cached,
4526
+ detail_complete=(
4527
+ cache_key not in getattr(
4528
+ self,
4529
+ "_selected_run_partial_detail_keys",
4530
+ set(),
4531
+ )
4532
+ ),
4533
+ access_mode=TRUSTED_LIVE_MODE,
4534
+ generation=generation,
4535
+ ):
4536
+ DatabaseActionsMixin._restore_current_selected_run_view(self)
4537
+ return True
4538
+ partial_keys = getattr(
4539
+ self,
4540
+ "_selected_run_partial_detail_keys",
4541
+ set(),
4542
+ )
4543
+ if cache_key not in partial_keys:
4544
+ return True
4545
+
4546
+ worker = DatabaseSelectedRunWorker(
4547
+ generation,
4548
+ instance.logical_path,
4549
+ run_id,
4550
+ str(guid),
4551
+ service,
4552
+ expected_database_instance=instance,
4553
+ )
4554
+ self._database_selected_run_worker = worker
4555
+ self._database_selected_run_instance = instance
4556
+ self._database_selected_run_mode = TRUSTED_LIVE_MODE
4557
+ worker.signals.progress.connect(self.database_selected_run_progress)
4558
+ worker.signals.finished.connect(self.database_selected_run_finished)
4559
+ pool = getattr(
4560
+ self,
4561
+ "databaseSelectedRunThreadPool",
4562
+ self.databaseDetailThreadPool,
4563
+ )
4564
+ pool.start(worker)
4565
+ return True
4566
+
4567
+
4568
+ @QtCore.pyqtSlot(int, str, str, object)
4569
+ def database_selected_run_progress(
4570
+ self,
4571
+ generation,
4572
+ database_path,
4573
+ guid,
4574
+ detail,
4575
+ ):
4576
+ """Publish cheap selected-run fields while expensive work continues."""
4577
+ if generation != getattr(self, "_database_selected_run_generation", 0):
4578
+ return
4579
+ instance = getattr(self, "_database_selected_run_instance", None)
4580
+ if getattr(self, "_database_selected_run_mode", None) != TRUSTED_LIVE_MODE:
4581
+ return
4582
+ if guid != getattr(self, "_selected_run_guid", None):
4583
+ return
4584
+ if not _database_paths_equal(database_path, self.fileTextbox.text()):
4585
+ return
4586
+ if DatabaseActionsMixin._reload_if_worker_database_instance_changed(
4587
+ self,
4588
+ database_path,
4589
+ instance,
4590
+ ):
4591
+ return
4592
+ instance = getattr(self, "_loaded_database_instance", instance)
4593
+ DatabaseActionsMixin._publish_trusted_selected_run_detail(
4594
+ self,
4595
+ instance,
4596
+ guid,
4597
+ detail,
4598
+ detail_complete=False,
4599
+ access_mode=TRUSTED_LIVE_MODE,
4600
+ generation=generation,
4601
+ )
4602
+
4603
+
4604
+ @QtCore.pyqtSlot(int, str, str, object, object)
4605
+ def database_selected_run_finished(
4606
+ self,
4607
+ generation,
4608
+ database_path,
4609
+ guid,
4610
+ detail,
4611
+ error,
4612
+ ):
4613
+ if generation != getattr(self, "_database_selected_run_generation", 0):
4614
+ return
4615
+ instance = getattr(self, "_database_selected_run_instance", None)
4616
+ access_mode = getattr(self, "_database_selected_run_mode", None)
4617
+ self._database_selected_run_worker = None
4618
+ self._database_selected_run_instance = None
4619
+ self._database_selected_run_mode = None
4620
+ if guid != getattr(self, "_selected_run_guid", None):
4621
+ return
4622
+ if not _database_paths_equal(database_path, self.fileTextbox.text()):
4623
+ return
4624
+ if DatabaseActionsMixin._reload_if_worker_database_instance_changed(
4625
+ self,
4626
+ database_path,
4627
+ instance,
4628
+ ):
4629
+ return
4630
+ instance = getattr(self, "_loaded_database_instance", instance)
4631
+
4632
+ run_metadata = self._run_metadata_for_guid(guid)
4633
+ if error is not None:
4634
+ if isinstance(error, DatabaseInstanceChangedError):
4635
+ log_exception("Selected-run detail failed", error, __name__)
4636
+ self._reload_replaced_database(database_path)
4637
+ return
4638
+ error = bounded_presentation_error(error)
4639
+ log_exception("Selected-run detail failed", error, __name__)
4640
+ cache_key = _selected_run_detail_cache_key(
4641
+ instance,
4642
+ getattr(self, "selected_run_id", None),
4643
+ guid,
4644
+ )
4645
+ cached = getattr(self, "_selected_run_detail_cache", {}).get(cache_key)
4646
+ partial_keys = getattr(
4647
+ self,
4648
+ "_selected_run_partial_detail_keys",
4649
+ set(),
4650
+ )
4651
+ service = (
4652
+ DatabaseActionsMixin._active_trusted_service_for_instance(
4653
+ self,
4654
+ instance,
4655
+ )
4656
+ if access_mode == TRUSTED_LIVE_MODE
4657
+ else None
4658
+ )
4659
+ if cached is not None and cache_key in partial_keys and service is not None:
4660
+ if not DatabaseActionsMixin._publish_trusted_selected_run_detail(
4661
+ self,
4662
+ instance,
4663
+ guid,
4664
+ cached,
4665
+ detail_complete=False,
4666
+ access_mode=TRUSTED_LIVE_MODE,
4667
+ generation=generation,
4668
+ ):
4669
+ DatabaseActionsMixin._restore_current_selected_run_view(self)
4670
+ return
4671
+ self.show_status(
4672
+ f"Selected-run summaries failed; basic details remain: {error}",
4673
+ 5000,
4674
+ )
4675
+ if DatabaseActionsMixin._selected_run_publication_instance(
4676
+ self,
4677
+ instance,
4678
+ guid,
4679
+ getattr(self, "selected_run_id", None),
4680
+ generation,
4681
+ ) is None:
4682
+ DatabaseActionsMixin._restore_current_selected_run_view(self)
4683
+ return
4684
+ error_method = (
4685
+ "set_snapshot_run_error"
4686
+ if access_mode == SNAPSHOT_FALLBACK_MODE
4687
+ else "set_trusted_run_error"
4688
+ )
4689
+ set_error = getattr(self.infoBox, error_method, None)
4690
+ if callable(set_error):
4691
+ set_error(error, run_metadata)
4692
+ instance = DatabaseActionsMixin._selected_run_publication_instance(
4693
+ self,
4694
+ instance,
4695
+ guid,
4696
+ getattr(self, "selected_run_id", None),
4697
+ generation,
4698
+ )
4699
+ if instance is None:
4700
+ DatabaseActionsMixin._restore_current_selected_run_view(self)
4701
+ return
4702
+ self.show_status(f"Selected-run details failed: {error}", 5000)
4703
+ if DatabaseActionsMixin._selected_run_publication_instance(
4704
+ self,
4705
+ instance,
4706
+ guid,
4707
+ getattr(self, "selected_run_id", None),
4708
+ generation,
4709
+ ) is None:
4710
+ DatabaseActionsMixin._restore_current_selected_run_view(self)
4711
+ return
4712
+ if detail is None or not isinstance(instance, DatabaseInstance):
4713
+ return
4714
+
4715
+ DatabaseActionsMixin._publish_trusted_selected_run_detail(
4716
+ self,
4717
+ instance,
4718
+ guid,
4719
+ detail,
4720
+ detail_complete=True,
4721
+ access_mode=access_mode,
4722
+ generation=generation,
4723
+ )
4724
+
4725
+
4726
+ def _publish_trusted_selected_run_detail(
4727
+ self,
4728
+ instance,
4729
+ guid,
4730
+ detail,
4731
+ *,
4732
+ detail_complete,
4733
+ access_mode=None,
4734
+ generation=None,
4735
+ ):
4736
+ """Apply one current selected-run view model without database I/O."""
4737
+ if detail is None or not isinstance(instance, DatabaseInstance):
4738
+ return False
4739
+ if generation is None:
4740
+ generation = getattr(self, "_database_selected_run_generation", 0)
4741
+ detail_metadata = detail.run.as_dict()
4742
+ detail_guid_value = detail_metadata.get("guid")
4743
+ if detail_guid_value is None:
4744
+ return False
4745
+ detail_guid = str(detail_guid_value)
4746
+ if (
4747
+ detail_guid != guid
4748
+ or guid != getattr(self, "_selected_run_guid", None)
4749
+ or detail.run.run_id != self.selected_run_id
4750
+ ):
4751
+ return False
4752
+ instance = DatabaseActionsMixin._selected_run_publication_instance(
4753
+ self,
4754
+ instance,
4755
+ guid,
4756
+ detail.run.run_id,
4757
+ generation,
4758
+ )
4759
+ if instance is None:
4760
+ return False
4761
+
4762
+ def discard_stale_cache(candidate_instance):
4763
+ cache = getattr(self, "_selected_run_detail_cache", {})
4764
+ partial_keys = getattr(
4765
+ self,
4766
+ "_selected_run_partial_detail_keys",
4767
+ set(),
4768
+ )
4769
+ stale_key = _selected_run_detail_cache_key(
4770
+ candidate_instance,
4771
+ detail.run.run_id,
4772
+ guid,
4773
+ )
4774
+ cache.pop(stale_key, None)
4775
+ partial_keys.discard(stale_key)
4776
+
4777
+ if _selected_run_detail_is_stale(detail, self._run_metadata_for_guid(guid)):
4778
+ # Reject before either widget sees the old counts/completion state.
4779
+ # Refresh also cancels/restarts the selected worker via its generation.
4780
+ return False
4781
+
4782
+ self.RunList.updateRuns({detail.run.run_id: detail_metadata})
4783
+ guarded_instance = (
4784
+ DatabaseActionsMixin._selected_run_publication_instance(
4785
+ self,
4786
+ instance,
4787
+ guid,
4788
+ detail.run.run_id,
4789
+ generation,
4790
+ )
4791
+ )
4792
+ if guarded_instance is None:
4793
+ discard_stale_cache(instance)
4794
+ DatabaseActionsMixin._restore_current_selected_run_view(self)
4795
+ return False
4796
+ instance = guarded_instance
4797
+
4798
+ if access_mode == SNAPSHOT_FALLBACK_MODE:
4799
+ self.infoBox.set_snapshot_run_detail(detail)
4800
+ else:
4801
+ self.infoBox.set_trusted_run_detail(detail)
4802
+ guarded_instance = DatabaseActionsMixin._selected_run_publication_instance(
4803
+ self,
4804
+ instance,
4805
+ guid,
4806
+ detail.run.run_id,
4807
+ generation,
4808
+ )
4809
+ if guarded_instance is None:
4810
+ discard_stale_cache(instance)
4811
+ DatabaseActionsMixin._restore_current_selected_run_view(self)
4812
+ return False
4813
+ instance = guarded_instance
4814
+
4815
+ cache = getattr(self, "_selected_run_detail_cache", None)
4816
+ if cache is None:
4817
+ cache = {}
4818
+ self._selected_run_detail_cache = cache
4819
+ cache_key = _selected_run_detail_cache_key(
4820
+ instance,
4821
+ detail.run.run_id,
4822
+ guid,
4823
+ )
4824
+ cache[cache_key] = detail
4825
+ partial_keys = getattr(self, "_selected_run_partial_detail_keys", None)
4826
+ if partial_keys is None:
4827
+ partial_keys = set()
4828
+ self._selected_run_partial_detail_keys = partial_keys
4829
+ if detail_complete:
4830
+ partial_keys.discard(cache_key)
4831
+ else:
4832
+ partial_keys.add(cache_key)
4833
+
4834
+ self._show_selected_run_status(
4835
+ detail.run.run_id,
4836
+ self._run_point_count(detail_metadata),
4837
+ )
4838
+ if DatabaseActionsMixin._selected_run_publication_instance(
4839
+ self,
4840
+ instance,
4841
+ guid,
4842
+ detail.run.run_id,
4843
+ generation,
4844
+ ) is None:
4845
+ cache.pop(cache_key, None)
4846
+ partial_keys.discard(cache_key)
4847
+ DatabaseActionsMixin._restore_current_selected_run_view(self)
4848
+ return False
4849
+ return True
4850
+
4851
+
4852
+ def _show_selected_run_status(self, run_id, point_count):
4853
+ if point_count is None:
4854
+ self.show_status(f"Selected run {run_id}.", 5000)
4855
+ else:
4856
+ self.show_status(
4857
+ f"Selected run {run_id} with {int(point_count):,} points.",
4858
+ 5000,
4859
+ )
4860
+
4861
+
4862
+ def select_default_run(self):
4863
+ """
4864
+ Select the first visible run so the details pane is not left empty.
4865
+
4866
+ """
4867
+ if DatabaseActionsMixin._database_generation_transaction_blocks_path(self):
4868
+ return
4869
+ if self.RunList.topLevelItemCount() == 0:
4870
+ return
4871
+
4872
+ first_item = self.RunList.topLevelItem(0)
4873
+ if first_item is None:
4874
+ return
4875
+
4876
+ self.RunList.setCurrentItem(first_item)
4877
+ self.RunList.scrollToItem(first_item, qtw.QAbstractItemView.ScrollHint.PositionAtTop)
4878
+
4879
+
4880
+ def _loaded_empty_database_status(self, abspath, elapsed):
4881
+ basename = os.path.basename(abspath)
4882
+ if self._main_refresh_interval() > 0:
4883
+ return (
4884
+ f"Loaded empty database {basename} in {elapsed:.2f} s; "
4885
+ "waiting for measurements."
4886
+ )
4887
+
4888
+ return (
4889
+ f"Loaded empty database {basename} in {elapsed:.2f} s; "
4890
+ "refresh manually to check for measurements."
4891
+ )
4892
+
4893
+
4894
+ def _empty_database_refresh_status(self):
4895
+ if self._main_refresh_interval() > 0:
4896
+ return "No measurements found yet; still waiting for new runs."
4897
+ return "No measurements found yet."
4898
+
4899
+
4900
+ def _main_refresh_interval(self):
4901
+ current_refresh_interval = getattr(self, "_current_refresh_interval", None)
4902
+ if callable(current_refresh_interval):
4903
+ return current_refresh_interval()
4904
+ return 0.0