qplotter 1.6.0__py3-none-any.whl

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