qplotter 1.6.0__py3-none-any.whl

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