qplotter 1.6.0b3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qplot/__init__.py +46 -0
- qplot/__main__.py +232 -0
- qplot/_auxiliary_paths.py +63 -0
- qplot/_metadata.py +12 -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 +573 -0
- qplot/configuration/config_schema.json +319 -0
- qplot/configuration/scripts.py +255 -0
- qplot/configuration/themes/__init__.py +13 -0
- qplot/configuration/themes/_base.py +579 -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 +2330 -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 +2561 -0
- qplot/datahandling/source_activity.py +21 -0
- qplot/datahandling/trusted_array_preview.py +127 -0
- qplot/datahandling/trusted_derived_cache.py +1605 -0
- qplot/datahandling/trusted_derived_rendering.py +868 -0
- qplot/datahandling/trusted_heatmap.py +241 -0
- qplot/datahandling/trusted_live.py +2808 -0
- qplot/datahandling/trusted_live_queries.py +5907 -0
- qplot/datahandling/trusted_live_service.py +1894 -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 +1733 -0
- qplot/tools/__init__.py +13 -0
- qplot/tools/finite_means.py +114 -0
- qplot/tools/general.py +134 -0
- qplot/tools/heatmap_geometry.py +381 -0
- qplot/tools/operation_registry.py +179 -0
- qplot/tools/plot_tools.py +625 -0
- qplot/tools/sample_statistics.py +56 -0
- qplot/tools/worker.py +2787 -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 +4904 -0
- qplot/windows/_dataset_handle.py +221 -0
- qplot/windows/_dragdrop.py +71 -0
- qplot/windows/_export_paths.py +1167 -0
- qplot/windows/_help.py +169 -0
- qplot/windows/_native_averaging.py +173 -0
- qplot/windows/_native_transforms.py +389 -0
- qplot/windows/_plot1d_snap.py +608 -0
- qplot/windows/_plot1d_traces.py +2183 -0
- qplot/windows/_plot2d_colorbar.py +1126 -0
- qplot/windows/_plot2d_colorbar_dialog.py +786 -0
- qplot/windows/_plot2d_layers.py +2197 -0
- qplot/windows/_plot2d_sweeps.py +712 -0
- qplot/windows/_plotWin.py +1993 -0
- qplot/windows/_plot_actions.py +2695 -0
- qplot/windows/_plot_appearance.py +276 -0
- qplot/windows/_plot_axis_scaling.py +1485 -0
- qplot/windows/_plot_export.py +1445 -0
- qplot/windows/_plot_feedback.py +110 -0
- qplot/windows/_plot_marquee.py +656 -0
- qplot/windows/_plot_refresh.py +942 -0
- qplot/windows/_plot_state.py +239 -0
- qplot/windows/_plot_transform_labels.py +60 -0
- qplot/windows/_preferences.py +635 -0
- qplot/windows/_refresh_interval.py +70 -0
- qplot/windows/_run_controls.py +828 -0
- qplot/windows/_shortcuts.py +47 -0
- qplot/windows/_subplots/__init__.py +8 -0
- qplot/windows/_subplots/subplot1d.py +531 -0
- qplot/windows/_subplots/subplot2d.py +918 -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 +413 -0
- qplot/windows/main.py +1635 -0
- qplot/windows/plot1d.py +311 -0
- qplot/windows/plot2d.py +1901 -0
- qplotter-1.6.0b3.dist-info/METADATA +318 -0
- qplotter-1.6.0b3.dist-info/RECORD +119 -0
- qplotter-1.6.0b3.dist-info/WHEEL +5 -0
- qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
- qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
- qplotter-1.6.0b3.dist-info/top_level.txt +1 -0
qplot/testdata.py
ADDED
|
@@ -0,0 +1,1733 @@
|
|
|
1
|
+
"""Generate synthetic QCoDeS databases from CSV specifications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import csv
|
|
7
|
+
import errno
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import secrets
|
|
12
|
+
import sqlite3
|
|
13
|
+
import stat
|
|
14
|
+
import tempfile
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from importlib import resources
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from time import perf_counter
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
from qcodes.dataset import Measurement, new_experiment
|
|
24
|
+
from qcodes.dataset.sqlite.connection import AtomicConnection
|
|
25
|
+
from qcodes.dataset.sqlite.database import connect
|
|
26
|
+
from qcodes.parameters import ManualParameter
|
|
27
|
+
|
|
28
|
+
from qplot.datahandling.file_identity import (
|
|
29
|
+
QPLOT_GENERATED_DATABASE_APPLICATION_ID,
|
|
30
|
+
QPLOT_GENERATION_LINEAGE_FORMAT_VERSION,
|
|
31
|
+
QPLOT_GENERATION_LINEAGE_NONCE_BYTES,
|
|
32
|
+
QPLOT_GENERATION_LINEAGE_RING_TABLE,
|
|
33
|
+
QPLOT_GENERATION_LINEAGE_STATE_TABLE,
|
|
34
|
+
QPLOT_GENERATION_LINEAGE_WINDOW,
|
|
35
|
+
QPLOT_GENERATION_PROVENANCE_TABLE,
|
|
36
|
+
QPLOT_GENERATION_PROVENANCE_TOKEN_BYTES,
|
|
37
|
+
QPLOT_GENERATION_PROVENANCE_TRIGGER_PREFIX,
|
|
38
|
+
database_publication_guard_path,
|
|
39
|
+
generation_lineage_transition_statements,
|
|
40
|
+
generation_provenance_trigger,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
CSV_COLUMNS = (
|
|
44
|
+
"dimensions",
|
|
45
|
+
"measured_name",
|
|
46
|
+
"measured_label",
|
|
47
|
+
"measured_unit",
|
|
48
|
+
"v_sd_start",
|
|
49
|
+
"v_sd_stop",
|
|
50
|
+
"v_sd_points",
|
|
51
|
+
"v_g_start",
|
|
52
|
+
"v_g_stop",
|
|
53
|
+
"v_g_points",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
EXAMPLE_ROWS = (
|
|
57
|
+
("1", "current", "Current", "nA", "-0.1", "0.1", "501", "", "", ""),
|
|
58
|
+
(
|
|
59
|
+
"1",
|
|
60
|
+
"conductance",
|
|
61
|
+
"Conductance",
|
|
62
|
+
"uS",
|
|
63
|
+
"-0.1",
|
|
64
|
+
"0.1",
|
|
65
|
+
"501",
|
|
66
|
+
"",
|
|
67
|
+
"",
|
|
68
|
+
"",
|
|
69
|
+
),
|
|
70
|
+
(
|
|
71
|
+
"1",
|
|
72
|
+
"resistance",
|
|
73
|
+
"Resistance",
|
|
74
|
+
"kOhm",
|
|
75
|
+
"-0.1",
|
|
76
|
+
"0.1",
|
|
77
|
+
"501",
|
|
78
|
+
"",
|
|
79
|
+
"",
|
|
80
|
+
"",
|
|
81
|
+
),
|
|
82
|
+
(
|
|
83
|
+
"1",
|
|
84
|
+
"transconductance",
|
|
85
|
+
"Transconductance",
|
|
86
|
+
"uS/V",
|
|
87
|
+
"-0.1",
|
|
88
|
+
"0.1",
|
|
89
|
+
"501",
|
|
90
|
+
"",
|
|
91
|
+
"",
|
|
92
|
+
"",
|
|
93
|
+
),
|
|
94
|
+
(
|
|
95
|
+
"1",
|
|
96
|
+
"current",
|
|
97
|
+
"Current",
|
|
98
|
+
"nA",
|
|
99
|
+
"-0.1",
|
|
100
|
+
"0.1",
|
|
101
|
+
"501",
|
|
102
|
+
"",
|
|
103
|
+
"",
|
|
104
|
+
"",
|
|
105
|
+
),
|
|
106
|
+
(
|
|
107
|
+
"2",
|
|
108
|
+
"current",
|
|
109
|
+
"Current",
|
|
110
|
+
"nA",
|
|
111
|
+
"-0.01",
|
|
112
|
+
"0.01",
|
|
113
|
+
"121",
|
|
114
|
+
"-1",
|
|
115
|
+
"1",
|
|
116
|
+
"81",
|
|
117
|
+
),
|
|
118
|
+
(
|
|
119
|
+
"2",
|
|
120
|
+
"current",
|
|
121
|
+
"Current",
|
|
122
|
+
"nA",
|
|
123
|
+
"-0.01",
|
|
124
|
+
"0.01",
|
|
125
|
+
"121",
|
|
126
|
+
"-1",
|
|
127
|
+
"1",
|
|
128
|
+
"81",
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
INSTRUCTION_FILE_NAMES = (
|
|
133
|
+
"qplot_test_db_01_10mb.csv",
|
|
134
|
+
"qplot_test_db_02_25mb.csv",
|
|
135
|
+
"qplot_test_db_03_50mb.csv",
|
|
136
|
+
"qplot_test_db_04_100mb.csv",
|
|
137
|
+
"qplot_test_db_05_250mb.csv",
|
|
138
|
+
"qplot_test_db_06_500mb.csv",
|
|
139
|
+
"qplot_test_db_07_1gb.csv",
|
|
140
|
+
"qplot_test_db_08_5gb.csv",
|
|
141
|
+
"qplot_test_db_09_10gb.csv",
|
|
142
|
+
"qplot_test_db_10_30gb.csv",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
_PARAMETER_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
146
|
+
_MINIMUM_AMPLITUDE = 0.5
|
|
147
|
+
_MAXIMUM_AMPLITUDE = 1.5
|
|
148
|
+
_MINIMUM_FREQUENCY = 0.5
|
|
149
|
+
_MAXIMUM_FREQUENCY = 4.0
|
|
150
|
+
_SINUSOID_COMPONENT_COUNT = 2
|
|
151
|
+
_RESULT_CHUNK_POINTS = 10_000
|
|
152
|
+
_TEMPORARY_DATABASE_PREFIX = ".qplot-testdata-"
|
|
153
|
+
_PROVENANCE_TRIGGER_PREFIX = QPLOT_GENERATION_PROVENANCE_TRIGGER_PREFIX
|
|
154
|
+
_PROVENANCE_WRITER_ENABLED_ATTRIBUTE = "_qplot_provenance_writer_enabled"
|
|
155
|
+
_SQLITE_SIDECAR_SUFFIXES = ("-wal", "-shm", "-journal")
|
|
156
|
+
_SQLITE_HEADER = b"SQLite format 3\x00"
|
|
157
|
+
_SQLITE_ROLLBACK_JOURNAL_VERSIONS = b"\x01\x01"
|
|
158
|
+
_REPLACEMENT_BUSY_ERRNOS = frozenset(
|
|
159
|
+
value
|
|
160
|
+
for value in (
|
|
161
|
+
errno.EACCES,
|
|
162
|
+
errno.EBUSY,
|
|
163
|
+
errno.EPERM,
|
|
164
|
+
getattr(errno, "ETXTBSY", None),
|
|
165
|
+
)
|
|
166
|
+
if value is not None
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class SpecificationError(ValueError):
|
|
171
|
+
"""Raised when a test-database CSV specification is invalid."""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class GenerationCancelled(RuntimeError):
|
|
175
|
+
"""Raised internally when test-database generation is cancelled."""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _result_column_name_is_usable(name):
|
|
179
|
+
"""Return whether SQLite accepts ``name`` as QCoDeS will emit it."""
|
|
180
|
+
connection = sqlite3.connect(":memory:")
|
|
181
|
+
try:
|
|
182
|
+
# Parameter names have already passed _PARAMETER_NAME_PATTERN, so this
|
|
183
|
+
# parser probe cannot introduce additional SQL tokens. Keeping the
|
|
184
|
+
# identifier unquoted deliberately mirrors QCoDeS result-table SQL.
|
|
185
|
+
connection.execute(
|
|
186
|
+
f"CREATE TABLE qplot_results (id INTEGER PRIMARY KEY, {name} numeric)"
|
|
187
|
+
)
|
|
188
|
+
except sqlite3.DatabaseError:
|
|
189
|
+
return False
|
|
190
|
+
finally:
|
|
191
|
+
connection.close()
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass(frozen=True)
|
|
196
|
+
class RunSpecification:
|
|
197
|
+
"""Validated settings for one generated QCoDeS run."""
|
|
198
|
+
|
|
199
|
+
dimensions: int
|
|
200
|
+
measured_name: str
|
|
201
|
+
measured_label: str
|
|
202
|
+
measured_unit: str
|
|
203
|
+
v_sd_start: float
|
|
204
|
+
v_sd_stop: float
|
|
205
|
+
v_sd_points: int
|
|
206
|
+
v_g_start: float | None = None
|
|
207
|
+
v_g_stop: float | None = None
|
|
208
|
+
v_g_points: int | None = None
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def point_count(self):
|
|
212
|
+
"""Return the number of measured points in the run."""
|
|
213
|
+
if self.dimensions == 1:
|
|
214
|
+
return self.v_sd_points
|
|
215
|
+
assert self.v_g_points is not None
|
|
216
|
+
return self.v_sd_points * self.v_g_points
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _row_error(line_number, message):
|
|
220
|
+
return SpecificationError(f"CSV row {line_number}: {message}")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _required(row, column, line_number):
|
|
224
|
+
value = (row.get(column) or "").strip()
|
|
225
|
+
if not value:
|
|
226
|
+
raise _row_error(line_number, f"{column} is required")
|
|
227
|
+
return value
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _finite_float(row, column, line_number):
|
|
231
|
+
value = _required(row, column, line_number)
|
|
232
|
+
try:
|
|
233
|
+
number = float(value)
|
|
234
|
+
except ValueError as error:
|
|
235
|
+
raise _row_error(line_number, f"{column} must be a number") from error
|
|
236
|
+
if not math.isfinite(number):
|
|
237
|
+
raise _row_error(line_number, f"{column} must be finite")
|
|
238
|
+
return number
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _point_count(row, column, line_number):
|
|
242
|
+
value = _required(row, column, line_number)
|
|
243
|
+
try:
|
|
244
|
+
points = int(value)
|
|
245
|
+
except ValueError as error:
|
|
246
|
+
raise _row_error(line_number, f"{column} must be an integer") from error
|
|
247
|
+
if points < 2:
|
|
248
|
+
raise _row_error(line_number, f"{column} must be at least 2")
|
|
249
|
+
return points
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _parse_row(row, line_number):
|
|
253
|
+
dimensions_value = _required(row, "dimensions", line_number)
|
|
254
|
+
try:
|
|
255
|
+
dimensions = int(dimensions_value)
|
|
256
|
+
except ValueError as error:
|
|
257
|
+
raise _row_error(line_number, "dimensions must be 1 or 2") from error
|
|
258
|
+
if dimensions not in (1, 2):
|
|
259
|
+
raise _row_error(line_number, "dimensions must be 1 or 2")
|
|
260
|
+
|
|
261
|
+
measured_name = _required(row, "measured_name", line_number)
|
|
262
|
+
if not _PARAMETER_NAME_PATTERN.fullmatch(measured_name):
|
|
263
|
+
raise _row_error(
|
|
264
|
+
line_number,
|
|
265
|
+
"measured_name must start with a letter or underscore and contain "
|
|
266
|
+
"only letters, numbers, and underscores",
|
|
267
|
+
)
|
|
268
|
+
if measured_name.lower() in {"v_sd", "v_g"}:
|
|
269
|
+
raise _row_error(
|
|
270
|
+
line_number,
|
|
271
|
+
"measured_name cannot be V_SD or V_G because those names are swept",
|
|
272
|
+
)
|
|
273
|
+
if not _result_column_name_is_usable(measured_name):
|
|
274
|
+
raise _row_error(
|
|
275
|
+
line_number,
|
|
276
|
+
"measured_name cannot be used as a QCoDeS result column",
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
measured_label = _required(row, "measured_label", line_number)
|
|
280
|
+
measured_unit = (row.get("measured_unit") or "").strip()
|
|
281
|
+
v_sd_start = _finite_float(row, "v_sd_start", line_number)
|
|
282
|
+
v_sd_stop = _finite_float(row, "v_sd_stop", line_number)
|
|
283
|
+
if v_sd_start == v_sd_stop:
|
|
284
|
+
raise _row_error(line_number, "v_sd_start and v_sd_stop must differ")
|
|
285
|
+
v_sd_points = _point_count(row, "v_sd_points", line_number)
|
|
286
|
+
|
|
287
|
+
gate_values = tuple((row.get(column) or "").strip() for column in CSV_COLUMNS[7:])
|
|
288
|
+
if dimensions == 1:
|
|
289
|
+
if any(gate_values):
|
|
290
|
+
raise _row_error(
|
|
291
|
+
line_number,
|
|
292
|
+
"v_g_start, v_g_stop, and v_g_points must be blank for a 1D run",
|
|
293
|
+
)
|
|
294
|
+
return RunSpecification(
|
|
295
|
+
dimensions=dimensions,
|
|
296
|
+
measured_name=measured_name,
|
|
297
|
+
measured_label=measured_label,
|
|
298
|
+
measured_unit=measured_unit,
|
|
299
|
+
v_sd_start=v_sd_start,
|
|
300
|
+
v_sd_stop=v_sd_stop,
|
|
301
|
+
v_sd_points=v_sd_points,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
v_g_start = _finite_float(row, "v_g_start", line_number)
|
|
305
|
+
v_g_stop = _finite_float(row, "v_g_stop", line_number)
|
|
306
|
+
if v_g_start == v_g_stop:
|
|
307
|
+
raise _row_error(line_number, "v_g_start and v_g_stop must differ")
|
|
308
|
+
v_g_points = _point_count(row, "v_g_points", line_number)
|
|
309
|
+
return RunSpecification(
|
|
310
|
+
dimensions=dimensions,
|
|
311
|
+
measured_name=measured_name,
|
|
312
|
+
measured_label=measured_label,
|
|
313
|
+
measured_unit=measured_unit,
|
|
314
|
+
v_sd_start=v_sd_start,
|
|
315
|
+
v_sd_stop=v_sd_stop,
|
|
316
|
+
v_sd_points=v_sd_points,
|
|
317
|
+
v_g_start=v_g_start,
|
|
318
|
+
v_g_stop=v_g_stop,
|
|
319
|
+
v_g_points=v_g_points,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def read_specifications(csv_path):
|
|
324
|
+
"""Read and validate all nonblank rows in a generator CSV file."""
|
|
325
|
+
csv_path = Path(csv_path)
|
|
326
|
+
try:
|
|
327
|
+
handle = csv_path.open("r", encoding="utf-8-sig", newline="")
|
|
328
|
+
except OSError as error:
|
|
329
|
+
raise SpecificationError(f"Could not open {csv_path}: {error}") from error
|
|
330
|
+
|
|
331
|
+
with handle:
|
|
332
|
+
reader = csv.DictReader(handle)
|
|
333
|
+
fieldnames = reader.fieldnames
|
|
334
|
+
if fieldnames is None:
|
|
335
|
+
raise SpecificationError("CSV file is empty")
|
|
336
|
+
fieldnames = [name.strip() for name in fieldnames]
|
|
337
|
+
reader.fieldnames = fieldnames
|
|
338
|
+
if len(fieldnames) != len(set(fieldnames)):
|
|
339
|
+
raise SpecificationError("CSV header contains duplicate columns")
|
|
340
|
+
missing = [column for column in CSV_COLUMNS if column not in fieldnames]
|
|
341
|
+
extra = [column for column in fieldnames if column not in CSV_COLUMNS]
|
|
342
|
+
if missing or extra:
|
|
343
|
+
details = []
|
|
344
|
+
if missing:
|
|
345
|
+
details.append(f"missing columns: {', '.join(missing)}")
|
|
346
|
+
if extra:
|
|
347
|
+
details.append(f"unexpected columns: {', '.join(extra)}")
|
|
348
|
+
raise SpecificationError("Invalid CSV header (" + "; ".join(details) + ")")
|
|
349
|
+
|
|
350
|
+
specifications = []
|
|
351
|
+
for line_number, row in enumerate(reader, start=2):
|
|
352
|
+
if None in row and any(str(value).strip() for value in row[None]):
|
|
353
|
+
raise _row_error(line_number, "contains more values than the header")
|
|
354
|
+
if not any((row.get(column) or "").strip() for column in CSV_COLUMNS):
|
|
355
|
+
continue
|
|
356
|
+
specifications.append(_parse_row(row, line_number))
|
|
357
|
+
|
|
358
|
+
if not specifications:
|
|
359
|
+
raise SpecificationError("CSV file contains no run specifications")
|
|
360
|
+
return specifications
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def write_example_csv(csv_path, overwrite=False):
|
|
364
|
+
"""Write a ready-to-use example generator CSV and return its path."""
|
|
365
|
+
csv_path = Path(csv_path)
|
|
366
|
+
if csv_path.suffix.lower() != ".csv":
|
|
367
|
+
raise SpecificationError("Example output path must end in .csv")
|
|
368
|
+
csv_path.parent.mkdir(parents=True, exist_ok=True)
|
|
369
|
+
mode = "w" if overwrite else "x"
|
|
370
|
+
try:
|
|
371
|
+
with csv_path.open(mode, encoding="utf-8-sig", newline="") as handle:
|
|
372
|
+
writer = csv.writer(handle)
|
|
373
|
+
writer.writerow(CSV_COLUMNS)
|
|
374
|
+
writer.writerows(EXAMPLE_ROWS)
|
|
375
|
+
except FileExistsError as error:
|
|
376
|
+
raise SpecificationError(
|
|
377
|
+
f"{csv_path} already exists; use --overwrite to replace it"
|
|
378
|
+
) from error
|
|
379
|
+
except OSError as error:
|
|
380
|
+
raise SpecificationError(f"Could not write {csv_path}: {error}") from error
|
|
381
|
+
return csv_path
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def copy_instruction_collection(directory, overwrite=False):
|
|
385
|
+
"""Copy the installed cumulative instruction CSV collection to a directory."""
|
|
386
|
+
directory = Path(directory)
|
|
387
|
+
if directory.exists() and not directory.is_dir():
|
|
388
|
+
raise SpecificationError(f"Collection destination is not a directory: {directory}")
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
392
|
+
except OSError as error:
|
|
393
|
+
raise SpecificationError(f"Could not create {directory}: {error}") from error
|
|
394
|
+
|
|
395
|
+
output_paths = tuple(directory / name for name in INSTRUCTION_FILE_NAMES)
|
|
396
|
+
if not overwrite:
|
|
397
|
+
existing = [path.name for path in output_paths if path.exists()]
|
|
398
|
+
if existing:
|
|
399
|
+
raise SpecificationError(
|
|
400
|
+
f"{directory} already contains collection files; use --overwrite "
|
|
401
|
+
f"to replace them: {', '.join(existing)}"
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
for output_path, (_name, content) in zip(
|
|
406
|
+
output_paths,
|
|
407
|
+
instruction_collection_contents(),
|
|
408
|
+
strict=True,
|
|
409
|
+
):
|
|
410
|
+
mode = "wb" if overwrite else "xb"
|
|
411
|
+
with output_path.open(mode) as handle:
|
|
412
|
+
handle.write(content)
|
|
413
|
+
except OSError as error:
|
|
414
|
+
raise SpecificationError(
|
|
415
|
+
f"Could not copy the instruction CSV collection to {directory}: {error}"
|
|
416
|
+
) from error
|
|
417
|
+
|
|
418
|
+
return output_paths
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def instruction_collection_contents():
|
|
422
|
+
"""Return installed collection filenames and bytes without writing them."""
|
|
423
|
+
resource_directory = resources.files("qplot").joinpath("resources", "testdata")
|
|
424
|
+
return tuple(
|
|
425
|
+
(name, resource_directory.joinpath(name).read_bytes())
|
|
426
|
+
for name in INSTRUCTION_FILE_NAMES
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _raise_if_cancelled(cancelled_callback):
|
|
431
|
+
if cancelled_callback is not None and cancelled_callback():
|
|
432
|
+
raise GenerationCancelled("Test-database generation was cancelled")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _qcodes_uri_name(database_path):
|
|
436
|
+
"""Return one URI-encoded path layer for QCoDeS' ``file:`` prefix."""
|
|
437
|
+
database_uri = Path(database_path).absolute().as_uri()
|
|
438
|
+
return database_uri.removeprefix("file:")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _connect_writable_exact_path(database_path):
|
|
442
|
+
"""Open an exact generator-owned path with QCoDeS' writable connector."""
|
|
443
|
+
return connect(_qcodes_uri_name(database_path))
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _quote_sqlite_identifier(identifier):
|
|
447
|
+
return '"' + identifier.replace('"', '""') + '"'
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _generation_provenance_trigger(table_name, operation):
|
|
451
|
+
"""Return a stable trigger name and exact SQL for one table operation."""
|
|
452
|
+
return generation_provenance_trigger(table_name, operation)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _generation_provenance_user_tables(connection):
|
|
456
|
+
excluded_tables = (
|
|
457
|
+
QPLOT_GENERATION_PROVENANCE_TABLE,
|
|
458
|
+
QPLOT_GENERATION_LINEAGE_STATE_TABLE,
|
|
459
|
+
QPLOT_GENERATION_LINEAGE_RING_TABLE,
|
|
460
|
+
)
|
|
461
|
+
return [
|
|
462
|
+
row[0]
|
|
463
|
+
for row in connection.execute(
|
|
464
|
+
"SELECT name FROM sqlite_schema "
|
|
465
|
+
"WHERE type = 'table' AND name NOT LIKE 'sqlite_%' "
|
|
466
|
+
"AND name NOT IN (?, ?, ?) ORDER BY name",
|
|
467
|
+
excluded_tables,
|
|
468
|
+
)
|
|
469
|
+
]
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _ensure_generation_provenance_triggers(connection):
|
|
473
|
+
"""Install exact provenance triggers for every table in this transaction."""
|
|
474
|
+
changed = False
|
|
475
|
+
existing_triggers = {
|
|
476
|
+
row[0]: (row[1], row[2])
|
|
477
|
+
for row in connection.execute(
|
|
478
|
+
"SELECT name, tbl_name, sql FROM sqlite_schema WHERE type = 'trigger'"
|
|
479
|
+
).fetchall()
|
|
480
|
+
}
|
|
481
|
+
for table_name in _generation_provenance_user_tables(connection):
|
|
482
|
+
for operation in ("INSERT", "UPDATE", "DELETE"):
|
|
483
|
+
trigger_name, statement = _generation_provenance_trigger(
|
|
484
|
+
table_name,
|
|
485
|
+
operation,
|
|
486
|
+
)
|
|
487
|
+
trigger_row = existing_triggers.get(trigger_name)
|
|
488
|
+
if trigger_row is None:
|
|
489
|
+
connection.execute(statement)
|
|
490
|
+
existing_triggers[trigger_name] = (table_name, statement)
|
|
491
|
+
changed = True
|
|
492
|
+
elif trigger_row != (table_name, statement):
|
|
493
|
+
raise RuntimeError(
|
|
494
|
+
"The generated database contains a conflicting qPlot "
|
|
495
|
+
f"provenance trigger named {trigger_name!r}."
|
|
496
|
+
)
|
|
497
|
+
return changed
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _create_generation_lineage(connection, token):
|
|
501
|
+
"""Create and seed the bounded nonce ancestry chain at sequence zero."""
|
|
502
|
+
state_table = _quote_sqlite_identifier(QPLOT_GENERATION_LINEAGE_STATE_TABLE)
|
|
503
|
+
ring_table = _quote_sqlite_identifier(QPLOT_GENERATION_LINEAGE_RING_TABLE)
|
|
504
|
+
connection.execute(
|
|
505
|
+
f"CREATE TABLE {ring_table} ("
|
|
506
|
+
"slot INTEGER PRIMARY KEY, "
|
|
507
|
+
"sequence INTEGER NOT NULL UNIQUE CHECK (sequence >= 0), "
|
|
508
|
+
"parent_nonce BLOB, nonce BLOB NOT NULL UNIQUE, "
|
|
509
|
+
f"CHECK (slot = sequence % {QPLOT_GENERATION_LINEAGE_WINDOW}), "
|
|
510
|
+
"CHECK ((sequence = 0 AND parent_nonce IS NULL) OR "
|
|
511
|
+
f"(typeof(parent_nonce) = 'blob' AND length(parent_nonce) = "
|
|
512
|
+
f"{QPLOT_GENERATION_LINEAGE_NONCE_BYTES})), "
|
|
513
|
+
f"CHECK (typeof(nonce) = 'blob' AND length(nonce) = "
|
|
514
|
+
f"{QPLOT_GENERATION_LINEAGE_NONCE_BYTES})) WITHOUT ROWID"
|
|
515
|
+
)
|
|
516
|
+
connection.execute(
|
|
517
|
+
f"INSERT INTO {ring_table} VALUES "
|
|
518
|
+
f"(0, 0, NULL, randomblob({QPLOT_GENERATION_LINEAGE_NONCE_BYTES}))"
|
|
519
|
+
)
|
|
520
|
+
connection.execute(
|
|
521
|
+
f"CREATE TABLE {state_table} ("
|
|
522
|
+
"singleton INTEGER PRIMARY KEY CHECK (singleton = 1), "
|
|
523
|
+
"format_version INTEGER NOT NULL, generation_token TEXT NOT NULL, "
|
|
524
|
+
"head_sequence INTEGER NOT NULL CHECK (head_sequence >= 0), "
|
|
525
|
+
"head_nonce BLOB NOT NULL, window_size INTEGER NOT NULL, "
|
|
526
|
+
f"CHECK (format_version = {QPLOT_GENERATION_LINEAGE_FORMAT_VERSION}), "
|
|
527
|
+
f"CHECK (typeof(head_nonce) = 'blob' AND length(head_nonce) = "
|
|
528
|
+
f"{QPLOT_GENERATION_LINEAGE_NONCE_BYTES}), "
|
|
529
|
+
f"CHECK (window_size = {QPLOT_GENERATION_LINEAGE_WINDOW}))"
|
|
530
|
+
)
|
|
531
|
+
connection.execute(
|
|
532
|
+
f"INSERT INTO {state_table} "
|
|
533
|
+
"SELECT 1, ?, ?, 0, nonce, ? "
|
|
534
|
+
f"FROM {ring_table} WHERE sequence = 0",
|
|
535
|
+
(
|
|
536
|
+
QPLOT_GENERATION_LINEAGE_FORMAT_VERSION,
|
|
537
|
+
token,
|
|
538
|
+
QPLOT_GENERATION_LINEAGE_WINDOW,
|
|
539
|
+
),
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _advance_generation_lineage(connection):
|
|
544
|
+
for statement in generation_lineage_transition_statements():
|
|
545
|
+
connection.execute(statement)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _generation_lineage_state(connection):
|
|
549
|
+
"""Return a validated v2 writer state, or ``None`` for legacy provenance."""
|
|
550
|
+
state_exists = connection.execute(
|
|
551
|
+
"SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?",
|
|
552
|
+
(QPLOT_GENERATION_LINEAGE_STATE_TABLE,),
|
|
553
|
+
).fetchone()
|
|
554
|
+
ring_exists = connection.execute(
|
|
555
|
+
"SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?",
|
|
556
|
+
(QPLOT_GENERATION_LINEAGE_RING_TABLE,),
|
|
557
|
+
).fetchone()
|
|
558
|
+
if state_exists is None and ring_exists is None:
|
|
559
|
+
return None
|
|
560
|
+
if state_exists is None or ring_exists is None:
|
|
561
|
+
raise ValueError("The qPlot generation lineage tables are incomplete.")
|
|
562
|
+
|
|
563
|
+
state_table = _quote_sqlite_identifier(QPLOT_GENERATION_LINEAGE_STATE_TABLE)
|
|
564
|
+
ring_table = _quote_sqlite_identifier(QPLOT_GENERATION_LINEAGE_RING_TABLE)
|
|
565
|
+
rows = connection.execute(
|
|
566
|
+
f"SELECT singleton, format_version, generation_token, head_sequence, "
|
|
567
|
+
f"head_nonce, window_size FROM {state_table}"
|
|
568
|
+
).fetchall()
|
|
569
|
+
if len(rows) != 1:
|
|
570
|
+
raise ValueError("The qPlot generation lineage state is invalid.")
|
|
571
|
+
singleton, version, token, sequence, nonce, window = rows[0]
|
|
572
|
+
tip = connection.execute(
|
|
573
|
+
f"SELECT slot, parent_nonce, nonce FROM {ring_table} WHERE sequence = ?",
|
|
574
|
+
(sequence,),
|
|
575
|
+
).fetchone()
|
|
576
|
+
if (
|
|
577
|
+
singleton != 1
|
|
578
|
+
or version != QPLOT_GENERATION_LINEAGE_FORMAT_VERSION
|
|
579
|
+
or not isinstance(token, str)
|
|
580
|
+
or not isinstance(sequence, int)
|
|
581
|
+
or sequence < 0
|
|
582
|
+
or not isinstance(nonce, bytes)
|
|
583
|
+
or len(nonce) != QPLOT_GENERATION_LINEAGE_NONCE_BYTES
|
|
584
|
+
or window != QPLOT_GENERATION_LINEAGE_WINDOW
|
|
585
|
+
or tip is None
|
|
586
|
+
or tip[0] != sequence % QPLOT_GENERATION_LINEAGE_WINDOW
|
|
587
|
+
or tip[2] != nonce
|
|
588
|
+
):
|
|
589
|
+
raise ValueError("The qPlot generation lineage state is invalid.")
|
|
590
|
+
return token, sequence, nonce
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _legacy_generation_provenance_triggers(connection):
|
|
594
|
+
"""Return only structurally exact triggers written by legacy qPlot."""
|
|
595
|
+
legacy_triggers = []
|
|
596
|
+
pattern = re.compile(r"qplot_provenance_\d+_(insert|update|delete)\Z")
|
|
597
|
+
provenance_table = _quote_sqlite_identifier(QPLOT_GENERATION_PROVENANCE_TABLE)
|
|
598
|
+
for trigger_name, table_name, sql in connection.execute(
|
|
599
|
+
"SELECT name, tbl_name, sql FROM sqlite_schema WHERE type = 'trigger'"
|
|
600
|
+
).fetchall():
|
|
601
|
+
match = pattern.fullmatch(trigger_name)
|
|
602
|
+
if match is None:
|
|
603
|
+
continue
|
|
604
|
+
operation = match.group(1).upper()
|
|
605
|
+
expected = (
|
|
606
|
+
f"CREATE TRIGGER {_quote_sqlite_identifier(trigger_name)} "
|
|
607
|
+
f"AFTER {operation} ON {_quote_sqlite_identifier(table_name)} BEGIN "
|
|
608
|
+
f"UPDATE {provenance_table} SET write_epoch = write_epoch + 1 "
|
|
609
|
+
"WHERE singleton = 1; END"
|
|
610
|
+
)
|
|
611
|
+
if sql != expected:
|
|
612
|
+
raise RuntimeError(
|
|
613
|
+
"The database contains a conflicting legacy qPlot provenance "
|
|
614
|
+
f"trigger named {trigger_name!r}."
|
|
615
|
+
)
|
|
616
|
+
legacy_triggers.append(trigger_name)
|
|
617
|
+
return legacy_triggers
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _remove_legacy_generation_provenance_triggers(connection):
|
|
621
|
+
changed = False
|
|
622
|
+
for trigger_name in _legacy_generation_provenance_triggers(connection):
|
|
623
|
+
connection.execute(
|
|
624
|
+
f"DROP TRIGGER {_quote_sqlite_identifier(trigger_name)}"
|
|
625
|
+
)
|
|
626
|
+
changed = True
|
|
627
|
+
return changed
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _install_generation_provenance(connection):
|
|
631
|
+
"""Make later WAL writes prove that they descend from this main file."""
|
|
632
|
+
token = secrets.token_hex(QPLOT_GENERATION_PROVENANCE_TOKEN_BYTES)
|
|
633
|
+
provenance_table = _quote_sqlite_identifier(QPLOT_GENERATION_PROVENANCE_TABLE)
|
|
634
|
+
connection.execute(
|
|
635
|
+
f"CREATE TABLE {provenance_table} ("
|
|
636
|
+
"singleton INTEGER PRIMARY KEY CHECK (singleton = 1), "
|
|
637
|
+
"generation_token TEXT NOT NULL, "
|
|
638
|
+
"write_epoch INTEGER NOT NULL CHECK (write_epoch >= 0))"
|
|
639
|
+
)
|
|
640
|
+
connection.execute(
|
|
641
|
+
f"INSERT INTO {provenance_table} VALUES (1, ?, 0)",
|
|
642
|
+
(token,),
|
|
643
|
+
)
|
|
644
|
+
_create_generation_lineage(connection, token)
|
|
645
|
+
_ensure_generation_provenance_triggers(connection)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _require_generation_provenance_for_writer(connection):
|
|
649
|
+
application_id = connection.execute("PRAGMA application_id").fetchone()
|
|
650
|
+
try:
|
|
651
|
+
provenance_rows = connection.execute(
|
|
652
|
+
f"SELECT singleton, generation_token, write_epoch "
|
|
653
|
+
f"FROM {_quote_sqlite_identifier(QPLOT_GENERATION_PROVENANCE_TABLE)}"
|
|
654
|
+
).fetchall()
|
|
655
|
+
except sqlite3.Error as error:
|
|
656
|
+
raise ValueError(
|
|
657
|
+
"This is not a qPlot-generated database with writable provenance."
|
|
658
|
+
) from error
|
|
659
|
+
|
|
660
|
+
if len(provenance_rows) != 1:
|
|
661
|
+
raise ValueError(
|
|
662
|
+
"This is not a qPlot-generated database with writable provenance."
|
|
663
|
+
)
|
|
664
|
+
singleton, token, epoch = provenance_rows[0]
|
|
665
|
+
try:
|
|
666
|
+
token_bytes = bytes.fromhex(token) if isinstance(token, str) else b""
|
|
667
|
+
except ValueError:
|
|
668
|
+
token_bytes = b""
|
|
669
|
+
if (
|
|
670
|
+
application_id != (QPLOT_GENERATED_DATABASE_APPLICATION_ID,)
|
|
671
|
+
or singleton != 1
|
|
672
|
+
or len(token_bytes) != QPLOT_GENERATION_PROVENANCE_TOKEN_BYTES
|
|
673
|
+
or not isinstance(epoch, int)
|
|
674
|
+
or epoch < 0
|
|
675
|
+
):
|
|
676
|
+
raise ValueError(
|
|
677
|
+
"This is not a qPlot-generated database with writable provenance."
|
|
678
|
+
)
|
|
679
|
+
lineage = _generation_lineage_state(connection)
|
|
680
|
+
if lineage is not None:
|
|
681
|
+
lineage_token, lineage_sequence, _lineage_nonce = lineage
|
|
682
|
+
if lineage_token != token or lineage_sequence != epoch:
|
|
683
|
+
raise ValueError("The qPlot generation lineage state is invalid.")
|
|
684
|
+
return lineage
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def enable_generation_provenance_for_writer(connection):
|
|
688
|
+
"""Cover future QCoDeS result tables on an explicitly writable connection.
|
|
689
|
+
|
|
690
|
+
SQLite has no persistent database-wide write trigger. A QCoDeS writer that
|
|
691
|
+
will create later runs must therefore opt into this connection hook before
|
|
692
|
+
it performs any new experiment or measurement writes. The hook refuses a
|
|
693
|
+
nonempty WAL, installs table triggers before the outer QCoDeS transaction
|
|
694
|
+
commits, and extends a token-bound nonce chain. Subsequent foreground or
|
|
695
|
+
background result writes therefore remain provable across checkpoints.
|
|
696
|
+
|
|
697
|
+
This function is exclusively for a connection deliberately opened by the
|
|
698
|
+
database owner for writing. qPlot's viewer never calls it and never
|
|
699
|
+
installs metadata or triggers in an input database.
|
|
700
|
+
"""
|
|
701
|
+
if not isinstance(connection, AtomicConnection):
|
|
702
|
+
raise TypeError(
|
|
703
|
+
"enable_generation_provenance_for_writer requires a writable "
|
|
704
|
+
"QCoDeS AtomicConnection"
|
|
705
|
+
)
|
|
706
|
+
if getattr(connection, _PROVENANCE_WRITER_ENABLED_ATTRIBUTE, False):
|
|
707
|
+
return connection
|
|
708
|
+
if connection.in_transaction:
|
|
709
|
+
raise RuntimeError(
|
|
710
|
+
"Enable qPlot generation provenance before starting a write "
|
|
711
|
+
"transaction."
|
|
712
|
+
)
|
|
713
|
+
if connection.isolation_level is None:
|
|
714
|
+
raise RuntimeError(
|
|
715
|
+
"Enable qPlot generation provenance before using autocommit mode."
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
original_commit = connection.commit
|
|
719
|
+
original_rollback = connection.rollback
|
|
720
|
+
last_synchronized_total_changes = connection.total_changes
|
|
721
|
+
last_synchronized_schema_version = connection.execute(
|
|
722
|
+
"PRAGMA schema_version"
|
|
723
|
+
).fetchone()[0]
|
|
724
|
+
|
|
725
|
+
def synchronize_triggers():
|
|
726
|
+
_ensure_generation_provenance_triggers(connection)
|
|
727
|
+
_advance_generation_lineage(connection)
|
|
728
|
+
|
|
729
|
+
def provenance_commit():
|
|
730
|
+
nonlocal last_synchronized_schema_version
|
|
731
|
+
nonlocal last_synchronized_total_changes
|
|
732
|
+
if not connection.in_transaction:
|
|
733
|
+
return original_commit()
|
|
734
|
+
schema_version = connection.execute("PRAGMA schema_version").fetchone()[0]
|
|
735
|
+
if (
|
|
736
|
+
connection.total_changes == last_synchronized_total_changes
|
|
737
|
+
and schema_version == last_synchronized_schema_version
|
|
738
|
+
):
|
|
739
|
+
return original_commit()
|
|
740
|
+
try:
|
|
741
|
+
synchronize_triggers()
|
|
742
|
+
result = original_commit()
|
|
743
|
+
last_synchronized_total_changes = connection.total_changes
|
|
744
|
+
last_synchronized_schema_version = connection.execute(
|
|
745
|
+
"PRAGMA schema_version"
|
|
746
|
+
).fetchone()[0]
|
|
747
|
+
return result
|
|
748
|
+
except BaseException:
|
|
749
|
+
try:
|
|
750
|
+
original_rollback()
|
|
751
|
+
except sqlite3.Error:
|
|
752
|
+
pass
|
|
753
|
+
last_synchronized_total_changes = connection.total_changes
|
|
754
|
+
last_synchronized_schema_version = connection.execute(
|
|
755
|
+
"PRAGMA schema_version"
|
|
756
|
+
).fetchone()[0]
|
|
757
|
+
raise
|
|
758
|
+
|
|
759
|
+
def provenance_rollback():
|
|
760
|
+
nonlocal last_synchronized_schema_version
|
|
761
|
+
nonlocal last_synchronized_total_changes
|
|
762
|
+
result = original_rollback()
|
|
763
|
+
last_synchronized_total_changes = connection.total_changes
|
|
764
|
+
last_synchronized_schema_version = connection.execute(
|
|
765
|
+
"PRAGMA schema_version"
|
|
766
|
+
).fetchone()[0]
|
|
767
|
+
return result
|
|
768
|
+
|
|
769
|
+
# Acquire SQLite's writer lock before inspecting the lineage or WAL. A
|
|
770
|
+
# second connection must not be able to park uninstrumented frames between
|
|
771
|
+
# the quiescence check and the first provenance event.
|
|
772
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
773
|
+
try:
|
|
774
|
+
lineage = _require_generation_provenance_for_writer(connection)
|
|
775
|
+
journal_mode = connection.execute("PRAGMA journal_mode").fetchone()[0].lower()
|
|
776
|
+
if journal_mode == "wal":
|
|
777
|
+
wal_path = Path(f"{connection.path_to_dbfile}-wal")
|
|
778
|
+
try:
|
|
779
|
+
wal_size = wal_path.stat().st_size
|
|
780
|
+
except FileNotFoundError:
|
|
781
|
+
wal_size = 0
|
|
782
|
+
if wal_size:
|
|
783
|
+
raise RuntimeError(
|
|
784
|
+
"qPlot provenance refuses to bless writes already present in a "
|
|
785
|
+
"WAL. On the owner connection, checkpoint the WAL with TRUNCATE, "
|
|
786
|
+
"then enable provenance before creating a Measurement."
|
|
787
|
+
)
|
|
788
|
+
if lineage is None and journal_mode == "wal":
|
|
789
|
+
raise RuntimeError(
|
|
790
|
+
"This older qPlot-generated database must be migrated from a "
|
|
791
|
+
"quiescent rollback-journal connection. Checkpoint the WAL, switch "
|
|
792
|
+
"the owner connection to journal_mode=DELETE, then enable generation "
|
|
793
|
+
"provenance before creating a Measurement."
|
|
794
|
+
)
|
|
795
|
+
if lineage is None:
|
|
796
|
+
_remove_legacy_generation_provenance_triggers(connection)
|
|
797
|
+
migrated_token = secrets.token_hex(
|
|
798
|
+
QPLOT_GENERATION_PROVENANCE_TOKEN_BYTES
|
|
799
|
+
)
|
|
800
|
+
connection.execute(
|
|
801
|
+
f"UPDATE {_quote_sqlite_identifier(QPLOT_GENERATION_PROVENANCE_TABLE)} "
|
|
802
|
+
"SET generation_token = ?, write_epoch = 0 WHERE singleton = 1",
|
|
803
|
+
(migrated_token,),
|
|
804
|
+
)
|
|
805
|
+
_create_generation_lineage(connection, migrated_token)
|
|
806
|
+
else:
|
|
807
|
+
_remove_legacy_generation_provenance_triggers(connection)
|
|
808
|
+
synchronize_triggers()
|
|
809
|
+
original_commit()
|
|
810
|
+
last_synchronized_total_changes = connection.total_changes
|
|
811
|
+
last_synchronized_schema_version = connection.execute(
|
|
812
|
+
"PRAGMA schema_version"
|
|
813
|
+
).fetchone()[0]
|
|
814
|
+
except BaseException:
|
|
815
|
+
try:
|
|
816
|
+
original_rollback()
|
|
817
|
+
except sqlite3.Error:
|
|
818
|
+
pass
|
|
819
|
+
raise
|
|
820
|
+
|
|
821
|
+
connection.commit = provenance_commit
|
|
822
|
+
connection.rollback = provenance_rollback
|
|
823
|
+
setattr(connection, _PROVENANCE_WRITER_ENABLED_ATTRIBUTE, True)
|
|
824
|
+
return connection
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _owned_temporary_artifacts(temporary_path, *, include_database=True):
|
|
828
|
+
if include_database:
|
|
829
|
+
yield temporary_path
|
|
830
|
+
for suffix in _SQLITE_SIDECAR_SUFFIXES:
|
|
831
|
+
yield Path(f"{temporary_path}{suffix}")
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _remove_owned_temporary_artifacts(temporary_path, *, include_database=True):
|
|
835
|
+
"""Remove only the generator-owned database and its possible sidecars."""
|
|
836
|
+
failures = []
|
|
837
|
+
for artifact_path in _owned_temporary_artifacts(
|
|
838
|
+
temporary_path,
|
|
839
|
+
include_database=include_database,
|
|
840
|
+
):
|
|
841
|
+
try:
|
|
842
|
+
artifact_path.unlink(missing_ok=True)
|
|
843
|
+
except OSError as error:
|
|
844
|
+
failures.append((artifact_path, error))
|
|
845
|
+
if failures:
|
|
846
|
+
failed_paths = ", ".join(str(path) for path, _error in failures)
|
|
847
|
+
raise OSError(
|
|
848
|
+
f"Could not remove generator-owned temporary files: {failed_paths}"
|
|
849
|
+
) from failures[0][1]
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _destination_entry_state(database_path):
|
|
853
|
+
"""Return a race-sensitive identity for the selected directory entry."""
|
|
854
|
+
try:
|
|
855
|
+
status = database_path.lstat()
|
|
856
|
+
except FileNotFoundError:
|
|
857
|
+
return None
|
|
858
|
+
except OSError as error:
|
|
859
|
+
raise _unsafe_publication_error(database_path) from error
|
|
860
|
+
return (
|
|
861
|
+
status.st_dev,
|
|
862
|
+
status.st_ino,
|
|
863
|
+
status.st_mode,
|
|
864
|
+
status.st_size,
|
|
865
|
+
status.st_mtime_ns,
|
|
866
|
+
status.st_ctime_ns,
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _destination_sidecars(database_path):
|
|
871
|
+
"""Return every existing sidecar entry without following symlinks."""
|
|
872
|
+
sidecars = []
|
|
873
|
+
for suffix in _SQLITE_SIDECAR_SUFFIXES:
|
|
874
|
+
sidecar_path = Path(f"{database_path}{suffix}")
|
|
875
|
+
try:
|
|
876
|
+
sidecar_path.lstat()
|
|
877
|
+
except FileNotFoundError:
|
|
878
|
+
continue
|
|
879
|
+
except OSError as error:
|
|
880
|
+
# An inspection failure is not evidence that the entry is absent.
|
|
881
|
+
# Publication must therefore fail closed without touching it.
|
|
882
|
+
raise _unsafe_publication_error(database_path) from error
|
|
883
|
+
sidecars.append(sidecar_path)
|
|
884
|
+
return tuple(sidecars)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _unsafe_publication_error(database_path, sidecars=()):
|
|
888
|
+
details = ""
|
|
889
|
+
if sidecars:
|
|
890
|
+
details = " Detected: " + ", ".join(path.name for path in sidecars) + "."
|
|
891
|
+
return SpecificationError(
|
|
892
|
+
f"Cannot publish {database_path}: database active or SQLite sidecars "
|
|
893
|
+
"present. Close every application using this database and have the "
|
|
894
|
+
"owning SQLite/QCoDeS application resolve any -wal, -shm, or -journal "
|
|
895
|
+
f"files, then retry or choose another output path.{details} qPlot did "
|
|
896
|
+
"not change the destination database or its SQLite sidecars."
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _ambiguous_sidecar_race_error(database_path, guard_path, sidecars):
|
|
901
|
+
if sidecars:
|
|
902
|
+
details = " Detected after replacement: " + ", ".join(
|
|
903
|
+
path.name for path in sidecars
|
|
904
|
+
) + "."
|
|
905
|
+
else:
|
|
906
|
+
details = (
|
|
907
|
+
" A post-install safety check failed; a transient sidecar cannot "
|
|
908
|
+
"be ruled out."
|
|
909
|
+
)
|
|
910
|
+
return SpecificationError(
|
|
911
|
+
f"Cannot publish {database_path}: database active or SQLite sidecars "
|
|
912
|
+
f"present.{details} qPlot restored the "
|
|
913
|
+
"previous database main and did not change the sidecars, but cannot "
|
|
914
|
+
"prove which main they belong to. To prevent a stale main/WAL pairing, "
|
|
915
|
+
f"qPlot left the safety guard {guard_path}. Close every application "
|
|
916
|
+
"using the database, have the owning SQLite/QCoDeS application resolve "
|
|
917
|
+
"the sidecars, then remove only that .qplot-publishing guard and retry "
|
|
918
|
+
"or choose another output path."
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _require_safe_destination_sidecars(database_path):
|
|
923
|
+
sidecars = _destination_sidecars(database_path)
|
|
924
|
+
if sidecars:
|
|
925
|
+
raise _unsafe_publication_error(database_path, sidecars)
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _result_chunks(point_count):
|
|
929
|
+
for start in range(0, point_count, _RESULT_CHUNK_POINTS):
|
|
930
|
+
yield slice(start, min(start + _RESULT_CHUNK_POINTS, point_count))
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _timestamped_message(message):
|
|
934
|
+
timestamp = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
935
|
+
print(f"[{timestamp}] {message}", flush=True)
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _random_sinusoid_components(random_generator, dimensions):
|
|
939
|
+
"""Return independently randomised sinusoid parameters for one run."""
|
|
940
|
+
components = []
|
|
941
|
+
for _ in range(_SINUSOID_COMPONENT_COUNT):
|
|
942
|
+
amplitude = random_generator.uniform(_MINIMUM_AMPLITUDE, _MAXIMUM_AMPLITUDE)
|
|
943
|
+
frequencies = tuple(
|
|
944
|
+
random_generator.uniform(_MINIMUM_FREQUENCY, _MAXIMUM_FREQUENCY)
|
|
945
|
+
for _ in range(dimensions)
|
|
946
|
+
)
|
|
947
|
+
phase = random_generator.uniform(0.0, 2.0 * np.pi)
|
|
948
|
+
components.append((amplitude, frequencies, phase))
|
|
949
|
+
return tuple(components)
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _sinusoid_sum_1d(normalized_values, components):
|
|
953
|
+
values = np.zeros_like(normalized_values, dtype=float)
|
|
954
|
+
for amplitude, frequencies, phase in components:
|
|
955
|
+
values += amplitude * np.sin(
|
|
956
|
+
2.0 * np.pi * frequencies[0] * normalized_values + phase
|
|
957
|
+
)
|
|
958
|
+
return values
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def _sinusoid_sum_2d_row(v_sd_normalized, v_g_normalized, components):
|
|
962
|
+
values = np.zeros_like(v_g_normalized, dtype=float)
|
|
963
|
+
for amplitude, frequencies, phase in components:
|
|
964
|
+
values += amplitude * np.sin(
|
|
965
|
+
2.0
|
|
966
|
+
* np.pi
|
|
967
|
+
* (
|
|
968
|
+
frequencies[0] * v_sd_normalized
|
|
969
|
+
+ frequencies[1] * v_g_normalized
|
|
970
|
+
)
|
|
971
|
+
+ phase
|
|
972
|
+
)
|
|
973
|
+
return values
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _write_run(
|
|
977
|
+
experiment,
|
|
978
|
+
run_number,
|
|
979
|
+
specification,
|
|
980
|
+
random_generator,
|
|
981
|
+
cancelled_callback=None,
|
|
982
|
+
):
|
|
983
|
+
_raise_if_cancelled(cancelled_callback)
|
|
984
|
+
v_sd = ManualParameter("V_SD", label="Source-drain voltage", unit="V")
|
|
985
|
+
measured = ManualParameter(
|
|
986
|
+
specification.measured_name,
|
|
987
|
+
label=specification.measured_label,
|
|
988
|
+
unit=specification.measured_unit,
|
|
989
|
+
)
|
|
990
|
+
measurement = Measurement(exp=experiment, name=f"run_{run_number}")
|
|
991
|
+
measurement.register_parameter(v_sd)
|
|
992
|
+
|
|
993
|
+
v_sd_values = np.linspace(
|
|
994
|
+
specification.v_sd_start,
|
|
995
|
+
specification.v_sd_stop,
|
|
996
|
+
specification.v_sd_points,
|
|
997
|
+
)
|
|
998
|
+
v_sd_normalized = np.linspace(0.0, 1.0, specification.v_sd_points)
|
|
999
|
+
components = _random_sinusoid_components(
|
|
1000
|
+
random_generator,
|
|
1001
|
+
specification.dimensions,
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
if specification.dimensions == 1:
|
|
1005
|
+
measurement.register_parameter(measured, setpoints=(v_sd,))
|
|
1006
|
+
measured_values = _sinusoid_sum_1d(v_sd_normalized, components)
|
|
1007
|
+
with measurement.run() as datasaver:
|
|
1008
|
+
for result_slice in _result_chunks(specification.v_sd_points):
|
|
1009
|
+
_raise_if_cancelled(cancelled_callback)
|
|
1010
|
+
datasaver.add_result(
|
|
1011
|
+
(v_sd, v_sd_values[result_slice]),
|
|
1012
|
+
(measured, measured_values[result_slice]),
|
|
1013
|
+
)
|
|
1014
|
+
points_written = int(datasaver.points_written)
|
|
1015
|
+
if points_written != specification.point_count:
|
|
1016
|
+
raise RuntimeError(
|
|
1017
|
+
f"run_{run_number} persisted {points_written} of "
|
|
1018
|
+
f"{specification.point_count} expected result rows"
|
|
1019
|
+
)
|
|
1020
|
+
return points_written
|
|
1021
|
+
|
|
1022
|
+
assert specification.v_g_start is not None
|
|
1023
|
+
assert specification.v_g_stop is not None
|
|
1024
|
+
assert specification.v_g_points is not None
|
|
1025
|
+
v_g = ManualParameter("V_G", label="Gate voltage", unit="V")
|
|
1026
|
+
measurement.register_parameter(v_g)
|
|
1027
|
+
measurement.register_parameter(measured, setpoints=(v_sd, v_g))
|
|
1028
|
+
v_g_values = np.linspace(
|
|
1029
|
+
specification.v_g_start,
|
|
1030
|
+
specification.v_g_stop,
|
|
1031
|
+
specification.v_g_points,
|
|
1032
|
+
)
|
|
1033
|
+
v_g_normalized = np.linspace(0.0, 1.0, specification.v_g_points)
|
|
1034
|
+
|
|
1035
|
+
with measurement.run() as datasaver:
|
|
1036
|
+
for v_sd_index, v_sd_value in enumerate(v_sd_values):
|
|
1037
|
+
measured_values = _sinusoid_sum_2d_row(
|
|
1038
|
+
v_sd_normalized[v_sd_index],
|
|
1039
|
+
v_g_normalized,
|
|
1040
|
+
components,
|
|
1041
|
+
)
|
|
1042
|
+
for result_slice in _result_chunks(specification.v_g_points):
|
|
1043
|
+
_raise_if_cancelled(cancelled_callback)
|
|
1044
|
+
result_count = result_slice.stop - result_slice.start
|
|
1045
|
+
datasaver.add_result(
|
|
1046
|
+
(v_sd, np.full(result_count, float(v_sd_value))),
|
|
1047
|
+
(v_g, v_g_values[result_slice]),
|
|
1048
|
+
(measured, measured_values[result_slice]),
|
|
1049
|
+
)
|
|
1050
|
+
points_written = int(datasaver.points_written)
|
|
1051
|
+
if points_written != specification.point_count:
|
|
1052
|
+
raise RuntimeError(
|
|
1053
|
+
f"run_{run_number} persisted {points_written} of "
|
|
1054
|
+
f"{specification.point_count} expected result rows"
|
|
1055
|
+
)
|
|
1056
|
+
return points_written
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def _require_rollback_journal_database(database_path):
|
|
1060
|
+
"""Reject destinations that cannot be locked without creating sidecars."""
|
|
1061
|
+
try:
|
|
1062
|
+
status = database_path.lstat()
|
|
1063
|
+
with database_path.open("rb") as handle:
|
|
1064
|
+
header = handle.read(20)
|
|
1065
|
+
except OSError as error:
|
|
1066
|
+
raise _unsafe_publication_error(database_path) from error
|
|
1067
|
+
if (
|
|
1068
|
+
not stat.S_ISREG(status.st_mode)
|
|
1069
|
+
or not header.startswith(_SQLITE_HEADER)
|
|
1070
|
+
or header[18:20] != _SQLITE_ROLLBACK_JOURNAL_VERSIONS
|
|
1071
|
+
):
|
|
1072
|
+
# A persistent WAL-mode main can have no sidecars while quiescent, but
|
|
1073
|
+
# BEGIN EXCLUSIVE would create its WAL/SHM. Rejecting it is the only
|
|
1074
|
+
# source-preserving choice; the user can choose a new output path.
|
|
1075
|
+
raise _unsafe_publication_error(database_path)
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def _open_quiescent_destination(
|
|
1079
|
+
database_path,
|
|
1080
|
+
expected_destination_state,
|
|
1081
|
+
lock_path,
|
|
1082
|
+
):
|
|
1083
|
+
"""Hold SQLite's own exclusive inode lock through a private alias."""
|
|
1084
|
+
_require_safe_destination_sidecars(database_path)
|
|
1085
|
+
if _destination_entry_state(database_path) != expected_destination_state:
|
|
1086
|
+
raise _unsafe_publication_error(database_path)
|
|
1087
|
+
_require_rollback_journal_database(lock_path)
|
|
1088
|
+
connection = None
|
|
1089
|
+
try:
|
|
1090
|
+
connection = sqlite3.connect(
|
|
1091
|
+
f"file:{_qcodes_uri_name(lock_path)}?mode=rw",
|
|
1092
|
+
uri=True,
|
|
1093
|
+
isolation_level=None,
|
|
1094
|
+
timeout=0,
|
|
1095
|
+
)
|
|
1096
|
+
# The alias is a hardlink to the destination inode, so SQLite's
|
|
1097
|
+
# VFS-specific EXCLUSIVE lock conflicts with real readers/writers. Its
|
|
1098
|
+
# recovery and journal lookup, however, uses the private alias name.
|
|
1099
|
+
# A destination -journal racing into this call is therefore never
|
|
1100
|
+
# opened, recovered, modified, or deleted by qPlot; the recheck below
|
|
1101
|
+
# observes it and rejects publication.
|
|
1102
|
+
connection.execute("BEGIN EXCLUSIVE")
|
|
1103
|
+
except sqlite3.Error as error:
|
|
1104
|
+
if connection is not None:
|
|
1105
|
+
connection.close()
|
|
1106
|
+
raise _unsafe_publication_error(database_path) from error
|
|
1107
|
+
|
|
1108
|
+
try:
|
|
1109
|
+
_require_safe_destination_sidecars(database_path)
|
|
1110
|
+
if _destination_entry_state(database_path) != expected_destination_state:
|
|
1111
|
+
raise _unsafe_publication_error(database_path)
|
|
1112
|
+
except Exception:
|
|
1113
|
+
connection.close()
|
|
1114
|
+
raise
|
|
1115
|
+
return connection
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def _close_publication_connection(connection):
|
|
1119
|
+
if connection is None:
|
|
1120
|
+
return
|
|
1121
|
+
try:
|
|
1122
|
+
connection.rollback()
|
|
1123
|
+
except sqlite3.Error:
|
|
1124
|
+
pass
|
|
1125
|
+
try:
|
|
1126
|
+
connection.close()
|
|
1127
|
+
except sqlite3.Error:
|
|
1128
|
+
pass
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def _create_publication_guard(database_path):
|
|
1132
|
+
"""Create the qPlot-visible guard for the short replacement transaction."""
|
|
1133
|
+
guard_path = database_publication_guard_path(database_path)
|
|
1134
|
+
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
|
|
1135
|
+
flags |= getattr(os, "O_CLOEXEC", 0)
|
|
1136
|
+
try:
|
|
1137
|
+
descriptor = os.open(guard_path, flags, 0o600)
|
|
1138
|
+
except OSError as error:
|
|
1139
|
+
raise _unsafe_publication_error(database_path) from error
|
|
1140
|
+
try:
|
|
1141
|
+
os.close(descriptor)
|
|
1142
|
+
except OSError as error:
|
|
1143
|
+
# Ownership starts at os.open, not when this helper returns. Otherwise
|
|
1144
|
+
# a reported close failure could strand an untracked guard and block
|
|
1145
|
+
# every later read/retry.
|
|
1146
|
+
try:
|
|
1147
|
+
guard_path.unlink()
|
|
1148
|
+
except OSError:
|
|
1149
|
+
pass
|
|
1150
|
+
raise _unsafe_publication_error(database_path) from error
|
|
1151
|
+
return guard_path
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def _create_database_backup_link(database_path):
|
|
1155
|
+
"""Keep the old main inode available for a sidecar-race rollback."""
|
|
1156
|
+
handle = tempfile.NamedTemporaryFile(
|
|
1157
|
+
prefix=_TEMPORARY_DATABASE_PREFIX,
|
|
1158
|
+
suffix=".backup",
|
|
1159
|
+
dir=database_path.parent,
|
|
1160
|
+
delete=False,
|
|
1161
|
+
)
|
|
1162
|
+
backup_path = Path(handle.name)
|
|
1163
|
+
handle.close()
|
|
1164
|
+
backup_path.unlink()
|
|
1165
|
+
try:
|
|
1166
|
+
# This private alias lets SQLite lock the old inode without consulting
|
|
1167
|
+
# destination-named journals, and lets the publisher atomically restore
|
|
1168
|
+
# that exact inode if a post-install safety check fails.
|
|
1169
|
+
os.link(database_path, backup_path)
|
|
1170
|
+
except OSError as error:
|
|
1171
|
+
backup_path.unlink(missing_ok=True)
|
|
1172
|
+
raise _unsafe_publication_error(database_path) from error
|
|
1173
|
+
return backup_path
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _restore_database_backup(backup_path, database_path):
|
|
1177
|
+
"""Atomically restore the old main without touching any sidecar."""
|
|
1178
|
+
try:
|
|
1179
|
+
os.replace(backup_path, database_path)
|
|
1180
|
+
except OSError as error:
|
|
1181
|
+
guard_path = database_publication_guard_path(database_path)
|
|
1182
|
+
raise OSError(
|
|
1183
|
+
f"Cannot publish {database_path}: database active or SQLite "
|
|
1184
|
+
"sidecars present. qPlot could not restore the previous database "
|
|
1185
|
+
"main because the replacement path is still active. The selected "
|
|
1186
|
+
f"path may contain the replacement; the prior main is retained at "
|
|
1187
|
+
f"{backup_path} unless qPlot's final retry restored it. The safety "
|
|
1188
|
+
f"guard {guard_path} remains. Close every application using the "
|
|
1189
|
+
"database and have the owning SQLite/QCoDeS application resolve "
|
|
1190
|
+
"all -wal, -shm, and -journal files before recovering the prior "
|
|
1191
|
+
"main or removing only that guard. qPlot did not modify or remove "
|
|
1192
|
+
"the SQLite sidecars."
|
|
1193
|
+
) from error
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def _replace_database_file(
|
|
1197
|
+
temporary_path,
|
|
1198
|
+
database_path,
|
|
1199
|
+
expected_destination_state,
|
|
1200
|
+
):
|
|
1201
|
+
"""Replace a proven-quiescent main and roll back any sidecar race."""
|
|
1202
|
+
guard_path = _create_publication_guard(database_path)
|
|
1203
|
+
backup_path = None
|
|
1204
|
+
connection = None
|
|
1205
|
+
replacement_installed = False
|
|
1206
|
+
guard_must_remain = False
|
|
1207
|
+
ambiguous_sidecars = ()
|
|
1208
|
+
try:
|
|
1209
|
+
_require_safe_destination_sidecars(database_path)
|
|
1210
|
+
if _destination_entry_state(database_path) != expected_destination_state:
|
|
1211
|
+
raise _unsafe_publication_error(database_path)
|
|
1212
|
+
_require_rollback_journal_database(database_path)
|
|
1213
|
+
backup_path = _create_database_backup_link(database_path)
|
|
1214
|
+
backup_state = _destination_entry_state(backup_path)
|
|
1215
|
+
locked_destination_state = _destination_entry_state(database_path)
|
|
1216
|
+
if (
|
|
1217
|
+
backup_state is None
|
|
1218
|
+
or locked_destination_state is None
|
|
1219
|
+
or backup_state[:5] != expected_destination_state[:5]
|
|
1220
|
+
or locked_destination_state != backup_state
|
|
1221
|
+
or not os.path.samefile(database_path, backup_path)
|
|
1222
|
+
):
|
|
1223
|
+
raise _unsafe_publication_error(database_path)
|
|
1224
|
+
|
|
1225
|
+
connection = _open_quiescent_destination(
|
|
1226
|
+
database_path,
|
|
1227
|
+
locked_destination_state,
|
|
1228
|
+
backup_path,
|
|
1229
|
+
)
|
|
1230
|
+
# Windows does not permit replacing the main while this connection is
|
|
1231
|
+
# open. Closing it is safe there because any competing SQLite handle
|
|
1232
|
+
# likewise prevents os.replace. POSIX keeps the VFS lock across replace.
|
|
1233
|
+
if os.name == "nt":
|
|
1234
|
+
_close_publication_connection(connection)
|
|
1235
|
+
connection = None
|
|
1236
|
+
|
|
1237
|
+
_require_safe_destination_sidecars(database_path)
|
|
1238
|
+
if not os.path.samefile(database_path, backup_path):
|
|
1239
|
+
raise _unsafe_publication_error(database_path)
|
|
1240
|
+
|
|
1241
|
+
try:
|
|
1242
|
+
os.replace(temporary_path, database_path)
|
|
1243
|
+
except OSError as error:
|
|
1244
|
+
if error.errno in _REPLACEMENT_BUSY_ERRNOS:
|
|
1245
|
+
raise _unsafe_publication_error(database_path) from error
|
|
1246
|
+
raise
|
|
1247
|
+
replacement_installed = True
|
|
1248
|
+
|
|
1249
|
+
try:
|
|
1250
|
+
# This postcondition is essential: a non-cooperating filesystem
|
|
1251
|
+
# actor can create a sidecar in the final check/replace syscall gap.
|
|
1252
|
+
# qPlot readers see the guard throughout this interval. If a
|
|
1253
|
+
# sidecar appeared, restore the byte-identical old inode and leave
|
|
1254
|
+
# that externally owned sidecar exactly as it was created.
|
|
1255
|
+
ambiguous_sidecars = _destination_sidecars(database_path)
|
|
1256
|
+
if ambiguous_sidecars:
|
|
1257
|
+
raise _unsafe_publication_error(
|
|
1258
|
+
database_path,
|
|
1259
|
+
ambiguous_sidecars,
|
|
1260
|
+
)
|
|
1261
|
+
backup_after_replace = _destination_entry_state(backup_path)
|
|
1262
|
+
if (
|
|
1263
|
+
backup_after_replace is None
|
|
1264
|
+
or backup_after_replace[:5] != backup_state[:5]
|
|
1265
|
+
):
|
|
1266
|
+
raise _unsafe_publication_error(database_path)
|
|
1267
|
+
ambiguous_sidecars = _destination_sidecars(database_path)
|
|
1268
|
+
if ambiguous_sidecars:
|
|
1269
|
+
raise _unsafe_publication_error(
|
|
1270
|
+
database_path,
|
|
1271
|
+
ambiguous_sidecars,
|
|
1272
|
+
)
|
|
1273
|
+
except Exception as error:
|
|
1274
|
+
# Once a post-install postcondition fails, absence observed later
|
|
1275
|
+
# cannot prove that a transient sidecar did not belong to the new
|
|
1276
|
+
# main. Keep the guard even if rollback itself needs a retry.
|
|
1277
|
+
guard_must_remain = True
|
|
1278
|
+
_restore_database_backup(backup_path, database_path)
|
|
1279
|
+
backup_path = None
|
|
1280
|
+
replacement_installed = False
|
|
1281
|
+
if not ambiguous_sidecars:
|
|
1282
|
+
try:
|
|
1283
|
+
ambiguous_sidecars = _destination_sidecars(database_path)
|
|
1284
|
+
except SpecificationError as inspection_error:
|
|
1285
|
+
# Inspection itself is unsafe, so retain the guard exactly
|
|
1286
|
+
# as for an observed sidecar. A fresh process must not guess.
|
|
1287
|
+
guard_must_remain = True
|
|
1288
|
+
raise _ambiguous_sidecar_race_error(
|
|
1289
|
+
database_path,
|
|
1290
|
+
guard_path,
|
|
1291
|
+
(),
|
|
1292
|
+
) from inspection_error
|
|
1293
|
+
# A sidecar observed after the swap could belong to either the old
|
|
1294
|
+
# or new main. Even if none is visible now, a failed postcondition
|
|
1295
|
+
# cannot rule out a transient one. The guard is therefore
|
|
1296
|
+
# persistent recovery state, not a disposable generator temporary.
|
|
1297
|
+
guard_must_remain = True
|
|
1298
|
+
raise _ambiguous_sidecar_race_error(
|
|
1299
|
+
database_path,
|
|
1300
|
+
guard_path,
|
|
1301
|
+
ambiguous_sidecars,
|
|
1302
|
+
) from error
|
|
1303
|
+
|
|
1304
|
+
# Keep SQLite's old-inode EXCLUSIVE lock through commit on POSIX. A
|
|
1305
|
+
# cooperating old SQLite connection cannot create a stale sidecar in
|
|
1306
|
+
# the check/unlink gap; on Windows, a handle overlapping os.replace
|
|
1307
|
+
# makes that syscall fail. No portable filesystem syscall atomically
|
|
1308
|
+
# conditions a main-file replace on three sibling paths, so the main's
|
|
1309
|
+
# embedded replacement marker is the cross-process backstop: qPlot
|
|
1310
|
+
# permanently quarantines WAL access for this identity even if a raw
|
|
1311
|
+
# filesystem actor bypasses SQLite's lock after the last check.
|
|
1312
|
+
try:
|
|
1313
|
+
guard_path.unlink()
|
|
1314
|
+
except OSError as error:
|
|
1315
|
+
guard_must_remain = True
|
|
1316
|
+
_restore_database_backup(backup_path, database_path)
|
|
1317
|
+
backup_path = None
|
|
1318
|
+
replacement_installed = False
|
|
1319
|
+
try:
|
|
1320
|
+
ambiguous_sidecars = _destination_sidecars(database_path)
|
|
1321
|
+
except SpecificationError as inspection_error:
|
|
1322
|
+
guard_must_remain = True
|
|
1323
|
+
raise _ambiguous_sidecar_race_error(
|
|
1324
|
+
database_path,
|
|
1325
|
+
guard_path,
|
|
1326
|
+
(),
|
|
1327
|
+
) from inspection_error
|
|
1328
|
+
raise _ambiguous_sidecar_race_error(
|
|
1329
|
+
database_path,
|
|
1330
|
+
guard_path,
|
|
1331
|
+
ambiguous_sidecars,
|
|
1332
|
+
) from error
|
|
1333
|
+
guard_path = None
|
|
1334
|
+
_close_publication_connection(connection)
|
|
1335
|
+
connection = None
|
|
1336
|
+
replacement_installed = False
|
|
1337
|
+
|
|
1338
|
+
# Publication is now committed. Failure to remove this private name
|
|
1339
|
+
# must not turn a completed replacement into a reported rejection.
|
|
1340
|
+
try:
|
|
1341
|
+
backup_path.unlink()
|
|
1342
|
+
except OSError:
|
|
1343
|
+
pass
|
|
1344
|
+
else:
|
|
1345
|
+
backup_path = None
|
|
1346
|
+
finally:
|
|
1347
|
+
_close_publication_connection(connection)
|
|
1348
|
+
if replacement_installed and backup_path is not None:
|
|
1349
|
+
guard_must_remain = True
|
|
1350
|
+
try:
|
|
1351
|
+
_restore_database_backup(backup_path, database_path)
|
|
1352
|
+
except OSError:
|
|
1353
|
+
# Keep both the backup and publication guard for manual recovery.
|
|
1354
|
+
guard_must_remain = True
|
|
1355
|
+
else:
|
|
1356
|
+
backup_path = None
|
|
1357
|
+
replacement_installed = False
|
|
1358
|
+
try:
|
|
1359
|
+
guard_must_remain = (
|
|
1360
|
+
guard_must_remain
|
|
1361
|
+
or bool(_destination_sidecars(database_path))
|
|
1362
|
+
)
|
|
1363
|
+
except SpecificationError:
|
|
1364
|
+
guard_must_remain = True
|
|
1365
|
+
if backup_path is not None and not replacement_installed:
|
|
1366
|
+
try:
|
|
1367
|
+
backup_path.unlink()
|
|
1368
|
+
except OSError:
|
|
1369
|
+
pass
|
|
1370
|
+
if (
|
|
1371
|
+
guard_path is not None
|
|
1372
|
+
and not guard_must_remain
|
|
1373
|
+
and not replacement_installed
|
|
1374
|
+
):
|
|
1375
|
+
try:
|
|
1376
|
+
guard_path.unlink()
|
|
1377
|
+
except OSError:
|
|
1378
|
+
pass
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def _link_database_file(temporary_path, database_path):
|
|
1382
|
+
"""Publish a previously absent output with a guarded postcondition."""
|
|
1383
|
+
guard_path = _create_publication_guard(database_path)
|
|
1384
|
+
destination_linked = False
|
|
1385
|
+
try:
|
|
1386
|
+
_require_safe_destination_sidecars(database_path)
|
|
1387
|
+
if _destination_entry_state(database_path) is not None:
|
|
1388
|
+
raise SpecificationError(
|
|
1389
|
+
f"{database_path} already exists; use --overwrite to replace it"
|
|
1390
|
+
)
|
|
1391
|
+
try:
|
|
1392
|
+
# The hard link is an atomic no-clobber publish because the
|
|
1393
|
+
# generator temporary lives in the destination directory.
|
|
1394
|
+
os.link(temporary_path, database_path)
|
|
1395
|
+
except FileExistsError as error:
|
|
1396
|
+
raise SpecificationError(
|
|
1397
|
+
f"{database_path} already exists; use --overwrite to replace it"
|
|
1398
|
+
) from error
|
|
1399
|
+
except OSError as error:
|
|
1400
|
+
raise OSError(
|
|
1401
|
+
"Could not atomically publish the generated database without "
|
|
1402
|
+
"overwriting an existing file"
|
|
1403
|
+
) from error
|
|
1404
|
+
destination_linked = True
|
|
1405
|
+
|
|
1406
|
+
# An orphan sidecar can be copied into place in the link syscall gap.
|
|
1407
|
+
# Since the old state was absence, rolling back only our own hard link
|
|
1408
|
+
# restores it without touching the externally owned sidecar.
|
|
1409
|
+
_require_safe_destination_sidecars(database_path)
|
|
1410
|
+
guard_path.unlink()
|
|
1411
|
+
guard_path = None
|
|
1412
|
+
destination_linked = False
|
|
1413
|
+
finally:
|
|
1414
|
+
if destination_linked:
|
|
1415
|
+
try:
|
|
1416
|
+
owns_destination = os.path.samefile(
|
|
1417
|
+
temporary_path,
|
|
1418
|
+
database_path,
|
|
1419
|
+
)
|
|
1420
|
+
except OSError:
|
|
1421
|
+
owns_destination = False
|
|
1422
|
+
if owns_destination:
|
|
1423
|
+
try:
|
|
1424
|
+
database_path.unlink()
|
|
1425
|
+
except OSError as error:
|
|
1426
|
+
# Keep the guard: qPlot must not open a main whose rollback
|
|
1427
|
+
# could not be proved after a sidecar publication race.
|
|
1428
|
+
raise OSError(
|
|
1429
|
+
"Could not roll back an unsafe new database publication; "
|
|
1430
|
+
"the qPlot publication guard was left in place"
|
|
1431
|
+
) from error
|
|
1432
|
+
destination_linked = False
|
|
1433
|
+
if guard_path is not None and not destination_linked:
|
|
1434
|
+
try:
|
|
1435
|
+
guard_path.unlink()
|
|
1436
|
+
except OSError:
|
|
1437
|
+
pass
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def _publish_database(
|
|
1441
|
+
temporary_path,
|
|
1442
|
+
database_path,
|
|
1443
|
+
overwrite,
|
|
1444
|
+
expected_destination_state=None,
|
|
1445
|
+
publication_callback=None,
|
|
1446
|
+
):
|
|
1447
|
+
"""Publish atomically and notify once the destination is committed."""
|
|
1448
|
+
_require_safe_destination_sidecars(database_path)
|
|
1449
|
+
if overwrite:
|
|
1450
|
+
# A changed directory entry means the user's overwrite decision no
|
|
1451
|
+
# longer describes the file now at this path. Reject rather than
|
|
1452
|
+
# replacing an unexamined concurrent owner. The replacement helper then
|
|
1453
|
+
# obtains SQLite's own exclusive lock and enforces a rollback-capable
|
|
1454
|
+
# postcondition across the final filesystem operation.
|
|
1455
|
+
if _destination_entry_state(database_path) != expected_destination_state:
|
|
1456
|
+
raise _unsafe_publication_error(database_path)
|
|
1457
|
+
if expected_destination_state is not None:
|
|
1458
|
+
_replace_database_file(
|
|
1459
|
+
temporary_path,
|
|
1460
|
+
database_path,
|
|
1461
|
+
expected_destination_state,
|
|
1462
|
+
)
|
|
1463
|
+
if publication_callback is not None:
|
|
1464
|
+
publication_callback()
|
|
1465
|
+
return
|
|
1466
|
+
|
|
1467
|
+
_link_database_file(temporary_path, database_path)
|
|
1468
|
+
if publication_callback is not None:
|
|
1469
|
+
publication_callback()
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
def generate_database(
|
|
1473
|
+
specifications,
|
|
1474
|
+
database_path,
|
|
1475
|
+
overwrite=False,
|
|
1476
|
+
rng=None,
|
|
1477
|
+
cancelled_callback=None,
|
|
1478
|
+
publication_callback=None,
|
|
1479
|
+
):
|
|
1480
|
+
"""Generate a database and optionally observe its publication boundary."""
|
|
1481
|
+
specifications = list(specifications)
|
|
1482
|
+
if not specifications:
|
|
1483
|
+
raise SpecificationError("At least one run specification is required")
|
|
1484
|
+
|
|
1485
|
+
database_path = Path(database_path)
|
|
1486
|
+
if database_path.suffix.lower() != ".db":
|
|
1487
|
+
raise SpecificationError("Database output path must end in .db")
|
|
1488
|
+
expected_destination_state = _destination_entry_state(database_path)
|
|
1489
|
+
if expected_destination_state is not None and not overwrite:
|
|
1490
|
+
raise SpecificationError(
|
|
1491
|
+
f"{database_path} already exists; use --overwrite to replace it"
|
|
1492
|
+
)
|
|
1493
|
+
_require_safe_destination_sidecars(database_path)
|
|
1494
|
+
|
|
1495
|
+
database_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1496
|
+
random_generator = rng if rng is not None else np.random.default_rng()
|
|
1497
|
+
total_runs = len(specifications)
|
|
1498
|
+
total_points = sum(specification.point_count for specification in specifications)
|
|
1499
|
+
generation_started = perf_counter()
|
|
1500
|
+
_timestamped_message(
|
|
1501
|
+
f"Test database generation started: {database_path} "
|
|
1502
|
+
f"({total_runs} runs, {total_points} points)."
|
|
1503
|
+
)
|
|
1504
|
+
temporary_path = None
|
|
1505
|
+
publication_completed = False
|
|
1506
|
+
try:
|
|
1507
|
+
try:
|
|
1508
|
+
temporary_handle = tempfile.NamedTemporaryFile(
|
|
1509
|
+
prefix=_TEMPORARY_DATABASE_PREFIX,
|
|
1510
|
+
suffix=".db",
|
|
1511
|
+
dir=database_path.parent,
|
|
1512
|
+
delete=False,
|
|
1513
|
+
)
|
|
1514
|
+
temporary_path = Path(temporary_handle.name)
|
|
1515
|
+
temporary_handle.close()
|
|
1516
|
+
_raise_if_cancelled(cancelled_callback)
|
|
1517
|
+
connection = _connect_writable_exact_path(temporary_path)
|
|
1518
|
+
try:
|
|
1519
|
+
experiment = new_experiment(
|
|
1520
|
+
"qplot_test_database",
|
|
1521
|
+
sample_name="synthetic",
|
|
1522
|
+
conn=connection,
|
|
1523
|
+
)
|
|
1524
|
+
for run_number, specification in enumerate(specifications, start=1):
|
|
1525
|
+
run_started = perf_counter()
|
|
1526
|
+
_timestamped_message(
|
|
1527
|
+
f"Run started: run_{run_number} ({run_number}/{total_runs}, "
|
|
1528
|
+
f"{specification.dimensions}D, "
|
|
1529
|
+
f"{specification.point_count} points)."
|
|
1530
|
+
)
|
|
1531
|
+
try:
|
|
1532
|
+
points_written = _write_run(
|
|
1533
|
+
experiment,
|
|
1534
|
+
run_number,
|
|
1535
|
+
specification,
|
|
1536
|
+
random_generator,
|
|
1537
|
+
cancelled_callback=cancelled_callback,
|
|
1538
|
+
)
|
|
1539
|
+
if points_written != specification.point_count:
|
|
1540
|
+
raise RuntimeError(
|
|
1541
|
+
f"run_{run_number} persisted {points_written} of "
|
|
1542
|
+
f"{specification.point_count} expected result rows"
|
|
1543
|
+
)
|
|
1544
|
+
except GenerationCancelled:
|
|
1545
|
+
_timestamped_message(
|
|
1546
|
+
f"Run stopped (cancelled): run_{run_number} "
|
|
1547
|
+
f"after {perf_counter() - run_started:.2f} s."
|
|
1548
|
+
)
|
|
1549
|
+
raise
|
|
1550
|
+
except Exception as error:
|
|
1551
|
+
_timestamped_message(
|
|
1552
|
+
f"Run stopped (failed): run_{run_number} after "
|
|
1553
|
+
f"{perf_counter() - run_started:.2f} s "
|
|
1554
|
+
f"({type(error).__name__}: {error})."
|
|
1555
|
+
)
|
|
1556
|
+
raise
|
|
1557
|
+
_timestamped_message(
|
|
1558
|
+
f"Run stopped (completed): run_{run_number} in "
|
|
1559
|
+
f"{perf_counter() - run_started:.2f} s."
|
|
1560
|
+
)
|
|
1561
|
+
# The marker identifies the provenance format; the unique token
|
|
1562
|
+
# and nonce-linked branch history let a fresh qPlot process
|
|
1563
|
+
# distinguish a WAL descending from this exact main from an
|
|
1564
|
+
# unrelated or divergent sidecar. Triggers are installed only
|
|
1565
|
+
# after generation so they do not penalise bulk creation.
|
|
1566
|
+
connection.execute(
|
|
1567
|
+
"PRAGMA application_id = "
|
|
1568
|
+
f"{QPLOT_GENERATED_DATABASE_APPLICATION_ID}"
|
|
1569
|
+
)
|
|
1570
|
+
_install_generation_provenance(connection)
|
|
1571
|
+
connection.commit()
|
|
1572
|
+
_raise_if_cancelled(cancelled_callback)
|
|
1573
|
+
finally:
|
|
1574
|
+
connection.close()
|
|
1575
|
+
# Sidecar cleanup is fallible, so complete it before the atomic
|
|
1576
|
+
# publication boundary. No cleanup failure may be reported as a
|
|
1577
|
+
# rejected publication after the destination has already changed.
|
|
1578
|
+
_remove_owned_temporary_artifacts(
|
|
1579
|
+
temporary_path,
|
|
1580
|
+
include_database=False,
|
|
1581
|
+
)
|
|
1582
|
+
if publication_callback is None:
|
|
1583
|
+
# Preserve the established four-argument call for test doubles
|
|
1584
|
+
# and internal callers that do not need publication tracking.
|
|
1585
|
+
_publish_database(
|
|
1586
|
+
temporary_path,
|
|
1587
|
+
database_path,
|
|
1588
|
+
overwrite,
|
|
1589
|
+
expected_destination_state,
|
|
1590
|
+
)
|
|
1591
|
+
else:
|
|
1592
|
+
_publish_database(
|
|
1593
|
+
temporary_path,
|
|
1594
|
+
database_path,
|
|
1595
|
+
overwrite,
|
|
1596
|
+
expected_destination_state,
|
|
1597
|
+
publication_callback=publication_callback,
|
|
1598
|
+
)
|
|
1599
|
+
publication_completed = True
|
|
1600
|
+
finally:
|
|
1601
|
+
if temporary_path is not None:
|
|
1602
|
+
try:
|
|
1603
|
+
_remove_owned_temporary_artifacts(temporary_path)
|
|
1604
|
+
except Exception:
|
|
1605
|
+
if not publication_completed:
|
|
1606
|
+
raise
|
|
1607
|
+
# Publication is committed and must still be reported as
|
|
1608
|
+
# success, but retry once so a transient unlink failure
|
|
1609
|
+
# does not strand a generator-owned hardlink/sidecar.
|
|
1610
|
+
try:
|
|
1611
|
+
_remove_owned_temporary_artifacts(temporary_path)
|
|
1612
|
+
except Exception:
|
|
1613
|
+
pass
|
|
1614
|
+
except GenerationCancelled:
|
|
1615
|
+
_timestamped_message(
|
|
1616
|
+
"Test database generation stopped (cancelled) after "
|
|
1617
|
+
f"{perf_counter() - generation_started:.2f} s: {database_path}."
|
|
1618
|
+
)
|
|
1619
|
+
raise
|
|
1620
|
+
except Exception as error:
|
|
1621
|
+
_timestamped_message(
|
|
1622
|
+
"Test database generation stopped (failed) after "
|
|
1623
|
+
f"{perf_counter() - generation_started:.2f} s: {database_path} "
|
|
1624
|
+
f"({type(error).__name__}: {error})."
|
|
1625
|
+
)
|
|
1626
|
+
raise
|
|
1627
|
+
|
|
1628
|
+
try:
|
|
1629
|
+
_timestamped_message(
|
|
1630
|
+
"Test database generation stopped (completed) in "
|
|
1631
|
+
f"{perf_counter() - generation_started:.2f} s: {database_path}."
|
|
1632
|
+
)
|
|
1633
|
+
except Exception:
|
|
1634
|
+
# Publication is already committed; a closed output stream must not
|
|
1635
|
+
# turn success into an apparent failure after replacing the database.
|
|
1636
|
+
pass
|
|
1637
|
+
return database_path
|
|
1638
|
+
|
|
1639
|
+
|
|
1640
|
+
def generate_database_from_csv(
|
|
1641
|
+
csv_path,
|
|
1642
|
+
database_path,
|
|
1643
|
+
overwrite=False,
|
|
1644
|
+
rng=None,
|
|
1645
|
+
cancelled_callback=None,
|
|
1646
|
+
):
|
|
1647
|
+
"""Validate a CSV and generate its QCoDeS database."""
|
|
1648
|
+
specifications = read_specifications(csv_path)
|
|
1649
|
+
path = generate_database(
|
|
1650
|
+
specifications,
|
|
1651
|
+
database_path,
|
|
1652
|
+
overwrite=overwrite,
|
|
1653
|
+
rng=rng,
|
|
1654
|
+
cancelled_callback=cancelled_callback,
|
|
1655
|
+
)
|
|
1656
|
+
return path, specifications
|
|
1657
|
+
|
|
1658
|
+
|
|
1659
|
+
def _argument_parser():
|
|
1660
|
+
parser = argparse.ArgumentParser(
|
|
1661
|
+
prog="qplot-generate-db",
|
|
1662
|
+
description="Generate two-sinusoid test runs in a QCoDeS database.",
|
|
1663
|
+
)
|
|
1664
|
+
parser.add_argument("specification", nargs="?", help="input CSV specification")
|
|
1665
|
+
parser.add_argument("database", nargs="?", help="output QCoDeS .db file")
|
|
1666
|
+
output_group = parser.add_mutually_exclusive_group()
|
|
1667
|
+
output_group.add_argument(
|
|
1668
|
+
"--write-example",
|
|
1669
|
+
metavar="CSV",
|
|
1670
|
+
help="write a ready-to-use example CSV instead of generating a database",
|
|
1671
|
+
)
|
|
1672
|
+
output_group.add_argument(
|
|
1673
|
+
"--write-collection",
|
|
1674
|
+
metavar="DIRECTORY",
|
|
1675
|
+
help="copy the installed cumulative instruction CSV collection",
|
|
1676
|
+
)
|
|
1677
|
+
parser.add_argument(
|
|
1678
|
+
"--overwrite",
|
|
1679
|
+
action="store_true",
|
|
1680
|
+
help="replace an existing output file",
|
|
1681
|
+
)
|
|
1682
|
+
return parser
|
|
1683
|
+
|
|
1684
|
+
|
|
1685
|
+
def main(argv: Sequence[str] | None = None):
|
|
1686
|
+
"""Run the ``qplot-generate-db`` command-line interface."""
|
|
1687
|
+
parser = _argument_parser()
|
|
1688
|
+
args = parser.parse_args(argv)
|
|
1689
|
+
|
|
1690
|
+
try:
|
|
1691
|
+
if args.write_example:
|
|
1692
|
+
if args.specification or args.database:
|
|
1693
|
+
parser.error("positional arguments cannot be used with --write-example")
|
|
1694
|
+
output_path = write_example_csv(args.write_example, overwrite=args.overwrite)
|
|
1695
|
+
print(f"Wrote example CSV: {output_path}")
|
|
1696
|
+
return 0
|
|
1697
|
+
|
|
1698
|
+
if args.write_collection:
|
|
1699
|
+
if args.specification or args.database:
|
|
1700
|
+
parser.error("positional arguments cannot be used with --write-collection")
|
|
1701
|
+
output_paths = copy_instruction_collection(
|
|
1702
|
+
args.write_collection,
|
|
1703
|
+
overwrite=args.overwrite,
|
|
1704
|
+
)
|
|
1705
|
+
print(
|
|
1706
|
+
f"Wrote {len(output_paths)} instruction CSV files to "
|
|
1707
|
+
f"{Path(args.write_collection)}"
|
|
1708
|
+
)
|
|
1709
|
+
return 0
|
|
1710
|
+
|
|
1711
|
+
if not args.specification or not args.database:
|
|
1712
|
+
parser.error(
|
|
1713
|
+
"specification and database are required unless --write-example or "
|
|
1714
|
+
"--write-collection is used"
|
|
1715
|
+
)
|
|
1716
|
+
output_path, specifications = generate_database_from_csv(
|
|
1717
|
+
args.specification,
|
|
1718
|
+
args.database,
|
|
1719
|
+
overwrite=args.overwrite,
|
|
1720
|
+
)
|
|
1721
|
+
except (OSError, SpecificationError) as error:
|
|
1722
|
+
parser.exit(2, f"qplot-generate-db: error: {error}\n")
|
|
1723
|
+
|
|
1724
|
+
total_points = sum(specification.point_count for specification in specifications)
|
|
1725
|
+
_timestamped_message(
|
|
1726
|
+
f"Generated {output_path} with {len(specifications)} runs "
|
|
1727
|
+
f"and {total_points} points."
|
|
1728
|
+
)
|
|
1729
|
+
return 0
|
|
1730
|
+
|
|
1731
|
+
|
|
1732
|
+
if __name__ == "__main__":
|
|
1733
|
+
raise SystemExit(main())
|