qplotter 1.6.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. qplot/__init__.py +46 -0
  2. qplot/__main__.py +232 -0
  3. qplot/_auxiliary_paths.py +63 -0
  4. qplot/_metadata.py +12 -0
  5. qplot/_repair.py +14 -0
  6. qplot/_shutdown_supervisor.py +4049 -0
  7. qplot/_version.py +12 -0
  8. qplot/_windows_shutdown_job.py +1166 -0
  9. qplot/configuration/__init__.py +3 -0
  10. qplot/configuration/config.py +573 -0
  11. qplot/configuration/config_schema.json +319 -0
  12. qplot/configuration/scripts.py +255 -0
  13. qplot/configuration/themes/__init__.py +13 -0
  14. qplot/configuration/themes/_base.py +579 -0
  15. qplot/configuration/themes/blank.py +17 -0
  16. qplot/configuration/themes/dark.py +54 -0
  17. qplot/configuration/themes/light.py +54 -0
  18. qplot/datahandling/LoadFromDB.py +224 -0
  19. qplot/datahandling/__init__.py +47 -0
  20. qplot/datahandling/_trusted_live_helper.py +812 -0
  21. qplot/datahandling/_trusted_live_protocol.py +1745 -0
  22. qplot/datahandling/database.py +2330 -0
  23. qplot/datahandling/dimensions.py +43 -0
  24. qplot/datahandling/file_identity.py +461 -0
  25. qplot/datahandling/parameter_data.py +149 -0
  26. qplot/datahandling/plot_progress.py +17 -0
  27. qplot/datahandling/qcodes_cache.py +277 -0
  28. qplot/datahandling/qcodes_compat.py +14 -0
  29. qplot/datahandling/readSQL.py +2442 -0
  30. qplot/datahandling/readonly.py +2561 -0
  31. qplot/datahandling/source_activity.py +21 -0
  32. qplot/datahandling/trusted_array_preview.py +127 -0
  33. qplot/datahandling/trusted_derived_cache.py +1605 -0
  34. qplot/datahandling/trusted_derived_rendering.py +868 -0
  35. qplot/datahandling/trusted_heatmap.py +241 -0
  36. qplot/datahandling/trusted_live.py +2808 -0
  37. qplot/datahandling/trusted_live_queries.py +5907 -0
  38. qplot/datahandling/trusted_live_service.py +1894 -0
  39. qplot/datahandling/trusted_live_supervisor.py +2161 -0
  40. qplot/datahandling/trusted_plot.py +283 -0
  41. qplot/datahandling/trusted_presentation.py +1065 -0
  42. qplot/datahandling/trusted_snapshot.py +1132 -0
  43. qplot/datahandling/trusted_work_coordinator.py +2157 -0
  44. qplot/datahandling/trusted_work_scheduler.py +1204 -0
  45. qplot/diagnostics.py +241 -0
  46. qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
  47. qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
  48. qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
  49. qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
  50. qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
  51. qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
  52. qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
  53. qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
  54. qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
  55. qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
  56. qplot/testdata.py +1733 -0
  57. qplot/tools/__init__.py +13 -0
  58. qplot/tools/finite_means.py +114 -0
  59. qplot/tools/general.py +134 -0
  60. qplot/tools/heatmap_geometry.py +381 -0
  61. qplot/tools/operation_registry.py +179 -0
  62. qplot/tools/plot_tools.py +625 -0
  63. qplot/tools/sample_statistics.py +56 -0
  64. qplot/tools/worker.py +2787 -0
  65. qplot/windows/__init__.py +9 -0
  66. qplot/windows/_colorbar.py +422 -0
  67. qplot/windows/_commands.py +523 -0
  68. qplot/windows/_config_persistence.py +123 -0
  69. qplot/windows/_database_actions.py +4904 -0
  70. qplot/windows/_dataset_handle.py +221 -0
  71. qplot/windows/_dragdrop.py +71 -0
  72. qplot/windows/_export_paths.py +1167 -0
  73. qplot/windows/_help.py +169 -0
  74. qplot/windows/_native_averaging.py +173 -0
  75. qplot/windows/_native_transforms.py +389 -0
  76. qplot/windows/_plot1d_snap.py +608 -0
  77. qplot/windows/_plot1d_traces.py +2183 -0
  78. qplot/windows/_plot2d_colorbar.py +1126 -0
  79. qplot/windows/_plot2d_colorbar_dialog.py +786 -0
  80. qplot/windows/_plot2d_layers.py +2197 -0
  81. qplot/windows/_plot2d_sweeps.py +712 -0
  82. qplot/windows/_plotWin.py +1993 -0
  83. qplot/windows/_plot_actions.py +2695 -0
  84. qplot/windows/_plot_appearance.py +276 -0
  85. qplot/windows/_plot_axis_scaling.py +1485 -0
  86. qplot/windows/_plot_export.py +1445 -0
  87. qplot/windows/_plot_feedback.py +110 -0
  88. qplot/windows/_plot_marquee.py +656 -0
  89. qplot/windows/_plot_refresh.py +942 -0
  90. qplot/windows/_plot_state.py +239 -0
  91. qplot/windows/_plot_transform_labels.py +60 -0
  92. qplot/windows/_preferences.py +635 -0
  93. qplot/windows/_refresh_interval.py +70 -0
  94. qplot/windows/_run_controls.py +828 -0
  95. qplot/windows/_shortcuts.py +47 -0
  96. qplot/windows/_subplots/__init__.py +8 -0
  97. qplot/windows/_subplots/subplot1d.py +531 -0
  98. qplot/windows/_subplots/subplot2d.py +918 -0
  99. qplot/windows/_svg_export.py +209 -0
  100. qplot/windows/_trusted_derived_qt.py +1759 -0
  101. qplot/windows/_widgets/__init__.py +23 -0
  102. qplot/windows/_widgets/_run_formatting.py +387 -0
  103. qplot/windows/_widgets/details_tables.py +1119 -0
  104. qplot/windows/_widgets/dropbox.py +290 -0
  105. qplot/windows/_widgets/operations.py +460 -0
  106. qplot/windows/_widgets/preview.py +2997 -0
  107. qplot/windows/_widgets/run_list_items.py +488 -0
  108. qplot/windows/_widgets/toolbar.py +111 -0
  109. qplot/windows/_widgets/treeWidgets.py +2572 -0
  110. qplot/windows/_window_controls.py +413 -0
  111. qplot/windows/main.py +1635 -0
  112. qplot/windows/plot1d.py +311 -0
  113. qplot/windows/plot2d.py +1901 -0
  114. qplotter-1.6.0b3.dist-info/METADATA +318 -0
  115. qplotter-1.6.0b3.dist-info/RECORD +119 -0
  116. qplotter-1.6.0b3.dist-info/WHEEL +5 -0
  117. qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
  118. qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
  119. qplotter-1.6.0b3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2330 @@
