qplotter 1.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qplot/__init__.py +46 -0
- qplot/__main__.py +232 -0
- qplot/_auxiliary_paths.py +67 -0
- qplot/_build_info.json +1 -0
- qplot/_metadata.py +75 -0
- qplot/_repair.py +14 -0
- qplot/_shutdown_supervisor.py +4049 -0
- qplot/_version.py +12 -0
- qplot/_windows_shutdown_job.py +1166 -0
- qplot/configuration/__init__.py +3 -0
- qplot/configuration/config.py +578 -0
- qplot/configuration/config_schema.json +357 -0
- qplot/configuration/fonts.py +3 -0
- qplot/configuration/scripts.py +255 -0
- qplot/configuration/themes/__init__.py +13 -0
- qplot/configuration/themes/_base.py +592 -0
- qplot/configuration/themes/blank.py +17 -0
- qplot/configuration/themes/dark.py +54 -0
- qplot/configuration/themes/light.py +54 -0
- qplot/datahandling/LoadFromDB.py +224 -0
- qplot/datahandling/__init__.py +47 -0
- qplot/datahandling/_trusted_live_helper.py +812 -0
- qplot/datahandling/_trusted_live_protocol.py +1745 -0
- qplot/datahandling/database.py +2340 -0
- qplot/datahandling/dimensions.py +43 -0
- qplot/datahandling/file_identity.py +461 -0
- qplot/datahandling/parameter_data.py +149 -0
- qplot/datahandling/plot_progress.py +17 -0
- qplot/datahandling/qcodes_cache.py +277 -0
- qplot/datahandling/qcodes_compat.py +14 -0
- qplot/datahandling/readSQL.py +2442 -0
- qplot/datahandling/readonly.py +2566 -0
- qplot/datahandling/source_activity.py +21 -0
- qplot/datahandling/trusted_array_preview.py +148 -0
- qplot/datahandling/trusted_derived_cache.py +1605 -0
- qplot/datahandling/trusted_derived_rendering.py +876 -0
- qplot/datahandling/trusted_heatmap.py +246 -0
- qplot/datahandling/trusted_live.py +2808 -0
- qplot/datahandling/trusted_live_queries.py +5907 -0
- qplot/datahandling/trusted_live_service.py +1919 -0
- qplot/datahandling/trusted_live_supervisor.py +2161 -0
- qplot/datahandling/trusted_plot.py +283 -0
- qplot/datahandling/trusted_presentation.py +1065 -0
- qplot/datahandling/trusted_snapshot.py +1132 -0
- qplot/datahandling/trusted_work_coordinator.py +2157 -0
- qplot/datahandling/trusted_work_scheduler.py +1204 -0
- qplot/diagnostics.py +241 -0
- qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
- qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
- qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
- qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
- qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
- qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
- qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
- qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
- qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
- qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
- qplot/testdata.py +1804 -0
- qplot/tools/__init__.py +13 -0
- qplot/tools/finite_means.py +123 -0
- qplot/tools/general.py +139 -0
- qplot/tools/heatmap_geometry.py +391 -0
- qplot/tools/operation_registry.py +179 -0
- qplot/tools/plot_tools.py +650 -0
- qplot/tools/sample_statistics.py +111 -0
- qplot/tools/worker.py +2838 -0
- qplot/windows/__init__.py +9 -0
- qplot/windows/_colorbar.py +422 -0
- qplot/windows/_commands.py +523 -0
- qplot/windows/_config_persistence.py +123 -0
- qplot/windows/_database_actions.py +4933 -0
- qplot/windows/_dataset_handle.py +221 -0
- qplot/windows/_display_precision.py +48 -0
- qplot/windows/_dragdrop.py +70 -0
- qplot/windows/_export_paths.py +1167 -0
- qplot/windows/_help.py +210 -0
- qplot/windows/_mixed_appearance.py +303 -0
- qplot/windows/_mixed_plots.py +461 -0
- qplot/windows/_native_averaging.py +323 -0
- qplot/windows/_native_transforms.py +551 -0
- qplot/windows/_plot1d_snap.py +612 -0
- qplot/windows/_plot1d_traces.py +2236 -0
- qplot/windows/_plot2d_colorbar.py +1127 -0
- qplot/windows/_plot2d_colorbar_dialog.py +786 -0
- qplot/windows/_plot2d_layers.py +2291 -0
- qplot/windows/_plot2d_sweeps.py +712 -0
- qplot/windows/_plotWin.py +2082 -0
- qplot/windows/_plot_actions.py +2703 -0
- qplot/windows/_plot_annotation.py +186 -0
- qplot/windows/_plot_appearance.py +283 -0
- qplot/windows/_plot_axis_scaling.py +1521 -0
- qplot/windows/_plot_data_viewbox.py +94 -0
- qplot/windows/_plot_drop_axes.py +224 -0
- qplot/windows/_plot_export.py +1445 -0
- qplot/windows/_plot_feedback.py +112 -0
- qplot/windows/_plot_marquee.py +659 -0
- qplot/windows/_plot_refresh.py +950 -0
- qplot/windows/_plot_source_promotion.py +178 -0
- qplot/windows/_plot_state.py +237 -0
- qplot/windows/_plot_status.py +115 -0
- qplot/windows/_plot_tiling.py +35 -0
- qplot/windows/_plot_transform_labels.py +60 -0
- qplot/windows/_preferences.py +724 -0
- qplot/windows/_refresh_interval.py +172 -0
- qplot/windows/_run_controls.py +789 -0
- qplot/windows/_shortcuts.py +47 -0
- qplot/windows/_subplots/__init__.py +8 -0
- qplot/windows/_subplots/subplot1d.py +532 -0
- qplot/windows/_subplots/subplot2d.py +919 -0
- qplot/windows/_svg_export.py +209 -0
- qplot/windows/_trusted_derived_qt.py +1759 -0
- qplot/windows/_widgets/__init__.py +23 -0
- qplot/windows/_widgets/_run_formatting.py +387 -0
- qplot/windows/_widgets/details_tables.py +1119 -0
- qplot/windows/_widgets/dropbox.py +290 -0
- qplot/windows/_widgets/operations.py +460 -0
- qplot/windows/_widgets/preview.py +2997 -0
- qplot/windows/_widgets/run_list_items.py +488 -0
- qplot/windows/_widgets/toolbar.py +111 -0
- qplot/windows/_widgets/treeWidgets.py +2572 -0
- qplot/windows/_window_controls.py +414 -0
- qplot/windows/main.py +1632 -0
- qplot/windows/plot1d.py +313 -0
- qplot/windows/plot2d.py +1935 -0
- qplotter-1.6.0.dist-info/METADATA +322 -0
- qplotter-1.6.0.dist-info/RECORD +130 -0
- qplotter-1.6.0.dist-info/WHEEL +5 -0
- qplotter-1.6.0.dist-info/entry_points.txt +4 -0
- qplotter-1.6.0.dist-info/licenses/LICENSE +21 -0
- qplotter-1.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|