1
+ """
2
+ Database access helpers used by the main window.
3
+
4
+ This module keeps blocking database probes, cloud-file hydration, background
5
+ load workers, and diagnostic report generation outside the GUI class.
6
+ """
7
+
8
+ import errno
9
+ import json
10
+ import os
11
+ import queue
12
+ import stat
13
+ import subprocess
14
+ import sys
15
+ import threading
16
+ from datetime import datetime
17
+ from time import monotonic, perf_counter
18
+
19
+ from PyQt6 import QtCore
20
+
21
+ from qplot.datahandling.file_identity import (
22
+ DatabaseInstance,
23
+ database_file_identity,
24
+ database_instance,
25
+ database_instances_differ,
26
+ )
27
+ from qplot.datahandling.readonly import (
28
+ DatabaseInstanceChangedError,
29
+ UnverifiableDatabaseWalError,
30
+ quarantine_wal_for_replaced_database,
31
+ replacement_wal_is_quarantined,
32
+ sqlite_read_only_connection,
33
+ )
34
+ from qplot.datahandling.readSQL import (
35
+ find_new_runs,
36
+ get_run_status,
37
+ get_runs_basic_via_sql,
38
+ get_snapshot_selected_run_detail,
39
+ iter_run_detail_batches_via_sql,
40
+ iter_run_shape_batches_via_sql,
41
+ iter_run_storage_batches_via_sql,
42
+ )
43
+ from qplot.datahandling.trusted_live import (
44
+ TrustedLiveReaderUnavailableError,
45
+ TrustedLiveSourceIOError,
46
+ TrustedLiveUnsupportedSourceError,
47
+ )
48
+ from qplot.datahandling.trusted_live_queries import run_records_as_dict
49
+ from qplot.datahandling.trusted_live_service import (
50
+ SNAPSHOT_FALLBACK_MODE,
51
+ TRUSTED_LIVE_MODE,
52
+ TrustedLiveReadService,
53
+ TrustedReadPriority,
54
+ TrustedReadRequestCancelledError,
55
+ )
56
+ from qplot.datahandling.trusted_presentation import (
57
+ bounded_presentation_error,
58
+ bounded_selected_run_fields,
59
+ )
60
+ from qplot.diagnostics import log_exception
61
+
62
+ DATABASE_ACCESS_TIMEOUT_SECONDS = 3
63
+ DATABASE_CLOUD_SYNC_TIMEOUT_SECONDS = 120
64
+ DATABASE_CLOUD_SYNC_CHUNK_BYTES = 4 * 1024 * 1024
65
+ DATABASE_CLOUD_SYNC_STATUS_INTERVAL = 1.0
66
+ DATABASE_CLOUD_SYNC_RETRY_INTERVAL = 0.25
67
+ DATABASE_PREFETCH_STATUS_PREFIX = "QPLOT_PREFETCH_PROGRESS:"
68
+ CLOUD_PLACEHOLDER_XATTR_MARKERS = (
69
+ "com.apple.fileprovider",
70
+ "com.apple.fileutil.PlaceholderData",
71
+ "com.microsoft.OneDrive",
72
+ )
73
+ WINDOWS_CLOUD_PLACEHOLDER_ATTRIBUTES = (
74
+ getattr(stat, "FILE_ATTRIBUTE_OFFLINE", 0x00001000)
75
+ | getattr(stat, "FILE_ATTRIBUTE_RECALL_ON_OPEN", 0x00040000)
76
+ | getattr(stat, "FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS", 0x00400000)
77
+ )
78
+ _DATABASE_ACCESS_ERROR_PREFIX = "QPLOT_DATABASE_ACCESS_ERROR:"
79
+
80
+
81
+ def _bounded_run_publication(fields):
82
+ """Strip raw run payloads before any worker result crosses into Qt."""
83
+
84
+ return {
85
+ name: list(value) if isinstance(value, tuple) else value
86
+ for name, value in bounded_selected_run_fields(fields or {})
87
+ }
88
+
89
+
90
+ def _bounded_runs_publication(runs):
91
+ return {
92
+ run_id: _bounded_run_publication(fields)
93
+ for run_id, fields in (runs or {}).items()
94
+ }
95
+
96
+
97
+ def _bounded_worker_error(error):
98
+ """Detach tracebacks/contexts and cap every object queued into Qt."""
99
+
100
+ if error is None:
101
+ return None
102
+ message = bounded_presentation_error(error)
103
+ if isinstance(error, DatabaseInstanceChangedError):
104
+ return DatabaseInstanceChangedError(message)
105
+ if isinstance(error, UnverifiableDatabaseWalError):
106
+ return UnverifiableDatabaseWalError(message)
107
+ return message
108
+
109
+
110
+ def trusted_open_failure_allows_snapshot_fallback(error):
111
+ """Return true only for the two documented initial-open outcomes.
112
+
113
+ Several supervisor/process failures intentionally inherit
114
+ ``TrustedLiveReaderUnavailableError``. Exact-type checks prevent helper
115
+ crashes, protocol failures, cancellation, deadlines, and accepted-session
116
+ failures from being converted into a legacy snapshot replay.
117
+ """
118
+
119
+ return type(error) in {
120
+ TrustedLiveReaderUnavailableError,
121
+ TrustedLiveUnsupportedSourceError,
122
+ }
123
+
124
+
125
+ class _DatabaseAccessErrorMessage(str):
126
+ """Probe text retaining the remote exception type for the load worker."""
127
+
128
+ def __new__(cls, message, error_type=None):
129
+ instance = super().__new__(cls, message)
130
+ instance.error_type = error_type
131
+ return instance
132
+
133
+
134
+ def _database_access_error_message(output):
135
+ """Decode a probe error without exposing its transport marker to users."""
136
+ output = (output or "").strip()
137
+ for line in reversed(output.splitlines()):
138
+ if not line.startswith(_DATABASE_ACCESS_ERROR_PREFIX):
139
+ continue
140
+ try:
141
+ payload = json.loads(line.removeprefix(_DATABASE_ACCESS_ERROR_PREFIX))
142
+ except (json.JSONDecodeError, TypeError):
143
+ continue
144
+ if not isinstance(payload, dict):
145
+ continue
146
+ message = payload.get("message")
147
+ error_type = payload.get("type")
148
+ if isinstance(message, str) and isinstance(error_type, str):
149
+ return _DatabaseAccessErrorMessage(message, error_type)
150
+ return _DatabaseAccessErrorMessage(output)
151
+
152
+
153
+ def database_path_from_mime_data(mime_data):
154
+ """
155
+ Return a dropped local .db path, if the drop contains exactly one.
156
+
157
+ """
158
+ if not mime_data.hasUrls():
159
+ return None
160
+
161
+ urls = mime_data.urls()
162
+ if len(urls) != 1:
163
+ return None
164
+
165
+ url = urls[0]
166
+ if not url.isLocalFile():
167
+ return None
168
+
169
+ path = os.path.normpath(url.toLocalFile())
170
+ if os.path.isfile(path) and path.lower().endswith(".db"):
171
+ return path
172
+
173
+ return None
174
+
175
+
176
+ def database_access_error(
177
+ database_path,
178
+ timeout=DATABASE_ACCESS_TIMEOUT_SECONDS,
179
+ *,
180
+ expected_database_identity=None,
181
+ cancelled_callback=None,
182
+ deadline=None,
183
+ ):
184
+ """
185
+ Return an error message if a database cannot be opened promptly.
186
+
187
+ QCoDeS initialisation can block inside SQLite when another process or a
188
+ cloud-sync provider holds the database. Probe in a short-lived interpreter
189
+ first so a stuck access check can be timed out without freezing qPlot.
190
+
191
+ """
192
+ if cancelled_callback is not None and cancelled_callback():
193
+ raise InterruptedError("Database access check cancelled.")
194
+ if deadline is not None and monotonic() >= deadline:
195
+ raise TimeoutError("Database access check deadline exceeded.")
196
+
197
+ # The child has a fresh replacement registry. Carry the identity observed
198
+ # before its launch so it cannot combine a later main-file replacement
199
+ # with a retained WAL sidecar.
200
+ if expected_database_identity is None:
201
+ expected_database_identity = database_file_identity(database_path)
202
+ ignore_unpaired_wal = replacement_wal_is_quarantined(database_path)
203
+ timeout = float(timeout)
204
+ if deadline is not None:
205
+ timeout = min(timeout, max(0.0, float(deadline) - monotonic()))
206
+ probe = (
207
+ "import json\n"
208
+ "import sys\n"
209
+ "from time import monotonic\n"
210
+ "from qplot.datahandling.readonly import probe_read_only_database\n"
211
+ "try:\n"
212
+ " expected_database_identity = json.loads(sys.argv[4])\n"
213
+ " if expected_database_identity is not None:\n"
214
+ " expected_database_identity = tuple(expected_database_identity)\n"
215
+ " deadline = monotonic() + max(0.0, float(sys.argv[3]))\n"
216
+ " probe_read_only_database(\n"
217
+ " sys.argv[1],\n"
218
+ " ignore_unpaired_wal=(sys.argv[2] == '1'),\n"
219
+ " expected_database_identity=expected_database_identity,\n"
220
+ " deadline=deadline,\n"
221
+ " _check_sqlite_lock_bytes=True,\n"
222
+ " )\n"
223
+ "except Exception as err:\n"
224
+ " payload = {'type': type(err).__name__, 'message': str(err)}\n"
225
+ f" print('{_DATABASE_ACCESS_ERROR_PREFIX}' + json.dumps(payload), "
226
+ "file=sys.stderr)\n"
227
+ " raise SystemExit(1)\n"
228
+ )
229
+
230
+ command = [
231
+ sys.executable,
232
+ "-c",
233
+ probe,
234
+ database_path,
235
+ "1" if ignore_unpaired_wal else "0",
236
+ repr(timeout),
237
+ json.dumps(expected_database_identity),
238
+ ]
239
+
240
+ try:
241
+ if cancelled_callback is None:
242
+ result = subprocess.run(
243
+ command,
244
+ capture_output=True,
245
+ text=True,
246
+ timeout=timeout,
247
+ check=False,
248
+ )
249
+ returncode = result.returncode
250
+ stdout = result.stdout
251
+ stderr = result.stderr
252
+ else:
253
+ process = subprocess.Popen(
254
+ command,
255
+ stdout=subprocess.PIPE,
256
+ stderr=subprocess.PIPE,
257
+ text=True,
258
+ )
259
+ probe_started_at = perf_counter()
260
+ while True:
261
+ if cancelled_callback():
262
+ process.kill()
263
+ process.communicate()
264
+ raise InterruptedError("Database access check cancelled.")
265
+ remaining = timeout - (perf_counter() - probe_started_at)
266
+ if remaining <= 0:
267
+ process.kill()
268
+ process.communicate()
269
+ raise subprocess.TimeoutExpired(command, timeout)
270
+ try:
271
+ stdout, stderr = process.communicate(
272
+ timeout=min(0.05, remaining),
273
+ )
274
+ except subprocess.TimeoutExpired:
275
+ continue
276
+ returncode = process.returncode
277
+ break
278
+ except InterruptedError:
279
+ raise
280
+ except subprocess.TimeoutExpired:
281
+ if deadline is not None and monotonic() >= deadline:
282
+ raise TimeoutError("Database access check deadline exceeded.") from None
283
+ return (
284
+ f"Timed out after {timeout:g} s while checking database access. "
285
+ "The database may be locked by another qPlot, QCoDeS, Python, or "
286
+ "notebook process, or blocked by cloud sync."
287
+ )
288
+ except OSError as err:
289
+ if cancelled_callback is not None and cancelled_callback():
290
+ raise InterruptedError("Database access check cancelled.") from err
291
+ return str(err)
292
+
293
+ if cancelled_callback is not None and cancelled_callback():
294
+ raise InterruptedError("Database access check cancelled.")
295
+
296
+ if returncode == 0:
297
+ return None
298
+
299
+ details = _database_access_error_message(stderr or stdout)
300
+ if not details:
301
+ details = _DatabaseAccessErrorMessage(
302
+ f"SQLite access probe exited with code {returncode}."
303
+ )
304
+ if (
305
+ deadline is not None
306
+ and details.error_type == TimeoutError.__name__
307
+ ):
308
+ raise TimeoutError(str(details))
309
+ return details
310
+
311
+
312
+ def database_cloud_storage_label(database_path):
313
+ """
314
+ Returns a user-facing cloud provider label when the path looks cloud-backed.
315
+
316
+ """
317
+ path = os.path.abspath(str(database_path or ""))
318
+ lower_path = path.lower()
319
+ if "onedrive" in lower_path:
320
+ return "OneDrive"
321
+ if "dropbox" in lower_path:
322
+ return "Dropbox"
323
+ if "google drive" in lower_path:
324
+ return "Google Drive"
325
+ if f"{os.sep}box{os.sep}" in lower_path:
326
+ return "Box"
327
+ if f"{os.sep}library{os.sep}cloudstorage{os.sep}" in lower_path:
328
+ return "cloud storage"
329
+ return None
330
+
331
+
332
+ def database_is_likely_cloud_placeholder(database_path):
333
+ """
334
+ Returns true when a database appears to be a cloud placeholder.
335
+
336
+ """
337
+ listxattr = getattr(os, "listxattr", None)
338
+ if listxattr is None:
339
+ attributes = []
340
+ else:
341
+ try:
342
+ attributes = listxattr(database_path)
343
+ except OSError:
344
+ attributes = []
345
+
346
+ for attribute in attributes:
347
+ if any(marker in attribute for marker in CLOUD_PLACEHOLDER_XATTR_MARKERS):
348
+ return True
349
+
350
+ if database_cloud_storage_label(database_path) is None:
351
+ return False
352
+
353
+ try:
354
+ info = os.stat(database_path)
355
+ except OSError:
356
+ return False
357
+
358
+ logical_size = getattr(info, "st_size", 0)
359
+ file_attributes = getattr(info, "st_file_attributes", None)
360
+ if file_attributes is not None:
361
+ return bool(file_attributes & WINDOWS_CLOUD_PLACEHOLDER_ATTRIBUTES)
362
+
363
+ blocks = getattr(info, "st_blocks", None)
364
+ if blocks is None:
365
+ return False
366
+
367
+ allocated_size = blocks * 512
368
+ return logical_size > 0 and allocated_size == 0
369
+
370
+
371
+ def prefetch_database_file(
372
+ database_path,
373
+ status_callback=None,
374
+ chunk_size=DATABASE_CLOUD_SYNC_CHUNK_BYTES,
375
+ status_interval=DATABASE_CLOUD_SYNC_STATUS_INTERVAL,
376
+ ):
377
+ """
378
+ Reads a database sequentially to trigger cloud Files-On-Demand hydration.
379
+
380
+ """
381
+ total_bytes = os.path.getsize(database_path)
382
+ if total_bytes <= 0:
383
+ return 0
384
+
385
+ provider = database_cloud_storage_label(database_path) or "cloud storage"
386
+ bytes_read = 0
387
+ last_status = 0.0
388
+
389
+ def emit_status(force=False):
390
+ nonlocal last_status
391
+ if status_callback is None:
392
+ return
393
+
394
+ now = perf_counter()
395
+ if not force and now - last_status < status_interval:
396
+ return
397
+
398
+ percent = min(100.0, (bytes_read / total_bytes) * 100.0)
399
+ status_callback(
400
+ f"Waiting for {provider} sync... {percent:.0f}% available"
401
+ )
402
+ last_status = now
403
+
404
+ emit_status(force=True)
405
+ with open(database_path, "rb") as handle:
406
+ while True:
407
+ chunk = handle.read(chunk_size)
408
+ if not chunk:
409
+ break
410
+ bytes_read += len(chunk)
411
+ emit_status()
412
+
413
+ emit_status(force=True)
414
+ return bytes_read
415
+
416
+
417
+ def prefetch_database_file_with_timeout(
418
+ database_path,
419
+ timeout=DATABASE_CLOUD_SYNC_TIMEOUT_SECONDS,
420
+ status_callback=None,
421
+ cancelled_callback=None,
422
+ ):
423
+ """
424
+ Runs cloud prefetch in a subprocess so stalled providers can be timed out.
425
+
426
+ """
427
+ timeout = float(timeout)
428
+ provider = database_cloud_storage_label(database_path) or "cloud storage"
429
+ if status_callback is not None:
430
+ status_callback(f"Waiting for {provider} sync...")
431
+
432
+ script = _database_prefetch_script()
433
+
434
+ try:
435
+ process = subprocess.Popen(
436
+ [sys.executable, "-c", script, database_path],
437
+ stdout=subprocess.PIPE,
438
+ stderr=subprocess.PIPE,
439
+ text=True,
440
+ bufsize=1,
441
+ )
442
+ except OSError as err:
443
+ raise RuntimeError(str(err)) from err
444
+
445
+ output_queue = queue.Queue()
446
+ stdout_thread = threading.Thread(
447
+ target=_read_prefetch_pipe,
448
+ args=(process.stdout, "stdout", output_queue),
449
+ daemon=True,
450
+ )
451
+ stderr_thread = threading.Thread(
452
+ target=_read_prefetch_pipe,
453
+ args=(process.stderr, "stderr", output_queue),
454
+ daemon=True,
455
+ )
456
+ stdout_thread.start()
457
+ stderr_thread.start()
458
+
459
+ start = perf_counter()
460
+ stderr_lines = []
461
+ bytes_read = 0
462
+
463
+ try:
464
+ while True:
465
+ try:
466
+ stream, line = output_queue.get(timeout=0.1)
467
+ except queue.Empty:
468
+ stream = line = None
469
+
470
+ if stream == "stdout":
471
+ parsed = _handle_prefetch_stdout_line(line, provider, status_callback)
472
+ if parsed is not None:
473
+ bytes_read = parsed
474
+ elif stream == "stderr" and line:
475
+ stderr_lines.append(line)
476
+
477
+ if process.poll() is not None:
478
+ stdout_thread.join(timeout=0.2)
479
+ stderr_thread.join(timeout=0.2)
480
+ parsed = _drain_prefetch_queue(
481
+ output_queue,
482
+ provider,
483
+ status_callback,
484
+ stderr_lines,
485
+ )
486
+ if parsed is not None:
487
+ bytes_read = parsed
488
+ break
489
+
490
+ if cancelled_callback is not None and cancelled_callback():
491
+ process.kill()
492
+ process.wait()
493
+ raise InterruptedError("Database load cancelled.")
494
+
495
+ if perf_counter() - start > timeout:
496
+ process.kill()
497
+ process.wait()
498
+ raise TimeoutError(
499
+ f"Timed out after {timeout:g} s while waiting for "
500
+ f"{provider} to download the database. Check that "
501
+ f"{provider} is running and signed in, or mark the "
502
+ "database folder as always available on this device."
503
+ )
504
+ finally:
505
+ for pipe in (process.stdout, process.stderr):
506
+ if pipe is not None:
507
+ pipe.close()
508
+
509
+ stdout_thread.join(timeout=0.5)
510
+ stderr_thread.join(timeout=0.5)
511
+
512
+ if process.returncode != 0:
513
+ details = "\n".join(line for line in stderr_lines if line)
514
+ if not details:
515
+ details = f"Cloud sync prefetch exited with code {process.returncode}."
516
+ raise RuntimeError(details)
517
+
518
+ return bytes_read
519
+
520
+
521
+ def _database_prefetch_script():
522
+ """
523
+ Returns the isolated cloud-hydration script used by the load worker.
524
+
525
+ Cloud providers can report a transient OS-level TimeoutError while a
526
+ placeholder is being materialised. The subprocess keeps retrying those
527
+ reads; its parent remains responsible for the overall timeout and cancel.
528
+
529
+ """
530
+ return (
531
+ "import errno, os, sys, time\n"
532
+ f"prefix = {DATABASE_PREFETCH_STATUS_PREFIX!r}\n"
533
+ f"chunk_size = {DATABASE_CLOUD_SYNC_CHUNK_BYTES!r}\n"
534
+ f"status_interval = {DATABASE_CLOUD_SYNC_STATUS_INTERVAL!r}\n"
535
+ f"retry_interval = {DATABASE_CLOUD_SYNC_RETRY_INTERVAL!r}\n"
536
+ "transient_errors = {errno.ETIMEDOUT, errno.ECANCELED}\n"
537
+ "path = sys.argv[1]\n"
538
+ "total = os.path.getsize(path)\n"
539
+ "read = 0\n"
540
+ "last = 0.0\n"
541
+ "def report(force=False):\n"
542
+ " global last\n"
543
+ " if total <= 0:\n"
544
+ " percent = 100.0\n"
545
+ " else:\n"
546
+ " percent = min(100.0, (read / total) * 100.0)\n"
547
+ " now = time.perf_counter()\n"
548
+ " if force or now - last >= status_interval:\n"
549
+ " print(prefix + f'{percent:.0f}', flush=True)\n"
550
+ " last = now\n"
551
+ "report(True)\n"
552
+ "while True:\n"
553
+ " try:\n"
554
+ " handle = open(path, 'rb')\n"
555
+ " break\n"
556
+ " except OSError as error:\n"
557
+ " if error.errno not in transient_errors:\n"
558
+ " raise\n"
559
+ " report(True)\n"
560
+ " time.sleep(retry_interval)\n"
561
+ "with handle:\n"
562
+ " while True:\n"
563
+ " try:\n"
564
+ " chunk = handle.read(chunk_size)\n"
565
+ " except OSError as error:\n"
566
+ " if error.errno not in transient_errors:\n"
567
+ " raise\n"
568
+ " report(True)\n"
569
+ " time.sleep(retry_interval)\n"
570
+ " continue\n"
571
+ " if not chunk:\n"
572
+ " break\n"
573
+ " read += len(chunk)\n"
574
+ " report(False)\n"
575
+ "report(True)\n"
576
+ "print(read, flush=True)\n"
577
+ )
578
+
579
+
580
+ def _read_prefetch_pipe(pipe, stream_name, output_queue):
581
+ if pipe is None:
582
+ return
583
+
584
+ for line in pipe:
585
+ output_queue.put((stream_name, line.rstrip()))
586
+
587
+
588
+ def _handle_prefetch_stdout_line(line, provider, status_callback):
589
+ if not line:
590
+ return None
591
+
592
+ if line.startswith(DATABASE_PREFETCH_STATUS_PREFIX):
593
+ percent = line[len(DATABASE_PREFETCH_STATUS_PREFIX):]
594
+ if status_callback is not None:
595
+ status_callback(f"Waiting for {provider} sync... {percent}% available")
596
+ return None
597
+
598
+ try:
599
+ return int(line)
600
+ except ValueError:
601
+ return None
602
+
603
+
604
+ def _drain_prefetch_queue(output_queue, provider, status_callback, stderr_lines):
605
+ bytes_read = None
606
+ while True:
607
+ try:
608
+ stream, line = output_queue.get_nowait()
609
+ except queue.Empty:
610
+ break
611
+
612
+ if stream == "stdout":
613
+ parsed = _handle_prefetch_stdout_line(line, provider, status_callback)
614
+ if parsed is not None:
615
+ bytes_read = parsed
616
+ elif stream == "stderr" and line:
617
+ stderr_lines.append(line)
618
+
619
+ return bytes_read
620
+
621
+
622
+ class DatabaseLoadSignals(QtCore.QObject):
623
+ """
624
+ Signals emitted by a background database load.
625
+
626
+ """
627
+ status = QtCore.pyqtSignal(int, str)
628
+ finished = QtCore.pyqtSignal(int, str, object, object)
629
+
630
+
631
+ class DatabaseDetailSignals(QtCore.QObject):
632
+ """
633
+ Signals emitted by progressive run-detail loading.
634
+
635
+ """
636
+ status = QtCore.pyqtSignal(int, str)
637
+ batch_ready = QtCore.pyqtSignal(int, str, object)
638
+ finished = QtCore.pyqtSignal(int, str, object)
639
+
640
+
641
+ class DatabaseSelectedRunSignals(QtCore.QObject):
642
+ """Signals for one generation-bound plain selected-run view."""
643
+
644
+ progress = QtCore.pyqtSignal(int, str, str, object)
645
+ finished = QtCore.pyqtSignal(int, str, str, object, object)
646
+
647
+
648
+ class _InterruptibleSqlWorker:
649
+ """Allows the GUI thread to interrupt an active read-only SQLite query."""
650
+
651
+ def _init_database_binding(
652
+ self,
653
+ database_path,
654
+ expected_database_instance=None,
655
+ deadline=None,
656
+ ):
657
+ """Retain the exact database instance accepted by the scheduler."""
658
+ supplied_instance = expected_database_instance is not None
659
+ requested_database_path = os.fspath(database_path)
660
+ if expected_database_instance is None:
661
+ expected_database_instance = database_instance(requested_database_path)
662
+ elif not isinstance(expected_database_instance, DatabaseInstance):
663
+ raise TypeError(
664
+ "expected_database_instance must be a DatabaseInstance or None"
665
+ )
666
+
667
+ self.database_instance = expected_database_instance
668
+ self.database_path = expected_database_instance.logical_path
669
+ self.requested_database_path = requested_database_path
670
+ # Signals are matched against the paths stored by the UI, which are
671
+ # logical_database_path values. Keeping the original spelling here
672
+ # makes an otherwise identical Windows path (for example ``D:`` vs
673
+ # ``d:``) look like a different database to signal consumers.
674
+ self._signal_database_path = requested_database_path
675
+ self._read_database_path = (
676
+ expected_database_instance.logical_path
677
+ if supplied_instance
678
+ else requested_database_path
679
+ )
680
+ self.logical_database_path = expected_database_instance.logical_path
681
+ self.resolved_database_path = expected_database_instance.resolved_path
682
+ self.database_identity = expected_database_instance.identity
683
+ self.sidecar_identities = expected_database_instance.sidecar_identities
684
+ self.deadline = deadline
685
+
686
+
687
+ def _expected_database_identity_kwargs(self):
688
+ """Keep legacy direct worker callers working when identity is unavailable."""
689
+ if self.database_identity is None:
690
+ return {}
691
+ return {"expected_database_identity": self.database_identity}
692
+
693
+
694
+ def _read_control_kwargs(self):
695
+ """Forward cancellation, identity, and an optional absolute deadline."""
696
+ kwargs = self._expected_database_identity_kwargs()
697
+ kwargs["cancelled_callback"] = self._cancelled.is_set
698
+ if self.deadline is not None:
699
+ kwargs["deadline"] = self.deadline
700
+ return kwargs
701
+
702
+
703
+ def _require_current_database_instance(self):
704
+ """Reject results if the worker's full logical source was replaced."""
705
+ accepted_instance = self.database_instance
706
+ current_instance = database_instance(accepted_instance.logical_path)
707
+ if (
708
+ not database_instances_differ(accepted_instance, current_instance)
709
+ and accepted_instance.sidecar_identities.issubset(
710
+ current_instance.sidecar_identities
711
+ )
712
+ ):
713
+ # SQLite may create WAL/SHM after the controller first accepts the
714
+ # main file. Permit that first directional appearance, but bind it
715
+ # immediately so a later removal, rotation, or ABA substitution is
716
+ # rejected by the worker's next pre-read/pre-publication guard.
717
+ if (
718
+ accepted_instance.sidecar_identities
719
+ != current_instance.sidecar_identities
720
+ ):
721
+ self.database_instance = current_instance
722
+ self.sidecar_identities = current_instance.sidecar_identities
723
+ return
724
+
725
+ quarantine_wal_for_replaced_database(accepted_instance.logical_path)
726
+ raise DatabaseInstanceChangedError(
727
+ "The database or one of its SQLite sidecars was replaced while "
728
+ "qPlot was loading background metadata."
729
+ )
730
+
731
+
732
+ @staticmethod
733
+ def _close_batch_iterator(iterator):
734
+ """Close a suspended SQL generator immediately on every early exit."""
735
+ close = getattr(iterator, "close", None)
736
+ if callable(close):
737
+ close()
738
+
739
+ def _init_sql_interrupt(self):
740
+ self._sql_connection_lock = threading.Lock()
741
+ self._publication_lock = threading.RLock()
742
+ self._sql_connection = None
743
+ self._trusted_request_lock = threading.Lock()
744
+ self._trusted_request = None
745
+
746
+
747
+ def _cancel_read(self):
748
+ """Linearize cancellation against worker result publication."""
749
+ with self._publication_lock:
750
+ self._cancelled.set()
751
+ with self._trusted_request_lock:
752
+ trusted_request = self._trusted_request
753
+ if trusted_request is not None:
754
+ trusted_request.cancel()
755
+ self._interrupt_sql()
756
+
757
+
758
+ def _interrupt_sql(self):
759
+ with self._sql_connection_lock:
760
+ connection = self._sql_connection
761
+ if connection is not None:
762
+ try:
763
+ connection.interrupt()
764
+ except Exception:
765
+ pass
766
+
767
+
768
+ def _set_sql_connection(self, connection):
769
+ with self._sql_connection_lock:
770
+ self._sql_connection = connection
771
+ if connection is not None and self._cancelled.is_set():
772
+ self._interrupt_sql()
773
+
774
+
775
+ def _set_trusted_request(self, request):
776
+ with self._trusted_request_lock:
777
+ self._trusted_request = request
778
+ if request is not None and self._cancelled.is_set():
779
+ request.cancel()
780
+
781
+
782
+ def _wait_trusted_request(self, request):
783
+ self._set_trusted_request(request)
784
+ try:
785
+ return request.wait()
786
+ finally:
787
+ with self._trusted_request_lock:
788
+ if self._trusted_request is request:
789
+ self._trusted_request = None
790
+
791
+
792
+ class DatabaseRefreshSignals(QtCore.QObject):
793
+ """Signals emitted by a coalesced main-window refresh worker."""
794
+
795
+ new_runs_ready = QtCore.pyqtSignal(int, str, object)
796
+ finished = QtCore.pyqtSignal(int, str, object, object, object)
797
+
798
+
799
+ class DatabaseRefreshWorker(_InterruptibleSqlWorker, QtCore.QRunnable):
800
+ """Fetch new runs and live-run status without blocking the GUI thread."""
801
+
802
+ def __init__(
803
+ self,
804
+ generation,
805
+ database_path,
806
+ last_run_id,
807
+ watched_runs,
808
+ *,
809
+ expected_database_instance=None,
810
+ deadline=None,
811
+ trusted_service=None,
812
+ require_publication_ack=False,
813
+ derived_coordinator_owned=False,
814
+ ):
815
+ super().__init__()
816
+ self.signals = DatabaseRefreshSignals()
817
+ self.generation = generation
818
+ self._init_database_binding(
819
+ database_path,
820
+ expected_database_instance,
821
+ deadline,
822
+ )
823
+ self.last_run_id = int(last_run_id or 0)
824
+ self.watched_runs = list(watched_runs or [])
825
+ self._cancelled = threading.Event()
826
+ self._init_sql_interrupt()
827
+ self.trusted_service = trusted_service
828
+ self._require_publication_ack = bool(require_publication_ack)
829
+ self._derived_coordinator_owned = bool(derived_coordinator_owned)
830
+ self._new_runs_publication_ack = threading.Event()
831
+ self._new_runs_publication_lock = threading.Lock()
832
+ self._new_runs_publication_error: BaseException | None = None
833
+
834
+
835
+ def cancel(self):
836
+ self._cancel_read()
837
+ self._new_runs_publication_ack.set()
838
+
839
+
840
+ def acknowledge_new_runs_published(self):
841
+ """Release the off-GUI refresh only after its basic page is visible."""
842
+ with self._new_runs_publication_lock:
843
+ self._new_runs_publication_error = None
844
+ self._new_runs_publication_ack.set()
845
+
846
+
847
+ def reject_new_runs_publication(self, error):
848
+ """Fail closed when a trusted basic page could not reach the GUI."""
849
+ with self._new_runs_publication_lock:
850
+ self._new_runs_publication_error = error
851
+ # The adapter cursor already accepted this bounded page. Retire the
852
+ # session instead of letting a later refresh omit rows the GUI failed
853
+ # to publish. close_async is deliberately prompt and GUI-safe.
854
+ if self.trusted_service is not None:
855
+ self.trusted_service.close_async()
856
+ self._new_runs_publication_ack.set()
857
+
858
+
859
+ def run(self):
860
+ new_runs = {}
861
+ statuses = {}
862
+ try:
863
+ if self._cancelled.is_set():
864
+ return
865
+
866
+ self._require_current_database_instance()
867
+ if self.trusted_service is not None:
868
+ new_runs, statuses = self._run_trusted_refresh()
869
+ self._require_current_database_instance()
870
+ self._emit_finished({}, statuses, None)
871
+ return
872
+ new_runs = find_new_runs(
873
+ self.last_run_id,
874
+ database_path=self._read_database_path,
875
+ connection_callback=self._set_sql_connection,
876
+ **self._read_control_kwargs(),
877
+ ) or {}
878
+ for guid in self.watched_runs:
879
+ if self._cancelled.is_set():
880
+ return
881
+ self._require_current_database_instance()
882
+ status = get_run_status(
883
+ guid,
884
+ database_path=self._read_database_path,
885
+ include_storage_bytes=False,
886
+ connection_callback=self._set_sql_connection,
887
+ **self._read_control_kwargs(),
888
+ )
889
+ if status:
890
+ statuses[guid] = status
891
+ self._require_current_database_instance()
892
+ except InterruptedError:
893
+ return
894
+ except DatabaseInstanceChangedError as err:
895
+ if self._cancelled.is_set():
896
+ return
897
+ self._emit_finished({}, {}, err)
898
+ return
899
+ except Exception as err:
900
+ if self._cancelled.is_set():
901
+ return
902
+ log_exception("Database refresh worker failed", err, __name__)
903
+ self._emit_finished(new_runs, statuses, err)
904
+ return
905
+
906
+ self._emit_finished(new_runs, statuses, None)
907
+
908
+
909
+ def _run_trusted_refresh(self):
910
+ header = self._wait_trusted_request(
911
+ self.trusted_service.submit_refresh(
912
+ self.last_run_id,
913
+ deadline=self.deadline,
914
+ )
915
+ )
916
+ new_runs = {}
917
+ if header.data_version_changed:
918
+ cursor = header.prior_run_id_watermark
919
+ while True:
920
+ if self._cancelled.is_set():
921
+ raise InterruptedError("Database refresh cancelled.")
922
+ page = self._wait_trusted_request(
923
+ self.trusted_service.submit_basic_page(
924
+ cursor,
925
+ header.run_id_watermark,
926
+ priority=TrustedReadPriority.REFRESH,
927
+ deadline=self.deadline,
928
+ )
929
+ )
930
+ page_runs = run_records_as_dict(page.runs)
931
+ new_runs.update(page_runs)
932
+ if page_runs:
933
+ self._emit_new_runs_ready(page_runs)
934
+ if page.complete:
935
+ break
936
+ cursor = page.next_run_id
937
+
938
+ statuses = {}
939
+ if not header.data_version_changed:
940
+ return new_runs, statuses
941
+ for watched in self.watched_runs:
942
+ if self._cancelled.is_set():
943
+ raise InterruptedError("Database refresh cancelled.")
944
+ if (
945
+ isinstance(watched, (tuple, list))
946
+ and len(watched) in {2, 3}
947
+ ):
948
+ if len(watched) == 2:
949
+ run_id, guid = watched
950
+ category = "remaining"
951
+ else:
952
+ run_id, guid, category = watched
953
+ else:
954
+ continue
955
+ try:
956
+ run_id = int(run_id)
957
+ except (TypeError, ValueError):
958
+ continue
959
+ if category == "selected":
960
+ cheap_priority = TrustedReadPriority.SELECTED_CHEAP
961
+ elif category == "visible":
962
+ cheap_priority = TrustedReadPriority.VISIBLE_CHEAP
963
+ else:
964
+ cheap_priority = TrustedReadPriority.REMAINING_CHEAP
965
+ cheap = self._wait_trusted_request(
966
+ self.trusted_service.submit_cheap_run(
967
+ run_id,
968
+ priority=cheap_priority,
969
+ deadline=self.deadline,
970
+ )
971
+ )
972
+ status = cheap.as_dict()
973
+ if not self._derived_coordinator_owned:
974
+ if category == "selected":
975
+ expensive_priority = TrustedReadPriority.SELECTED_EXPENSIVE
976
+ elif category == "visible":
977
+ expensive_priority = TrustedReadPriority.VISIBLE_EXPENSIVE
978
+ else:
979
+ expensive_priority = TrustedReadPriority.REMAINING_EXPENSIVE
980
+ expensive = self._wait_trusted_request(
981
+ self.trusted_service.submit_expensive_run(
982
+ run_id,
983
+ priority=expensive_priority,
984
+ deadline=self.deadline,
985
+ )
986
+ )
987
+ status.update(expensive.as_dict())
988
+ statuses[str(guid)] = status
989
+ return new_runs, statuses
990
+
991
+
992
+ def _emit_new_runs_ready(self, new_runs):
993
+ self._require_current_database_instance()
994
+ new_runs = _bounded_runs_publication(new_runs)
995
+ if self._require_publication_ack:
996
+ with self._new_runs_publication_lock:
997
+ self._new_runs_publication_error = None
998
+ self._new_runs_publication_ack.clear()
999
+ with self._publication_lock:
1000
+ if self._cancelled.is_set():
1001
+ return
1002
+ self.signals.new_runs_ready.emit(
1003
+ self.generation,
1004
+ self._signal_database_path,
1005
+ new_runs,
1006
+ )
1007
+ if not self._require_publication_ack:
1008
+ return
1009
+
1010
+ acknowledgement_deadline = monotonic() + 10.0
1011
+ if self.deadline is not None:
1012
+ acknowledgement_deadline = min(
1013
+ acknowledgement_deadline,
1014
+ self.deadline,
1015
+ )
1016
+ while not self._new_runs_publication_ack.wait(0.05):
1017
+ if self._cancelled.is_set():
1018
+ raise InterruptedError("Database refresh cancelled.")
1019
+ if monotonic() >= acknowledgement_deadline:
1020
+ raise TimeoutError(
1021
+ "The GUI did not publish a trusted basic-run page in time."
1022
+ )
1023
+ with self._new_runs_publication_lock:
1024
+ publication_error = self._new_runs_publication_error
1025
+ if publication_error is not None:
1026
+ raise RuntimeError(
1027
+ "The GUI could not publish a trusted basic-run page; the "
1028
+ "accepted service retirement was requested to prevent an "
1029
+ "omitted run."
1030
+ ) from publication_error
1031
+ if self._cancelled.is_set():
1032
+ raise InterruptedError("Database refresh cancelled.")
1033
+
1034
+
1035
+ def _emit_finished(self, new_runs, statuses, error):
1036
+ if not isinstance(error, DatabaseInstanceChangedError):
1037
+ try:
1038
+ self._require_current_database_instance()
1039
+ except DatabaseInstanceChangedError as err:
1040
+ new_runs = {}
1041
+ statuses = {}
1042
+ error = err
1043
+ new_runs = _bounded_runs_publication(new_runs)
1044
+ statuses = _bounded_runs_publication(statuses)
1045
+ error = _bounded_worker_error(error)
1046
+ with self._publication_lock:
1047
+ if self._cancelled.is_set():
1048
+ return
1049
+ try:
1050
+ self.signals.finished.emit(
1051
+ self.generation,
1052
+ self._signal_database_path,
1053
+ new_runs,
1054
+ statuses,
1055
+ error,
1056
+ )
1057
+ except RuntimeError as err:
1058
+ message = str(err)
1059
+ if not (
1060
+ "wrapped C/C++ object" in message
1061
+ and "has been deleted" in message
1062
+ ):
1063
+ raise
1064
+
1065
+
1066
+ class DatabaseLoadWorker(_InterruptibleSqlWorker, QtCore.QRunnable):
1067
+ """
1068
+ Loads database metadata away from the GUI thread.
1069
+
1070
+ The worker is responsible for the blocking parts of opening a database:
1071
+ access probing, QCoDeS initialisation, and collecting the run table
1072
+ metadata. Widget updates stay in MainWindow on the GUI thread.
1073
+
1074
+ """
1075
+
1076
+ def __init__(
1077
+ self,
1078
+ generation,
1079
+ database_path,
1080
+ cloud_sync_timeout=DATABASE_CLOUD_SYNC_TIMEOUT_SECONDS,
1081
+ *,
1082
+ expected_database_instance=None,
1083
+ deadline=None,
1084
+ trusted_service=None,
1085
+ ):
1086
+ super().__init__()
1087
+ self.signals = DatabaseLoadSignals()
1088
+ self.generation = generation
1089
+ self._init_database_binding(
1090
+ database_path,
1091
+ expected_database_instance,
1092
+ deadline,
1093
+ )
1094
+ self.cloud_sync_timeout = cloud_sync_timeout
1095
+ self._cancelled = threading.Event()
1096
+ self._init_sql_interrupt()
1097
+ self._owns_trusted_service = trusted_service is None
1098
+ self.trusted_service = trusted_service or TrustedLiveReadService(
1099
+ self._read_database_path,
1100
+ expected_database_instance=self.database_instance,
1101
+ session_generation=max(1, int(generation)),
1102
+ )
1103
+ self.access_mode = None
1104
+ self.fallback_reason = None
1105
+
1106
+
1107
+ def cancel(self):
1108
+ """
1109
+ Marks this load as cancelled so later phases do not run.
1110
+
1111
+ """
1112
+ self._cancel_read()
1113
+ if self._owns_trusted_service:
1114
+ self.trusted_service.close_async()
1115
+
1116
+
1117
+ def run(self):
1118
+ try:
1119
+ if self._is_cancelled():
1120
+ return
1121
+
1122
+ self._require_current_database_instance()
1123
+ if database_is_likely_cloud_placeholder(self._read_database_path):
1124
+ self._prefetch_cloud_file()
1125
+ if self._is_cancelled():
1126
+ return
1127
+ self._require_current_database_instance()
1128
+ try:
1129
+ runs = self._load_trusted_runs()
1130
+ self.access_mode = TRUSTED_LIVE_MODE
1131
+ except Exception as trusted_error:
1132
+ if self._is_cancelled():
1133
+ return
1134
+ if (
1135
+ self.trusted_service.accepted
1136
+ or not trusted_open_failure_allows_snapshot_fallback(
1137
+ trusted_error
1138
+ )
1139
+ ):
1140
+ raise
1141
+ self.fallback_reason = type(trusted_error).__name__
1142
+ self._close_trusted_before_fallback()
1143
+ runs = self._load_snapshot_fallback()
1144
+ self.access_mode = SNAPSHOT_FALLBACK_MODE
1145
+ if self._is_cancelled():
1146
+ return
1147
+ self._require_current_database_instance()
1148
+ except (InterruptedError, TrustedReadRequestCancelledError):
1149
+ return
1150
+ except DatabaseInstanceChangedError as err:
1151
+ if self._is_cancelled():
1152
+ return
1153
+ self._emit_finished({}, err)
1154
+ return
1155
+ except Exception as err:
1156
+ if self._is_cancelled():
1157
+ return
1158
+ log_exception("Database load worker failed", err, __name__)
1159
+ provider = database_cloud_storage_label(self._read_database_path)
1160
+ if (
1161
+ provider
1162
+ and (
1163
+ isinstance(err, (TrustedLiveSourceIOError, TimeoutError))
1164
+ or (
1165
+ isinstance(err, OSError)
1166
+ and err.errno in {errno.EIO, errno.ETIMEDOUT}
1167
+ )
1168
+ )
1169
+ ):
1170
+ sync_app = provider if provider != "cloud storage" else "your cloud sync app"
1171
+ err = RuntimeError(
1172
+ f"The database is stored in {provider}, and qPlot could not "
1173
+ "read it or its auxiliary files in time. The cloud sync "
1174
+ "app may not be running, or some files may not be "
1175
+ "available on this device.\n\n"
1176
+ f"Start or restart {sync_app}, check that it is signed in, "
1177
+ "and wait for the database folder to finish downloading. "
1178
+ "Mark the whole folder as always available on this device, "
1179
+ "then open the database again in qPlot.\n\n"
1180
+ f"Technical details: {err}"
1181
+ )
1182
+ self._emit_finished({}, err)
1183
+ return
1184
+
1185
+ self._emit_finished(runs, None)
1186
+
1187
+
1188
+ def _load_trusted_runs(self):
1189
+ self._emit_status("Opening trusted live database...")
1190
+ header = self._wait_trusted_request(
1191
+ self.trusted_service.submit_bootstrap(deadline=self.deadline)
1192
+ )
1193
+ self._emit_status("Loading basic run list...")
1194
+ runs = {}
1195
+ cursor = 0
1196
+ while True:
1197
+ if self._is_cancelled():
1198
+ raise InterruptedError("Database load cancelled.")
1199
+ page = self._wait_trusted_request(
1200
+ self.trusted_service.submit_basic_page(
1201
+ cursor,
1202
+ header.run_id_watermark,
1203
+ priority=TrustedReadPriority.BOOTSTRAP,
1204
+ deadline=self.deadline,
1205
+ )
1206
+ )
1207
+ runs.update(run_records_as_dict(page.runs))
1208
+ if page.complete:
1209
+ break
1210
+ if page.next_run_id <= cursor:
1211
+ raise RuntimeError(
1212
+ "Trusted run-list pagination did not advance its cursor."
1213
+ )
1214
+ cursor = page.next_run_id
1215
+ return runs
1216
+
1217
+
1218
+ def _close_trusted_before_fallback(self):
1219
+ self.trusted_service.close_async()
1220
+ if not self.trusted_service.wait_closed(10):
1221
+ raise TimeoutError(
1222
+ "The unavailable trusted reader did not retire before fallback."
1223
+ )
1224
+ if self.trusted_service.close_error is not None:
1225
+ raise self.trusted_service.close_error
1226
+
1227
+
1228
+ def _load_snapshot_fallback(self):
1229
+ self._emit_status("Checking database access for snapshot fallback...")
1230
+ access_error = database_access_error(
1231
+ self._read_database_path,
1232
+ **self._read_control_kwargs(),
1233
+ )
1234
+ if self._is_cancelled():
1235
+ raise InterruptedError("Database load cancelled.")
1236
+
1237
+ if (
1238
+ access_error
1239
+ and getattr(access_error, "error_type", None) not in {
1240
+ DatabaseInstanceChangedError.__name__,
1241
+ UnverifiableDatabaseWalError.__name__,
1242
+ }
1243
+ and database_cloud_storage_label(self._read_database_path)
1244
+ and os.path.isfile(self._read_database_path)
1245
+ ):
1246
+ self._prefetch_cloud_file()
1247
+ if self._is_cancelled():
1248
+ raise InterruptedError("Database load cancelled.")
1249
+ self._emit_status("Checking database access for snapshot fallback...")
1250
+ access_error = database_access_error(
1251
+ self._read_database_path,
1252
+ **self._read_control_kwargs(),
1253
+ )
1254
+
1255
+ if access_error:
1256
+ error_type = getattr(access_error, "error_type", None)
1257
+ if error_type == DatabaseInstanceChangedError.__name__:
1258
+ raise DatabaseInstanceChangedError(access_error)
1259
+ if error_type == UnverifiableDatabaseWalError.__name__:
1260
+ raise UnverifiableDatabaseWalError(access_error)
1261
+ raise RuntimeError(access_error)
1262
+
1263
+ self._emit_status("Opening snapshot fallback read-only...")
1264
+ self._emit_status("Loading basic run list...")
1265
+ return get_runs_basic_via_sql(
1266
+ self._read_database_path,
1267
+ connection_callback=self._set_sql_connection,
1268
+ **self._read_control_kwargs(),
1269
+ ) or {}
1270
+
1271
+
1272
+ def _is_cancelled(self):
1273
+ return self._cancelled.is_set()
1274
+
1275
+
1276
+ def _emit_status(self, message):
1277
+ with self._publication_lock:
1278
+ if self._cancelled.is_set():
1279
+ return
1280
+ try:
1281
+ self.signals.status.emit(self.generation, message)
1282
+ except RuntimeError as err:
1283
+ if not self._qt_signal_was_deleted(err):
1284
+ raise
1285
+
1286
+
1287
+ def _emit_finished(self, runs, error):
1288
+ if not isinstance(error, DatabaseInstanceChangedError):
1289
+ try:
1290
+ self._require_current_database_instance()
1291
+ except DatabaseInstanceChangedError as err:
1292
+ runs = {}
1293
+ error = err
1294
+ if error is None:
1295
+ runs = _bounded_runs_publication(runs)
1296
+ else:
1297
+ runs = {}
1298
+ error = _bounded_worker_error(error)
1299
+ with self._publication_lock:
1300
+ if self._cancelled.is_set():
1301
+ return
1302
+ try:
1303
+ self.signals.finished.emit(
1304
+ self.generation,
1305
+ self._signal_database_path,
1306
+ runs,
1307
+ error,
1308
+ )
1309
+ except RuntimeError as err:
1310
+ if not self._qt_signal_was_deleted(err):
1311
+ raise
1312
+
1313
+
1314
+ def _qt_signal_was_deleted(self, err):
1315
+ message = str(err)
1316
+ return "wrapped C/C++ object" in message and "has been deleted" in message
1317
+
1318
+
1319
+ def _prefetch_cloud_file(self):
1320
+ timeout = self.cloud_sync_timeout
1321
+ if self.deadline is not None:
1322
+ remaining = self.deadline - monotonic()
1323
+ if remaining <= 0:
1324
+ raise TimeoutError("Database load deadline exceeded.")
1325
+ timeout = min(timeout, remaining)
1326
+ prefetch_database_file_with_timeout(
1327
+ self._read_database_path,
1328
+ timeout=timeout,
1329
+ status_callback=self._emit_status,
1330
+ cancelled_callback=self._is_cancelled,
1331
+ )
1332
+
1333
+
1334
+ class _PrioritizedRunWorker(_InterruptibleSqlWorker, QtCore.QRunnable):
1335
+ """
1336
+ Base for background workers that need selected/visible run prioritisation.
1337
+
1338
+ """
1339
+
1340
+ _trusted_selected_priority = TrustedReadPriority.SELECTED_CHEAP
1341
+ _trusted_visible_priority = TrustedReadPriority.VISIBLE_CHEAP
1342
+ _trusted_remaining_priority = TrustedReadPriority.REMAINING_CHEAP
1343
+
1344
+ def __init__(
1345
+ self,
1346
+ generation,
1347
+ database_path,
1348
+ run_ids,
1349
+ *,
1350
+ expected_database_instance=None,
1351
+ deadline=None,
1352
+ trusted_service=None,
1353
+ ):
1354
+ super().__init__()
1355
+ self.signals = DatabaseDetailSignals()
1356
+ self.generation = generation
1357
+ self._init_database_binding(
1358
+ database_path,
1359
+ expected_database_instance,
1360
+ deadline,
1361
+ )
1362
+ self.run_ids = list(run_ids or [])
1363
+ self._default_run_order = {
1364
+ run_id: index
1365
+ for index, run_id in enumerate(self.run_ids)
1366
+ }
1367
+ self._cancelled = threading.Event()
1368
+ self._init_sql_interrupt()
1369
+ self._priority_lock = threading.Lock()
1370
+ self._priority_scores = {}
1371
+ self._promoted_run_ids = []
1372
+ self._trusted_active_run_id = None
1373
+ self._accepting_run_ids = True
1374
+ self.trusted_service = trusted_service
1375
+
1376
+
1377
+ def cancel(self):
1378
+ with self._priority_lock:
1379
+ self._accepting_run_ids = False
1380
+ self._cancel_read()
1381
+
1382
+
1383
+ def add_run_ids(self, run_ids):
1384
+ """Append trusted incremental work without replaying completed runs."""
1385
+ candidates = []
1386
+ for run_id in run_ids or ():
1387
+ try:
1388
+ candidate = int(run_id)
1389
+ except (TypeError, ValueError):
1390
+ continue
1391
+ if candidate > 0:
1392
+ candidates.append(candidate)
1393
+
1394
+ with self._priority_lock:
1395
+ if not self._accepting_run_ids or self._cancelled.is_set():
1396
+ return False
1397
+ known = {
1398
+ int(run_id)
1399
+ for run_id in self.run_ids
1400
+ if str(run_id).lstrip("-").isdigit()
1401
+ }
1402
+ additions = [run_id for run_id in candidates if run_id not in known]
1403
+ for run_id in additions:
1404
+ self._default_run_order[run_id] = len(self.run_ids)
1405
+ self.run_ids.append(run_id)
1406
+ known.add(run_id)
1407
+ return True
1408
+
1409
+
1410
+ def prioritize_run_ids(self, run_ids):
1411
+ normalised = []
1412
+ seen = set()
1413
+ for run_id in run_ids or []:
1414
+ run_id = self._normalise_run_id(run_id)
1415
+ if run_id is None or run_id in seen:
1416
+ continue
1417
+ normalised.append(run_id)
1418
+ seen.add(run_id)
1419
+
1420
+ with self._priority_lock:
1421
+ # This is a replacement snapshot of the selected/visible rows, not
1422
+ # an accumulating promotion log. Omitted rows immediately return
1423
+ # to their stable table order, and an empty snapshot clears every
1424
+ # prior viewport/selection promotion.
1425
+ base_score = -len(normalised)
1426
+ self._priority_scores = {
1427
+ run_id: base_score + offset
1428
+ for offset, run_id in enumerate(normalised)
1429
+ }
1430
+ self._promoted_run_ids = normalised
1431
+ active_run_id = self._trusted_active_run_id
1432
+ with self._trusted_request_lock:
1433
+ request = self._trusted_request
1434
+ if active_run_id is not None and request is not None:
1435
+ requested_priority = self._trusted_priority_for_run_locked(
1436
+ active_run_id,
1437
+ promoted_order=normalised,
1438
+ )
1439
+ reprioritize = getattr(request, "reprioritize", None)
1440
+ if callable(reprioritize):
1441
+ reprioritize(requested_priority)
1442
+ elif active_run_id in normalised:
1443
+ # Compatibility with narrow request doubles that predate
1444
+ # bidirectional trusted request priorities.
1445
+ request.promote(requested_priority)
1446
+
1447
+
1448
+ def _is_cancelled(self):
1449
+ return self._cancelled.is_set()
1450
+
1451
+
1452
+ def _normalise_run_id(self, run_id):
1453
+ if run_id in self._default_run_order:
1454
+ return run_id
1455
+
1456
+ try:
1457
+ int_run_id = int(run_id)
1458
+ except (TypeError, ValueError):
1459
+ return None
1460
+
1461
+ if int_run_id in self._default_run_order:
1462
+ return int_run_id
1463
+ text_run_id = str(int_run_id)
1464
+ if text_run_id in self._default_run_order:
1465
+ return text_run_id
1466
+ return None
1467
+
1468
+
1469
+ def _next_priority_batch(self, done, batch_size):
1470
+ with self._priority_lock:
1471
+ priority_scores = dict(self._priority_scores)
1472
+ run_ids = tuple(self.run_ids)
1473
+ default_run_order = dict(self._default_run_order)
1474
+
1475
+ candidates = [run_id for run_id in run_ids if run_id not in done]
1476
+ if not candidates:
1477
+ return []
1478
+
1479
+ def sort_key(run_id):
1480
+ return (
1481
+ priority_scores.get(run_id, 0),
1482
+ default_run_order.get(run_id, 0),
1483
+ )
1484
+
1485
+ candidates.sort(key=sort_key)
1486
+ return candidates[:max(1, int(batch_size or 1))]
1487
+
1488
+
1489
+ def _next_trusted_run(self, done):
1490
+ """Choose one run or atomically stop accepting dynamic additions."""
1491
+ with self._priority_lock:
1492
+ candidates = [run_id for run_id in self.run_ids if run_id not in done]
1493
+ if not candidates:
1494
+ self._accepting_run_ids = False
1495
+ return None
1496
+ priority_scores = dict(self._priority_scores)
1497
+ default_run_order = dict(self._default_run_order)
1498
+ candidates.sort(
1499
+ key=lambda run_id: (
1500
+ priority_scores.get(run_id, 0),
1501
+ default_run_order.get(run_id, 0),
1502
+ )
1503
+ )
1504
+ return candidates[0]
1505
+
1506
+
1507
+ def _trusted_priority_for_run(self, run_id, promoted_order=None):
1508
+ with self._priority_lock:
1509
+ return self._trusted_priority_for_run_locked(
1510
+ run_id,
1511
+ promoted_order=promoted_order,
1512
+ )
1513
+
1514
+
1515
+ def _trusted_priority_for_run_locked(self, run_id, promoted_order=None):
1516
+ """Return one trusted priority while ``_priority_lock`` is held."""
1517
+
1518
+ order = list(
1519
+ self._promoted_run_ids
1520
+ if promoted_order is None
1521
+ else promoted_order
1522
+ )
1523
+ if run_id in order:
1524
+ return (
1525
+ self._trusted_selected_priority
1526
+ if order.index(run_id) == 0
1527
+ else self._trusted_visible_priority
1528
+ )
1529
+ return self._trusted_remaining_priority
1530
+
1531
+
1532
+ def _submit_next_trusted_run(self, done, submit):
1533
+ """Choose, submit, and install one request as one priority transition."""
1534
+
1535
+ with self._priority_lock:
1536
+ candidates = [run_id for run_id in self.run_ids if run_id not in done]
1537
+ if not candidates:
1538
+ self._accepting_run_ids = False
1539
+ return None
1540
+ candidates.sort(
1541
+ key=lambda run_id: (
1542
+ self._priority_scores.get(run_id, 0),
1543
+ self._default_run_order.get(run_id, 0),
1544
+ )
1545
+ )
1546
+ run_id = candidates[0]
1547
+ priority = self._trusted_priority_for_run_locked(run_id)
1548
+ self._trusted_active_run_id = run_id
1549
+ try:
1550
+ request = submit(run_id, priority)
1551
+ except Exception:
1552
+ self._trusted_active_run_id = None
1553
+ raise
1554
+ # ``prioritize_run_ids`` takes these locks in the same order. It
1555
+ # therefore observes either no active run or an installed request;
1556
+ # promotion cannot fall into the former submit/install gap.
1557
+ with self._trusted_request_lock:
1558
+ self._trusted_request = request
1559
+ return run_id, request
1560
+
1561
+
1562
+ def _run_trusted_details(self, status_label, submit):
1563
+ with self._priority_lock:
1564
+ total = len(self.run_ids)
1565
+ if total == 0:
1566
+ self._emit_finished(None)
1567
+ return
1568
+ done = set()
1569
+ first_error = None
1570
+ self._emit_status(f"{status_label} 0/{total}")
1571
+ while True:
1572
+ if self._is_cancelled():
1573
+ return
1574
+ submitted = self._submit_next_trusted_run(done, submit)
1575
+ if submitted is None:
1576
+ break
1577
+ run_id, request = submitted
1578
+ try:
1579
+ record = self._wait_trusted_request(request)
1580
+ except Exception as error:
1581
+ if self._is_cancelled():
1582
+ return
1583
+ service = self.trusted_service
1584
+ if (
1585
+ service is None
1586
+ or isinstance(error, DatabaseInstanceChangedError)
1587
+ or TrustedLiveReadService._terminal_session_error(error)
1588
+ or bool(getattr(service, "closing", False))
1589
+ or bool(getattr(service, "closed", False))
1590
+ ):
1591
+ raise
1592
+ if first_error is None:
1593
+ first_error = error
1594
+ done.add(run_id)
1595
+ finally:
1596
+ with self._priority_lock:
1597
+ if self._trusted_active_run_id == run_id:
1598
+ self._trusted_active_run_id = None
1599
+ if self._is_cancelled():
1600
+ return
1601
+ if run_id not in done:
1602
+ self._emit_batch_ready({record.run_id: record.as_dict()})
1603
+ done.add(run_id)
1604
+ with self._priority_lock:
1605
+ total = len(self.run_ids)
1606
+ self._emit_status(f"{status_label} {len(done)}/{total}")
1607
+ self._emit_finished(first_error)
1608
+
1609
+
1610
+ def _emit_status(self, message):
1611
+ with self._publication_lock:
1612
+ if self._cancelled.is_set():
1613
+ return
1614
+ try:
1615
+ self.signals.status.emit(self.generation, message)
1616
+ except RuntimeError as err:
1617
+ if not self._qt_signal_was_deleted(err):
1618
+ raise
1619
+
1620
+
1621
+ def _emit_batch_ready(self, details):
1622
+ self._require_current_database_instance()
1623
+ details = _bounded_runs_publication(details)
1624
+ with self._publication_lock:
1625
+ if self._cancelled.is_set():
1626
+ return
1627
+ try:
1628
+ self.signals.batch_ready.emit(
1629
+ self.generation,
1630
+ self._signal_database_path,
1631
+ details,
1632
+ )
1633
+ except RuntimeError as err:
1634
+ if not self._qt_signal_was_deleted(err):
1635
+ raise
1636
+
1637
+
1638
+ def _emit_finished(self, error):
1639
+ with self._priority_lock:
1640
+ self._accepting_run_ids = False
1641
+ if not isinstance(error, DatabaseInstanceChangedError):
1642
+ try:
1643
+ self._require_current_database_instance()
1644
+ except DatabaseInstanceChangedError as err:
1645
+ error = err
1646
+ error = _bounded_worker_error(error)
1647
+ with self._publication_lock:
1648
+ if self._cancelled.is_set():
1649
+ return
1650
+ try:
1651
+ self.signals.finished.emit(
1652
+ self.generation,
1653
+ self._signal_database_path,
1654
+ error,
1655
+ )
1656
+ except RuntimeError as err:
1657
+ if not self._qt_signal_was_deleted(err):
1658
+ raise
1659
+
1660
+
1661
+ def _qt_signal_was_deleted(self, err):
1662
+ message = str(err)
1663
+ return "wrapped C/C++ object" in message and "has been deleted" in message
1664
+
1665
+
1666
+ class DatabaseDetailWorker(_PrioritizedRunWorker):
1667
+ """
1668
+ Loads cheap per-run metadata after the basic run table is visible.
1669
+
1670
+ """
1671
+
1672
+ def __init__(
1673
+ self,
1674
+ generation,
1675
+ database_path,
1676
+ run_ids,
1677
+ batch_size=1,
1678
+ *,
1679
+ expected_database_instance=None,
1680
+ deadline=None,
1681
+ trusted_service=None,
1682
+ ):
1683
+ super().__init__(
1684
+ generation,
1685
+ database_path,
1686
+ run_ids,
1687
+ expected_database_instance=expected_database_instance,
1688
+ deadline=deadline,
1689
+ trusted_service=trusted_service,
1690
+ )
1691
+ self.batch_size = max(1, int(batch_size or 1))
1692
+
1693
+
1694
+ def run(self):
1695
+ total = len(self.run_ids)
1696
+ completed = 0
1697
+ try:
1698
+ if self._is_cancelled():
1699
+ return
1700
+ if self.trusted_service is not None:
1701
+ self._run_trusted_details(
1702
+ "Loading cheap run metadata...",
1703
+ lambda run_id, priority:
1704
+ self.trusted_service.submit_cheap_run(
1705
+ int(run_id),
1706
+ priority=priority,
1707
+ deadline=self.deadline,
1708
+ ),
1709
+ )
1710
+ return
1711
+ if total == 0:
1712
+ self._emit_finished(None)
1713
+ return
1714
+
1715
+ self._emit_status(f"Loading run details... 0/{total}")
1716
+ done = set()
1717
+ while len(done) < total:
1718
+ if self._is_cancelled():
1719
+ return
1720
+
1721
+ batch = self._next_priority_batch(done, self.batch_size)
1722
+ if not batch:
1723
+ break
1724
+ self._require_current_database_instance()
1725
+ batches = iter_run_detail_batches_via_sql(
1726
+ self._read_database_path,
1727
+ batch,
1728
+ batch_size=self.batch_size,
1729
+ infer_missing_shapes=False,
1730
+ include_storage_bytes=False,
1731
+ include_storage_estimate=True,
1732
+ include_read_setpoint_count=True,
1733
+ connection_callback=self._set_sql_connection,
1734
+ **self._read_control_kwargs(),
1735
+ )
1736
+ try:
1737
+ for details in batches:
1738
+ if self._is_cancelled():
1739
+ return
1740
+ if details:
1741
+ self._emit_batch_ready(details)
1742
+ finally:
1743
+ self._close_batch_iterator(batches)
1744
+
1745
+ done.update(batch)
1746
+ completed = len(done)
1747
+ self._emit_status(
1748
+ f"Loading run details... {min(completed, total)}/{total}"
1749
+ )
1750
+ except InterruptedError:
1751
+ return
1752
+ except DatabaseInstanceChangedError as err:
1753
+ if self._is_cancelled():
1754
+ return
1755
+ self._emit_finished(err)
1756
+ return
1757
+ except Exception as err:
1758
+ if self._is_cancelled():
1759
+ return
1760
+ log_exception("Database detail worker failed", err, __name__)
1761
+ self._emit_finished(err)
1762
+ return
1763
+
1764
+ self._emit_finished(None)
1765
+
1766
+
1767
+ class DatabaseExpensiveDetailWorker(_PrioritizedRunWorker):
1768
+ """
1769
+ Loads expensive shape and storage metadata in priority order.
1770
+
1771
+ """
1772
+
1773
+ _trusted_selected_priority = TrustedReadPriority.SELECTED_EXPENSIVE
1774
+ _trusted_visible_priority = TrustedReadPriority.VISIBLE_EXPENSIVE
1775
+ _trusted_remaining_priority = TrustedReadPriority.REMAINING_EXPENSIVE
1776
+
1777
+ def __init__(
1778
+ self,
1779
+ generation,
1780
+ database_path,
1781
+ run_ids,
1782
+ batch_size=10,
1783
+ *,
1784
+ expected_database_instance=None,
1785
+ deadline=None,
1786
+ trusted_service=None,
1787
+ ):
1788
+ super().__init__(
1789
+ generation,
1790
+ database_path,
1791
+ run_ids,
1792
+ expected_database_instance=expected_database_instance,
1793
+ deadline=deadline,
1794
+ trusted_service=trusted_service,
1795
+ )
1796
+ self.batch_size = max(1, int(batch_size or 1))
1797
+
1798
+
1799
+ def run(self):
1800
+ total = len(self.run_ids)
1801
+ try:
1802
+ if self._is_cancelled():
1803
+ return
1804
+ if self.trusted_service is not None:
1805
+ self._run_trusted_details(
1806
+ "Loading counts, shapes, and sizes...",
1807
+ lambda run_id, priority:
1808
+ self.trusted_service.submit_expensive_run(
1809
+ int(run_id),
1810
+ priority=priority,
1811
+ deadline=self.deadline,
1812
+ ),
1813
+ )
1814
+ return
1815
+ if total == 0:
1816
+ self._emit_finished(None)
1817
+ return
1818
+
1819
+ shape_done = set()
1820
+ self._emit_status(f"Loading setpoint shapes... 0/{total}")
1821
+ while len(shape_done) < total:
1822
+ if self._is_cancelled():
1823
+ return
1824
+
1825
+ batch = self._next_priority_batch(
1826
+ shape_done,
1827
+ batch_size=self.batch_size,
1828
+ )
1829
+ if not batch:
1830
+ break
1831
+
1832
+ self._require_current_database_instance()
1833
+ batches = iter_run_shape_batches_via_sql(
1834
+ self._read_database_path,
1835
+ batch,
1836
+ batch_size=self.batch_size,
1837
+ connection_callback=self._set_sql_connection,
1838
+ **self._read_control_kwargs(),
1839
+ )
1840
+ try:
1841
+ for shapes in batches:
1842
+ if self._is_cancelled():
1843
+ return
1844
+
1845
+ if shapes:
1846
+ self._emit_batch_ready(shapes)
1847
+ finally:
1848
+ self._close_batch_iterator(batches)
1849
+
1850
+ shape_done.update(batch)
1851
+ self._emit_status(
1852
+ f"Loading setpoint shapes... {len(shape_done)}/{total}"
1853
+ )
1854
+
1855
+ storage_done = set()
1856
+ storage_batch_size = max(25, self.batch_size)
1857
+ self._emit_status(f"Loading exact run sizes... 0/{total}")
1858
+ while len(storage_done) < total:
1859
+ if self._is_cancelled():
1860
+ return
1861
+
1862
+ batch = self._next_priority_batch(
1863
+ storage_done,
1864
+ batch_size=storage_batch_size,
1865
+ )
1866
+ if not batch:
1867
+ break
1868
+
1869
+ self._require_current_database_instance()
1870
+ batches = iter_run_storage_batches_via_sql(
1871
+ self._read_database_path,
1872
+ batch,
1873
+ batch_size=storage_batch_size,
1874
+ connection_callback=self._set_sql_connection,
1875
+ **self._read_control_kwargs(),
1876
+ )
1877
+ try:
1878
+ for storage in batches:
1879
+ if self._is_cancelled():
1880
+ return
1881
+
1882
+ if storage:
1883
+ self._emit_batch_ready(storage)
1884
+ finally:
1885
+ self._close_batch_iterator(batches)
1886
+
1887
+ storage_done.update(batch)
1888
+ self._emit_status(
1889
+ f"Loading exact run sizes... {len(storage_done)}/{total}"
1890
+ )
1891
+ except InterruptedError:
1892
+ return
1893
+ except DatabaseInstanceChangedError as err:
1894
+ if self._is_cancelled():
1895
+ return
1896
+ self._emit_finished(err)
1897
+ return
1898
+ except Exception as err:
1899
+ if self._is_cancelled():
1900
+ return
1901
+ log_exception("Expensive database detail worker failed", err, __name__)
1902
+ self._emit_finished(err)
1903
+ return
1904
+
1905
+ self._emit_finished(None)
1906
+
1907
+
1908
+ class DatabaseSelectedRunWorker(_InterruptibleSqlWorker, QtCore.QRunnable):
1909
+ """Load a plain selected-run view off-GUI for either accepted read mode."""
1910
+
1911
+ def __init__(
1912
+ self,
1913
+ generation,
1914
+ database_path,
1915
+ run_id,
1916
+ guid,
1917
+ trusted_service=None,
1918
+ *,
1919
+ run_metadata=None,
1920
+ expected_database_instance=None,
1921
+ deadline=None,
1922
+ ):
1923
+ super().__init__()
1924
+ if trusted_service is not None and not isinstance(
1925
+ trusted_service,
1926
+ TrustedLiveReadService,
1927
+ ):
1928
+ raise TypeError(
1929
+ "trusted_service must be a TrustedLiveReadService or None."
1930
+ )
1931
+ self.signals = DatabaseSelectedRunSignals()
1932
+ self.generation = generation
1933
+ self.run_id = int(run_id)
1934
+ self.guid = str(guid)
1935
+ self.trusted_service = trusted_service
1936
+ self.run_metadata = dict(run_metadata or {})
1937
+ self._init_database_binding(
1938
+ database_path,
1939
+ expected_database_instance,
1940
+ deadline,
1941
+ )
1942
+ self._cancelled = threading.Event()
1943
+ self._init_sql_interrupt()
1944
+
1945
+
1946
+ def cancel(self):
1947
+ self._cancel_read()
1948
+
1949
+
1950
+ def run(self):
1951
+ try:
1952
+ if self._cancelled.is_set():
1953
+ return
1954
+ self._require_current_database_instance()
1955
+ if self.trusted_service is None:
1956
+ detail = self._run_snapshot_detail()
1957
+ self._require_current_database_instance()
1958
+ self._emit_finished(detail, None)
1959
+ return
1960
+ self._wait_trusted_request(
1961
+ self.trusted_service.submit_cheap_run(
1962
+ self.run_id,
1963
+ priority=TrustedReadPriority.SELECTED_CHEAP,
1964
+ deadline=self.deadline,
1965
+ )
1966
+ )
1967
+ initial_detail = self._wait_trusted_request(
1968
+ self.trusted_service.submit_selected_run(
1969
+ self.run_id,
1970
+ priority=TrustedReadPriority.SELECTED_CHEAP,
1971
+ deadline=self.deadline,
1972
+ )
1973
+ )
1974
+ self._emit_progress(initial_detail)
1975
+ if self._cancelled.is_set():
1976
+ return
1977
+ self._wait_trusted_request(
1978
+ self.trusted_service.submit_expensive_run(
1979
+ self.run_id,
1980
+ priority=TrustedReadPriority.SELECTED_EXPENSIVE,
1981
+ deadline=self.deadline,
1982
+ )
1983
+ )
1984
+ detail = self._wait_trusted_request(
1985
+ self.trusted_service.submit_selected_run(
1986
+ self.run_id,
1987
+ priority=TrustedReadPriority.SELECTED_EXPENSIVE,
1988
+ deadline=self.deadline,
1989
+ )
1990
+ )
1991
+ self._require_current_database_instance()
1992
+ except (InterruptedError, TrustedReadRequestCancelledError):
1993
+ return
1994
+ except Exception as error:
1995
+ if self._cancelled.is_set():
1996
+ return
1997
+ log_exception("Selected-run detail worker failed", error, __name__)
1998
+ self._emit_finished(None, error)
1999
+ return
2000
+ self._emit_finished(detail, None)
2001
+
2002
+
2003
+ def _run_snapshot_detail(self):
2004
+ return get_snapshot_selected_run_detail(
2005
+ self._read_database_path,
2006
+ self.run_id,
2007
+ self.guid,
2008
+ self.run_metadata,
2009
+ connection_callback=self._set_sql_connection,
2010
+ **self._read_control_kwargs(),
2011
+ )
2012
+
2013
+
2014
+ def _emit_progress(self, detail):
2015
+ self._require_current_database_instance()
2016
+ with self._publication_lock:
2017
+ if self._cancelled.is_set():
2018
+ return
2019
+ self.signals.progress.emit(
2020
+ self.generation,
2021
+ self._signal_database_path,
2022
+ self.guid,
2023
+ detail,
2024
+ )
2025
+
2026
+
2027
+ def _emit_finished(self, detail, error):
2028
+ if not isinstance(error, DatabaseInstanceChangedError):
2029
+ try:
2030
+ self._require_current_database_instance()
2031
+ except DatabaseInstanceChangedError as changed_error:
2032
+ detail = None
2033
+ error = changed_error
2034
+ if error is not None:
2035
+ detail = None
2036
+ error = _bounded_worker_error(error)
2037
+ with self._publication_lock:
2038
+ if self._cancelled.is_set():
2039
+ return
2040
+ self.signals.finished.emit(
2041
+ self.generation,
2042
+ self._signal_database_path,
2043
+ self.guid,
2044
+ detail,
2045
+ error,
2046
+ )
2047
+
2048
+
2049
+ class DatabaseInfoSignals(QtCore.QObject):
2050
+ finished = QtCore.pyqtSignal(int, str, object, object)
2051
+
2052
+
2053
+ class DatabaseInfoWorker(_InterruptibleSqlWorker, QtCore.QRunnable):
2054
+ """Read diagnostic metadata with cancellable source-bound background work."""
2055
+
2056
+ def __init__(
2057
+ self,
2058
+ generation,
2059
+ database_path,
2060
+ *,
2061
+ expected_database_instance=None,
2062
+ deadline=None,
2063
+ trusted_service=None,
2064
+ ):
2065
+ super().__init__()
2066
+ self.signals = DatabaseInfoSignals()
2067
+ self.generation = generation
2068
+ self._init_database_binding(
2069
+ database_path, expected_database_instance, deadline,
2070
+ )
2071
+ self._cancelled = threading.Event()
2072
+ self._init_sql_interrupt()
2073
+ self.trusted_service = trusted_service
2074
+
2075
+ def cancel(self):
2076
+ self._cancel_read()
2077
+
2078
+ def run(self):
2079
+ rows = None
2080
+ error = None
2081
+ try:
2082
+ if self._cancelled.is_set():
2083
+ return
2084
+ self._require_current_database_instance()
2085
+ if self.trusted_service is not None:
2086
+ trusted_info = self._wait_trusted_request(
2087
+ self.trusted_service.submit_database_info(deadline=self.deadline)
2088
+ )
2089
+ summary = _database_info_summary(
2090
+ self._read_database_path, trusted_info=trusted_info,
2091
+ )
2092
+ else:
2093
+ summary = _database_info_summary(
2094
+ self._read_database_path,
2095
+ connection_callback=self._set_sql_connection,
2096
+ **self._read_control_kwargs(),
2097
+ )
2098
+ rows = [row for group in _database_info_groups(summary) for row in group]
2099
+ self._require_current_database_instance()
2100
+ except (InterruptedError, TrustedReadRequestCancelledError) as err:
2101
+ if self._cancelled.is_set():
2102
+ return
2103
+ error = _bounded_worker_error(err)
2104
+ except Exception as err:
2105
+ rows = None
2106
+ error = _bounded_worker_error(err)
2107
+ with self._publication_lock:
2108
+ if not self._cancelled.is_set():
2109
+ self.signals.finished.emit(
2110
+ self.generation, self._signal_database_path, rows, error,
2111
+ )
2112
+
2113
+
2114
+ def database_info_report(database_path):
2115
+ """
2116
+ Build a diagnostic text report for a QCoDeS database file.
2117
+
2118
+ """
2119
+ return _format_database_info(_database_info_summary(database_path))
2120
+
2121
+
2122
+ def database_info_rows(database_path):
2123
+ """
2124
+ Build display rows for the database information dialog.
2125
+
2126
+ """
2127
+ groups = _database_info_groups(_database_info_summary(database_path))
2128
+ return [row for group in groups for row in group]
2129
+
2130
+
2131
+ def _database_info_summary(
2132
+ database_path,
2133
+ *,
2134
+ trusted_info=None,
2135
+ connection_callback=None,
2136
+ **read_controls,
2137
+ ):
2138
+ if not database_path:
2139
+ raise ValueError("No database is loaded.")
2140
+
2141
+ if not os.path.isfile(database_path):
2142
+ raise FileNotFoundError(database_path)
2143
+
2144
+ path = os.path.abspath(database_path)
2145
+ file_size = os.path.getsize(path)
2146
+
2147
+ if trusted_info is not None:
2148
+ return {
2149
+ "path": path,
2150
+ "folder": os.path.dirname(path),
2151
+ "filename": os.path.basename(path),
2152
+ "file_size": file_size,
2153
+ "file_modified": os.path.getmtime(path),
2154
+ "user_version": trusted_info.user_version,
2155
+ "application_id": trusted_info.application_id,
2156
+ "page_count": trusted_info.page_count,
2157
+ "page_size": trusted_info.page_size,
2158
+ "table_count": trusted_info.table_count,
2159
+ "experiment_count": trusted_info.experiment_count,
2160
+ "run_count": trusted_info.run_count,
2161
+ "latest_run": (
2162
+ trusted_info.latest_run.as_dict()
2163
+ if trusted_info.latest_run is not None else None
2164
+ ),
2165
+ }
2166
+
2167
+ conn = sqlite_read_only_connection(path, timeout=10, **read_controls)
2168
+ try:
2169
+ if connection_callback is not None:
2170
+ connection_callback(conn)
2171
+ cursor = conn.cursor()
2172
+ user_version = _pragma_value(cursor, "user_version")
2173
+ summary = {
2174
+ "path": path,
2175
+ "folder": os.path.dirname(path),
2176
+ "filename": os.path.basename(path),
2177
+ "file_size": file_size,
2178
+ "file_modified": os.path.getmtime(path),
2179
+ "user_version": user_version,
2180
+ "application_id": _pragma_value(cursor, "application_id"),
2181
+ "page_count": _pragma_value(cursor, "page_count"),
2182
+ "page_size": _pragma_value(cursor, "page_size"),
2183
+ "table_count": _table_count(cursor),
2184
+ "experiment_count": _row_count(cursor, "experiments"),
2185
+ "run_count": _row_count(cursor, "runs"),
2186
+ "latest_run": _latest_run(cursor),
2187
+ }
2188
+ finally:
2189
+ conn.close()
2190
+ if connection_callback is not None:
2191
+ connection_callback(None)
2192
+
2193
+ return summary
2194
+
2195
+
2196
+ def _pragma_value(cursor, name):
2197
+ cursor.execute(f"PRAGMA {name}")
2198
+ value = cursor.fetchone()
2199
+ return value[0] if value else None
2200
+
2201
+
2202
+ def _table_count(cursor):
2203
+ cursor.execute("""
2204
+ SELECT COUNT(*)
2205
+ FROM sqlite_master
2206
+ WHERE type='table' AND name NOT LIKE 'sqlite_%'
2207
+ """)
2208
+ return cursor.fetchone()[0]
2209
+
2210
+
2211
+ def _row_count(cursor, table_name):
2212
+ if not _table_exists(cursor, table_name):
2213
+ return None
2214
+
2215
+ cursor.execute(f"SELECT COUNT(*) FROM {_sqlite_identifier(table_name)}")
2216
+ return cursor.fetchone()[0]
2217
+
2218
+
2219
+ def _table_exists(cursor, table_name):
2220
+ cursor.execute(
2221
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
2222
+ (table_name, )
2223
+ )
2224
+ return cursor.fetchone() is not None
2225
+
2226
+
2227
+ def _latest_run(cursor):
2228
+ if not _table_exists(cursor, "runs"):
2229
+ return None
2230
+
2231
+ cursor.execute("""
2232
+ SELECT run_id, name, run_timestamp, completed_timestamp, is_completed, guid
2233
+ FROM runs
2234
+ ORDER BY run_id DESC
2235
+ LIMIT 1
2236
+ """)
2237
+ value = cursor.fetchone()
2238
+ if value is None:
2239
+ return None
2240
+
2241
+ return {
2242
+ "run_id": value[0],
2243
+ "name": value[1],
2244
+ "run_timestamp": value[2],
2245
+ "completed_timestamp": value[3],
2246
+ "is_completed": value[4],
2247
+ "guid": value[5],
2248
+ }
2249
+
2250
+
2251
+ def _sqlite_identifier(name):
2252
+ return f'"{str(name).replace(chr(34), chr(34) * 2)}"'
2253
+
2254
+
2255
+ def _database_info_groups(summary):
2256
+ latest_run = summary["latest_run"]
2257
+ latest_run_rows = [("Latest run", "None")]
2258
+ if latest_run:
2259
+ status = "completed" if latest_run.get("is_completed") else "running or incomplete"
2260
+ latest_run_rows = [
2261
+ ("Latest run ID", _display_value(latest_run.get("run_id"))),
2262
+ ("Latest run name", _display_value(latest_run.get("name"))),
2263
+ ("Latest run status", status),
2264
+ ("Latest run started", _timestamp_value(latest_run.get("run_timestamp"))),
2265
+ ("Latest run completed", _timestamp_value(latest_run.get("completed_timestamp"))),
2266
+ ("Latest run GUID", _display_value(latest_run.get("guid"))),
2267
+ ]
2268
+
2269
+ page_bytes = None
2270
+ if summary["page_count"] is not None and summary["page_size"] is not None:
2271
+ page_bytes = int(summary["page_count"]) * int(summary["page_size"])
2272
+
2273
+ return [
2274
+ [
2275
+ ("Database", summary["filename"]),
2276
+ ("Path", summary["path"]),
2277
+ ("Folder", summary["folder"]),
2278
+ ("File size", _format_bytes(summary["file_size"])),
2279
+ ("Last modified", _timestamp_value(summary["file_modified"])),
2280
+ ("SQLite allocated size", _format_bytes(page_bytes)),
2281
+ ],
2282
+ [
2283
+ ("Database schema version", _display_value(summary["user_version"])),
2284
+ ("SQLite application_id", _display_value(summary["application_id"])),
2285
+ ],
2286
+ [
2287
+ ("Tables", _display_value(summary["table_count"])),
2288
+ ("Experiments", _display_value(summary["experiment_count"])),
2289
+ ("Runs", _display_value(summary["run_count"])),
2290
+ ],
2291
+ latest_run_rows,
2292
+ ]
2293
+
2294
+
2295
+ def _format_database_info(summary):
2296
+ groups = _database_info_groups(summary)
2297
+ sections = [
2298
+ "\n".join(f"{label}: {_display_value(value)}" for label, value in group)
2299
+ for group in groups
2300
+ ]
2301
+ return "\n\n".join(sections)
2302
+
2303
+
2304
+ def _format_bytes(value):
2305
+ if value is None:
2306
+ return "Unknown"
2307
+
2308
+ value = int(value)
2309
+ units = ("B", "KB", "MB", "GB", "TB")
2310
+ size = float(value)
2311
+ for unit in units:
2312
+ if size < 1024 or unit == units[-1]:
2313
+ return f"{size:.1f} {unit}" if unit != "B" else f"{value} B"
2314
+ size /= 1024
2315
+
2316
+
2317
+ def _timestamp_value(value):
2318
+ if value in (None, ""):
2319
+ return "Not recorded"
2320
+
2321
+ try:
2322
+ return datetime.fromtimestamp(float(value)).strftime("%Y-%m-%d %H:%M:%S")
2323
+ except (TypeError, ValueError, OSError):
2324
+ return str(value)
2325
+
2326
+
2327
+ def _display_value(value):
2328
+ if value in (None, ""):
2329
+ return "Unknown"
2330
+ return str(value)