qplotter 1.6.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. qplot/__init__.py +46 -0
  2. qplot/__main__.py +232 -0
  3. qplot/_auxiliary_paths.py +63 -0
  4. qplot/_metadata.py +12 -0
  5. qplot/_repair.py +14 -0
  6. qplot/_shutdown_supervisor.py +4049 -0
  7. qplot/_version.py +12 -0
  8. qplot/_windows_shutdown_job.py +1166 -0
  9. qplot/configuration/__init__.py +3 -0
  10. qplot/configuration/config.py +573 -0
  11. qplot/configuration/config_schema.json +319 -0
  12. qplot/configuration/scripts.py +255 -0
  13. qplot/configuration/themes/__init__.py +13 -0
  14. qplot/configuration/themes/_base.py +579 -0
  15. qplot/configuration/themes/blank.py +17 -0
  16. qplot/configuration/themes/dark.py +54 -0
  17. qplot/configuration/themes/light.py +54 -0
  18. qplot/datahandling/LoadFromDB.py +224 -0
  19. qplot/datahandling/__init__.py +47 -0
  20. qplot/datahandling/_trusted_live_helper.py +812 -0
  21. qplot/datahandling/_trusted_live_protocol.py +1745 -0
  22. qplot/datahandling/database.py +2330 -0
  23. qplot/datahandling/dimensions.py +43 -0
  24. qplot/datahandling/file_identity.py +461 -0
  25. qplot/datahandling/parameter_data.py +149 -0
  26. qplot/datahandling/plot_progress.py +17 -0
  27. qplot/datahandling/qcodes_cache.py +277 -0
  28. qplot/datahandling/qcodes_compat.py +14 -0
  29. qplot/datahandling/readSQL.py +2442 -0
  30. qplot/datahandling/readonly.py +2561 -0
  31. qplot/datahandling/source_activity.py +21 -0
  32. qplot/datahandling/trusted_array_preview.py +127 -0
  33. qplot/datahandling/trusted_derived_cache.py +1605 -0
  34. qplot/datahandling/trusted_derived_rendering.py +868 -0
  35. qplot/datahandling/trusted_heatmap.py +241 -0
  36. qplot/datahandling/trusted_live.py +2808 -0
  37. qplot/datahandling/trusted_live_queries.py +5907 -0
  38. qplot/datahandling/trusted_live_service.py +1894 -0
  39. qplot/datahandling/trusted_live_supervisor.py +2161 -0
  40. qplot/datahandling/trusted_plot.py +283 -0
  41. qplot/datahandling/trusted_presentation.py +1065 -0
  42. qplot/datahandling/trusted_snapshot.py +1132 -0
  43. qplot/datahandling/trusted_work_coordinator.py +2157 -0
  44. qplot/datahandling/trusted_work_scheduler.py +1204 -0
  45. qplot/diagnostics.py +241 -0
  46. qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
  47. qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
  48. qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
  49. qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
  50. qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
  51. qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
  52. qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
  53. qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
  54. qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
  55. qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
  56. qplot/testdata.py +1733 -0
  57. qplot/tools/__init__.py +13 -0
  58. qplot/tools/finite_means.py +114 -0
  59. qplot/tools/general.py +134 -0
  60. qplot/tools/heatmap_geometry.py +381 -0
  61. qplot/tools/operation_registry.py +179 -0
  62. qplot/tools/plot_tools.py +625 -0
  63. qplot/tools/sample_statistics.py +56 -0
  64. qplot/tools/worker.py +2787 -0
  65. qplot/windows/__init__.py +9 -0
  66. qplot/windows/_colorbar.py +422 -0
  67. qplot/windows/_commands.py +523 -0
  68. qplot/windows/_config_persistence.py +123 -0
  69. qplot/windows/_database_actions.py +4904 -0
  70. qplot/windows/_dataset_handle.py +221 -0
  71. qplot/windows/_dragdrop.py +71 -0
  72. qplot/windows/_export_paths.py +1167 -0
  73. qplot/windows/_help.py +169 -0
  74. qplot/windows/_native_averaging.py +173 -0
  75. qplot/windows/_native_transforms.py +389 -0
  76. qplot/windows/_plot1d_snap.py +608 -0
  77. qplot/windows/_plot1d_traces.py +2183 -0
  78. qplot/windows/_plot2d_colorbar.py +1126 -0
  79. qplot/windows/_plot2d_colorbar_dialog.py +786 -0
  80. qplot/windows/_plot2d_layers.py +2197 -0
  81. qplot/windows/_plot2d_sweeps.py +712 -0
  82. qplot/windows/_plotWin.py +1993 -0
  83. qplot/windows/_plot_actions.py +2695 -0
  84. qplot/windows/_plot_appearance.py +276 -0
  85. qplot/windows/_plot_axis_scaling.py +1485 -0
  86. qplot/windows/_plot_export.py +1445 -0
  87. qplot/windows/_plot_feedback.py +110 -0
  88. qplot/windows/_plot_marquee.py +656 -0
  89. qplot/windows/_plot_refresh.py +942 -0
  90. qplot/windows/_plot_state.py +239 -0
  91. qplot/windows/_plot_transform_labels.py +60 -0
  92. qplot/windows/_preferences.py +635 -0
  93. qplot/windows/_refresh_interval.py +70 -0
  94. qplot/windows/_run_controls.py +828 -0
  95. qplot/windows/_shortcuts.py +47 -0
  96. qplot/windows/_subplots/__init__.py +8 -0
  97. qplot/windows/_subplots/subplot1d.py +531 -0
  98. qplot/windows/_subplots/subplot2d.py +918 -0
  99. qplot/windows/_svg_export.py +209 -0
  100. qplot/windows/_trusted_derived_qt.py +1759 -0
  101. qplot/windows/_widgets/__init__.py +23 -0
  102. qplot/windows/_widgets/_run_formatting.py +387 -0
  103. qplot/windows/_widgets/details_tables.py +1119 -0
  104. qplot/windows/_widgets/dropbox.py +290 -0
  105. qplot/windows/_widgets/operations.py +460 -0
  106. qplot/windows/_widgets/preview.py +2997 -0
  107. qplot/windows/_widgets/run_list_items.py +488 -0
  108. qplot/windows/_widgets/toolbar.py +111 -0
  109. qplot/windows/_widgets/treeWidgets.py +2572 -0
  110. qplot/windows/_window_controls.py +413 -0
  111. qplot/windows/main.py +1635 -0
  112. qplot/windows/plot1d.py +311 -0
  113. qplot/windows/plot2d.py +1901 -0
  114. qplotter-1.6.0b3.dist-info/METADATA +318 -0
  115. qplotter-1.6.0b3.dist-info/RECORD +119 -0
  116. qplotter-1.6.0b3.dist-info/WHEEL +5 -0
  117. qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
  118. qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
  119. qplotter-1.6.0b3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1167 @@
1
+ """Safe filename approval and publication for user-requested exports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ntpath
6
+ import os
7
+ import re
8
+ import stat
9
+ import tempfile
10
+ import unicodedata
11
+ from collections.abc import Callable, Iterable, Mapping
12
+ from dataclasses import dataclass, field, replace
13
+ from typing import Any
14
+
15
+ from PyQt6 import QtWidgets as qtw
16
+
17
+ from qplot.datahandling.file_identity import (
18
+ SQLITE_SIDECAR_SUFFIXES,
19
+ DatabaseFileIdentity,
20
+ canonical_database_path,
21
+ database_file_identity,
22
+ logical_database_path,
23
+ )
24
+
25
+ _DATABASE_ARTIFACT_SUFFIXES = ("", *SQLITE_SIDECAR_SUFFIXES)
26
+ _SQLITE_HEADER = b"SQLite format 3\x00"
27
+ _SQLITE_ARTIFACT_HEADERS = (
28
+ _SQLITE_HEADER,
29
+ b"\x37\x7f\x06\x82", # WAL, little-endian checksums
30
+ b"\x37\x7f\x06\x83", # WAL, big-endian checksums
31
+ b"\xd9\xd5\x05\xf9\x20\xa1\x63\xd7", # rollback journal
32
+ )
33
+
34
+
35
+ class UnsafeExportDestinationError(RuntimeError):
36
+ """Raised when an export cannot safely use its selected destination."""
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class ProtectedDatabaseFiles:
41
+ """Filesystem names and instances owned by current or retained DB views."""
42
+
43
+ logical_paths: frozenset[str] = frozenset()
44
+ resolved_paths: frozenset[str] = frozenset()
45
+ identities: frozenset[DatabaseFileIdentity] = frozenset()
46
+
47
+ def merged(self, other: ProtectedDatabaseFiles) -> ProtectedDatabaseFiles:
48
+ """Return the union of two protection observations."""
49
+ return ProtectedDatabaseFiles(
50
+ logical_paths=self.logical_paths | other.logical_paths,
51
+ resolved_paths=self.resolved_paths | other.resolved_paths,
52
+ identities=self.identities | other.identities,
53
+ )
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class ExportDestinationTransaction:
58
+ """One exact export target approved against a stable filesystem view."""
59
+
60
+ filename: str
61
+ parent_path: str
62
+ parent_identity: tuple[int, ...]
63
+ target_signature: tuple[object, ...] | None
64
+ replacement_confirmed: bool
65
+ protected_database_files: ProtectedDatabaseFiles
66
+ owner: object | None = field(default=None, compare=False, repr=False)
67
+ existing_target_validator: Callable[[str], Any] | None = field(
68
+ default=None,
69
+ compare=False,
70
+ repr=False,
71
+ )
72
+
73
+ @property
74
+ def target_existed(self) -> bool:
75
+ """Return whether approval captured an existing target."""
76
+ return self.target_signature is not None
77
+
78
+ @property
79
+ def protected_database_identities(self) -> frozenset[DatabaseFileIdentity]:
80
+ """Compatibility view of approval-time protected file identities."""
81
+ return self.protected_database_files.identities
82
+
83
+ def __fspath__(self) -> str:
84
+ return self.filename
85
+
86
+ def __str__(self) -> str:
87
+ return self.filename
88
+
89
+ def validate(self, *, parent_fd: int | None = None) -> None:
90
+ """Revalidate the exact approval without changing filesystem state."""
91
+ _ensure_parent_is_unchanged(self, parent_fd=parent_fd)
92
+
93
+ protected_files = self.protected_database_files.merged(
94
+ collect_protected_database_files(self.owner)
95
+ )
96
+ _ensure_target_is_not_protected(
97
+ self.filename, protected_files, parent_fd=parent_fd,
98
+ )
99
+ current_signature = _target_signature(
100
+ self.filename,
101
+ parent_fd=parent_fd,
102
+ )
103
+ if current_signature != self.target_signature:
104
+ raise UnsafeExportDestinationError(
105
+ "The selected file changed while printing or exporting; "
106
+ "it was not replaced."
107
+ )
108
+ if self.target_existed and not self.replacement_confirmed:
109
+ raise UnsafeExportDestinationError(
110
+ "Replacement of the existing export file was not confirmed."
111
+ )
112
+ _run_existing_target_validator(self, current_signature)
113
+
114
+
115
+ def normalize_export_path(filename: str, required_suffix: str) -> str:
116
+ """Return an absolute export path with ``required_suffix`` exactly once."""
117
+ if not required_suffix.startswith("."):
118
+ required_suffix = f".{required_suffix}"
119
+ if not filename.casefold().endswith(required_suffix.casefold()):
120
+ filename = f"{filename}{required_suffix}"
121
+ return os.path.abspath(filename)
122
+
123
+
124
+ def choose_export_path(
125
+ parent: qtw.QWidget,
126
+ *,
127
+ caption: str,
128
+ suggested_path: str,
129
+ name_filter: str,
130
+ required_suffix: str,
131
+ replace_title: str,
132
+ file_description: str,
133
+ existing_target_validator: Callable[[str], Any] | None = None,
134
+ ) -> ExportDestinationTransaction | None:
135
+ """Choose, normalize, and approve the exact path that may be replaced."""
136
+ dialog_options = (
137
+ qtw.QFileDialog.Option.DontConfirmOverwrite
138
+ | qtw.QFileDialog.Option.DontUseNativeDialog
139
+ )
140
+ filename = qtw.QFileDialog.getSaveFileName(
141
+ parent,
142
+ caption,
143
+ suggested_path,
144
+ name_filter,
145
+ options=dialog_options,
146
+ )[0]
147
+ if not filename:
148
+ return None
149
+
150
+ filename = normalize_export_path(filename, required_suffix)
151
+ return approve_export_path(
152
+ parent,
153
+ filename,
154
+ replace_title=replace_title,
155
+ file_description=file_description,
156
+ existing_target_validator=existing_target_validator,
157
+ )
158
+
159
+
160
+ def choose_export_path_with_suffixes(
161
+ parent: qtw.QWidget,
162
+ *,
163
+ caption: str,
164
+ suggested_path: str,
165
+ name_filter: str,
166
+ allowed_suffixes: Iterable[str],
167
+ default_suffix: str,
168
+ replace_title: str,
169
+ file_description: str,
170
+ match_selected_filter: bool = False,
171
+ ) -> ExportDestinationTransaction | None:
172
+ """Choose and approve an export, optionally enforcing its selected format.
173
+
174
+ When matching the filter, replace a conflicting allowed suffix before
175
+ approval. An absent/unrecognized filter leaves an allowed suffix intact.
176
+ """
177
+ suffixes = tuple(
178
+ suffix.casefold() if suffix.startswith(".") else f".{suffix.casefold()}"
179
+ for suffix in allowed_suffixes
180
+ )
181
+ if not suffixes:
182
+ raise ValueError("At least one export suffix must be allowed.")
183
+ default_suffix = (
184
+ default_suffix.casefold()
185
+ if default_suffix.startswith(".")
186
+ else f".{default_suffix.casefold()}"
187
+ )
188
+ if default_suffix not in suffixes:
189
+ raise ValueError("The default export suffix must also be allowed.")
190
+
191
+ dialog_options = (
192
+ qtw.QFileDialog.Option.DontConfirmOverwrite
193
+ | qtw.QFileDialog.Option.DontUseNativeDialog
194
+ )
195
+ filename, selected_filter = qtw.QFileDialog.getSaveFileName(
196
+ parent,
197
+ caption,
198
+ suggested_path,
199
+ name_filter,
200
+ options=dialog_options,
201
+ )
202
+ if not filename:
203
+ return None
204
+
205
+ selected_suffix = os.path.splitext(filename)[1].casefold()
206
+ selected_match = re.search(r"\*\.([A-Za-z0-9]+)\b", selected_filter)
207
+ filtered_suffix = (
208
+ f".{selected_match.group(1).casefold()}"
209
+ if selected_match is not None else None
210
+ )
211
+ if match_selected_filter and filtered_suffix in suffixes:
212
+ if selected_suffix in suffixes and selected_suffix != filtered_suffix:
213
+ filename = f"{os.path.splitext(filename)[0]}{filtered_suffix}"
214
+ selected_suffix = filtered_suffix
215
+ elif selected_suffix not in suffixes:
216
+ selected_suffix = filtered_suffix
217
+ filename = f"{filename}{selected_suffix}"
218
+ if selected_suffix not in suffixes:
219
+ if filtered_suffix in suffixes:
220
+ selected_suffix = filtered_suffix
221
+ if selected_suffix not in suffixes:
222
+ selected_suffix = default_suffix
223
+ filename = f"{filename}{selected_suffix}"
224
+
225
+ return approve_export_path(
226
+ parent,
227
+ os.path.abspath(filename),
228
+ replace_title=replace_title,
229
+ file_description=file_description,
230
+ )
231
+
232
+
233
+ def approve_export_path(
234
+ parent: qtw.QWidget,
235
+ filename: str,
236
+ *,
237
+ replace_title: str,
238
+ file_description: str,
239
+ existing_target_validator: Callable[[str], Any] | None = None,
240
+ ) -> ExportDestinationTransaction | None:
241
+ """Capture one selected path and explicitly confirm its exact target."""
242
+ destination = _capture_export_destination(
243
+ parent,
244
+ os.path.abspath(filename),
245
+ existing_target_validator=existing_target_validator,
246
+ )
247
+ if not destination.target_existed:
248
+ return destination
249
+
250
+ reply = qtw.QMessageBox.question(
251
+ parent,
252
+ replace_title,
253
+ f"{destination.filename} already exists.\n\n"
254
+ f"Replace the existing {file_description}?",
255
+ qtw.QMessageBox.StandardButton.Yes | qtw.QMessageBox.StandardButton.No,
256
+ qtw.QMessageBox.StandardButton.No,
257
+ )
258
+ if reply != qtw.QMessageBox.StandardButton.Yes:
259
+ return None
260
+ return replace(destination, replacement_confirmed=True)
261
+
262
+
263
+ def prepare_export_destination(
264
+ owner: object | None,
265
+ filename: str,
266
+ *,
267
+ replacement_confirmed: bool = False,
268
+ required_suffix: str | None = None,
269
+ existing_target_validator: Callable[[str], Any] | None = None,
270
+ ) -> ExportDestinationTransaction:
271
+ """Approve a caller-supplied path without supplying implicit consent."""
272
+ if required_suffix is not None:
273
+ filename = normalize_export_path(filename, required_suffix)
274
+ else:
275
+ filename = os.path.abspath(filename)
276
+ destination = _capture_export_destination(
277
+ owner,
278
+ filename,
279
+ existing_target_validator=existing_target_validator,
280
+ )
281
+ if destination.target_existed and not replacement_confirmed:
282
+ raise UnsafeExportDestinationError(
283
+ "Replacement of the existing export file was not confirmed."
284
+ )
285
+ return replace(
286
+ destination,
287
+ replacement_confirmed=bool(
288
+ replacement_confirmed and destination.target_existed
289
+ ),
290
+ )
291
+
292
+
293
+ def write_export_atomically(
294
+ destination: ExportDestinationTransaction | str | os.PathLike[str],
295
+ writer: Callable[[str], Any],
296
+ *,
297
+ before_publish: Callable[[], Any] | None = None,
298
+ ) -> bool:
299
+ """Stage beside an approved target and publish without widening consent.
300
+
301
+ Raw paths remain accepted only for a target that is absent at approval.
302
+ Replacing an existing target requires an ``ExportDestinationTransaction``
303
+ whose confirmation flag came from the user-facing approval step.
304
+ """
305
+ if not isinstance(destination, ExportDestinationTransaction):
306
+ destination = prepare_export_destination(None, os.fspath(destination))
307
+
308
+ parent_fd = _open_parent_directory(destination)
309
+ stage_fd: int | None = None
310
+ stage_path = ""
311
+ stage_name = ""
312
+ stage_identity: tuple[int, ...] | None = None
313
+ try:
314
+ # This is deliberately the last operation before staging begins.
315
+ destination.validate(parent_fd=parent_fd)
316
+ suffix = os.path.splitext(destination.filename)[1] or ".tmp"
317
+ stage_fd, stage_path = tempfile.mkstemp(
318
+ prefix=f".{os.path.basename(destination.filename)}.",
319
+ suffix=suffix,
320
+ dir=destination.parent_path,
321
+ )
322
+ stage_name = os.path.basename(stage_path)
323
+ stage_identity = _stage_identity(os.fstat(stage_fd))
324
+ _verify_owned_stage(
325
+ destination,
326
+ stage_path,
327
+ stage_name,
328
+ stage_identity,
329
+ parent_fd=parent_fd,
330
+ )
331
+
332
+ if writer(stage_path) is False:
333
+ return False
334
+
335
+ _verify_owned_stage(
336
+ destination,
337
+ stage_path,
338
+ stage_name,
339
+ stage_identity,
340
+ parent_fd=parent_fd,
341
+ )
342
+ os.fsync(stage_fd)
343
+ os.close(stage_fd)
344
+ stage_fd = None
345
+
346
+ if before_publish is not None:
347
+ before_publish()
348
+
349
+ # A callback can replace the private pathname just as it can replace
350
+ # the public target. Revalidate the owned stage after every callback,
351
+ # then leave destination validation immediately adjacent to publish.
352
+ _verify_owned_stage(
353
+ destination,
354
+ stage_path,
355
+ stage_name,
356
+ stage_identity,
357
+ parent_fd=parent_fd,
358
+ )
359
+ if destination.target_existed:
360
+ _replace_approved_target(
361
+ destination,
362
+ stage_path,
363
+ stage_name,
364
+ stage_identity,
365
+ parent_fd=parent_fd,
366
+ )
367
+ stage_path = ""
368
+ else:
369
+ _publish_new_target(
370
+ destination,
371
+ stage_path,
372
+ stage_name,
373
+ stage_identity,
374
+ parent_fd=parent_fd,
375
+ )
376
+ stage_path = ""
377
+
378
+ _sync_parent_directory(parent_fd)
379
+ return True
380
+ finally:
381
+ if stage_fd is not None:
382
+ os.close(stage_fd)
383
+ if stage_path:
384
+ _unlink_owned_stage(
385
+ destination,
386
+ stage_path,
387
+ stage_name,
388
+ stage_identity,
389
+ parent_fd=parent_fd,
390
+ )
391
+ if parent_fd is not None:
392
+ os.close(parent_fd)
393
+
394
+
395
+ def collect_protected_database_files(
396
+ owner: object | None,
397
+ ) -> ProtectedDatabaseFiles:
398
+ """Collect current and retained DB artifacts without opening SQLite."""
399
+ identities: set[DatabaseFileIdentity] = set()
400
+ database_paths: set[str] = set()
401
+
402
+ def add_identity(candidate: object) -> None:
403
+ if isinstance(candidate, tuple) and len(candidate) in {2, 3}:
404
+ try:
405
+ hash(candidate)
406
+ except TypeError:
407
+ return
408
+ identities.add(candidate) # type: ignore[arg-type]
409
+
410
+ def add_identities(candidates: object) -> None:
411
+ add_identity(candidates)
412
+ if isinstance(candidates, (frozenset, set, list)):
413
+ for candidate in candidates:
414
+ add_identity(candidate)
415
+
416
+ def add_path(candidate: object) -> None:
417
+ if candidate:
418
+ try:
419
+ database_paths.add(os.path.abspath(os.fspath(candidate)))
420
+ except (TypeError, ValueError):
421
+ pass
422
+
423
+ def add_source(candidate: object) -> None:
424
+ if candidate is None:
425
+ return
426
+ if isinstance(candidate, (str, os.PathLike)):
427
+ add_path(candidate)
428
+ return
429
+ add_identity(candidate)
430
+ for attribute in ("database_identity", "identity"):
431
+ try:
432
+ add_identity(getattr(candidate, attribute, None))
433
+ except RuntimeError:
434
+ pass
435
+ for attribute in (
436
+ "sidecar_identities",
437
+ "sqlite_sidecar_identities",
438
+ "protected_database_identities",
439
+ ):
440
+ try:
441
+ add_identities(getattr(candidate, attribute, None))
442
+ except RuntimeError:
443
+ pass
444
+ for attribute in (
445
+ "database_path",
446
+ "logical_path",
447
+ "path_to_db",
448
+ "resolved_database_path",
449
+ "resolved_path",
450
+ ):
451
+ try:
452
+ add_path(getattr(candidate, attribute, None))
453
+ except RuntimeError:
454
+ pass
455
+ try:
456
+ cache = getattr(candidate, "cache", None)
457
+ cached_dataset = getattr(cache, "_dataset", None)
458
+ add_path(getattr(cached_dataset, "path_to_db", None))
459
+ except RuntimeError:
460
+ pass
461
+
462
+ application = qtw.QApplication.instance()
463
+ owners: list[object] = []
464
+ if owner is not None:
465
+ owners.append(owner)
466
+ if application is not None:
467
+ # Do not retain protection observations from hidden, deleted-later Qt
468
+ # test or closed-window objects. Their filesystem identities may be
469
+ # reused for an unrelated export on filesystems such as ext4. The
470
+ # active export owner is always included above; other active plot/main
471
+ # windows are top-level and visible.
472
+ for widget in qtw.QApplication.topLevelWidgets():
473
+ try:
474
+ if widget.isVisible():
475
+ owners.append(widget)
476
+ except RuntimeError:
477
+ pass
478
+
479
+ seen_owners: set[int] = set()
480
+ for current_owner in owners:
481
+ if id(current_owner) in seen_owners:
482
+ continue
483
+ seen_owners.add(id(current_owner))
484
+ _collect_owner_database_sources(current_owner, add_source, add_identity)
485
+
486
+ logical_paths: set[str] = set()
487
+ resolved_paths: set[str] = set()
488
+ for database_path in database_paths:
489
+ logical_base = logical_database_path(database_path)
490
+ resolved_base = canonical_database_path(logical_base)
491
+ for base_path in {logical_base, resolved_base}:
492
+ for suffix in _DATABASE_ARTIFACT_SUFFIXES:
493
+ artifact_path = f"{base_path}{suffix}"
494
+ logical_paths.add(logical_database_path(artifact_path))
495
+ resolved_paths.add(canonical_database_path(artifact_path))
496
+ identity = database_file_identity(artifact_path)
497
+ if identity is not None:
498
+ identities.add(identity)
499
+
500
+ return ProtectedDatabaseFiles(
501
+ logical_paths=frozenset(logical_paths),
502
+ resolved_paths=frozenset(resolved_paths),
503
+ identities=frozenset(identities),
504
+ )
505
+
506
+
507
+ def _collect_owner_database_sources(
508
+ owner: object,
509
+ add_source: Callable[[object], None],
510
+ add_identity: Callable[[object], None],
511
+ ) -> None:
512
+ """Inspect the bounded set of qPlot state holders used by its windows."""
513
+ add_source(owner)
514
+ for attribute in (
515
+ "_dataset_key",
516
+ "_selected_dataset_key",
517
+ "_loaded_database_instance",
518
+ "_database_refresh_instance",
519
+ "_database_load_worker",
520
+ "_database_refresh_worker",
521
+ "_database_detail_worker",
522
+ "_database_expensive_detail_worker",
523
+ "_test_database_generation_worker",
524
+ "worker",
525
+ ):
526
+ try:
527
+ add_source(getattr(owner, attribute, None))
528
+ except RuntimeError:
529
+ pass
530
+
531
+ for holder_name in ("_dataset_holder", "dataset_holder"):
532
+ try:
533
+ holder = getattr(owner, holder_name, None)
534
+ items = getattr(holder, "items", None)
535
+ if callable(items):
536
+ for dataset_key, dataset_handle in items():
537
+ add_source(dataset_key)
538
+ add_source(dataset_handle)
539
+ except (RuntimeError, TypeError):
540
+ pass
541
+
542
+ try:
543
+ load_state = getattr(owner, "_database_load_state", None)
544
+ except RuntimeError:
545
+ load_state = None
546
+ if isinstance(load_state, Mapping):
547
+ add_source(load_state.get("load_instance"))
548
+ add_identity(load_state.get("load_identity"))
549
+ add_source(load_state.get("abspath"))
550
+
551
+ try:
552
+ replacement_state = getattr(owner, "_test_database_replacement_state", None)
553
+ except RuntimeError:
554
+ replacement_state = None
555
+ add_source(replacement_state)
556
+ add_source(getattr(replacement_state, "original_instance", None))
557
+
558
+ for container_name in ("_plot_workers", "_workers"):
559
+ try:
560
+ container = getattr(owner, container_name, None)
561
+ if isinstance(container, Mapping):
562
+ sources: Iterable[object] = container.values()
563
+ elif isinstance(container, (set, frozenset, list, tuple)):
564
+ sources = container
565
+ else:
566
+ continue
567
+ for source in tuple(sources):
568
+ add_source(source)
569
+ except RuntimeError:
570
+ pass
571
+
572
+ try:
573
+ file_textbox = getattr(owner, "fileTextbox", None)
574
+ displayed_path = getattr(file_textbox, "text", None)
575
+ if callable(displayed_path):
576
+ add_source(displayed_path())
577
+ except RuntimeError:
578
+ pass
579
+
580
+
581
+ def _capture_export_destination(
582
+ owner: object | None,
583
+ filename: str,
584
+ *,
585
+ existing_target_validator: Callable[[str], Any] | None,
586
+ ) -> ExportDestinationTransaction:
587
+ filename = os.path.abspath(filename)
588
+ _ensure_windows_export_path_is_unambiguous(filename)
589
+ parent_path = os.path.dirname(filename)
590
+ try:
591
+ parent_stat = os.stat(parent_path)
592
+ except OSError as err:
593
+ raise UnsafeExportDestinationError(
594
+ "The selected export folder is not available."
595
+ ) from err
596
+ if not stat.S_ISDIR(parent_stat.st_mode):
597
+ raise UnsafeExportDestinationError(
598
+ "The selected export parent is not a folder."
599
+ )
600
+ if not _directory_identity_is_stable(parent_stat):
601
+ raise UnsafeExportDestinationError(
602
+ "The selected export folder has no stable filesystem identity."
603
+ )
604
+
605
+ target_signature = _target_signature(filename)
606
+ protected_files = collect_protected_database_files(owner)
607
+ _ensure_target_is_not_protected(filename, protected_files)
608
+ if _target_signature(filename) != target_signature:
609
+ raise UnsafeExportDestinationError(
610
+ "The selected export file changed while it was being inspected."
611
+ )
612
+ destination = ExportDestinationTransaction(
613
+ filename=filename,
614
+ parent_path=parent_path,
615
+ parent_identity=_directory_identity(parent_stat),
616
+ target_signature=target_signature,
617
+ replacement_confirmed=False,
618
+ protected_database_files=protected_files,
619
+ owner=owner,
620
+ existing_target_validator=existing_target_validator,
621
+ )
622
+ _run_existing_target_validator(destination, target_signature)
623
+ return destination
624
+
625
+
626
+ def _ensure_target_is_not_protected(
627
+ filename: str,
628
+ protected_files: ProtectedDatabaseFiles,
629
+ *,
630
+ parent_fd: int | None = None,
631
+ ) -> None:
632
+ logical_path = logical_database_path(filename)
633
+ resolved_path = canonical_database_path(filename)
634
+ protected_paths = (
635
+ protected_files.logical_paths | protected_files.resolved_paths
636
+ )
637
+ protected_aliases = {
638
+ _filesystem_alias_key(protected_path)
639
+ for protected_path in protected_paths
640
+ }
641
+ if (
642
+ logical_path in protected_paths
643
+ or resolved_path in protected_paths
644
+ or _filesystem_alias_key(logical_path) in protected_aliases
645
+ or _filesystem_alias_key(resolved_path) in protected_aliases
646
+ ):
647
+ raise UnsafeExportDestinationError(
648
+ "The selected export path refers to an input database or one of "
649
+ "its SQLite sidecars."
650
+ )
651
+
652
+ target_identity = database_file_identity(filename)
653
+ if (
654
+ target_identity is not None
655
+ and target_identity in protected_files.identities
656
+ ):
657
+ raise UnsafeExportDestinationError(
658
+ "The selected export path refers to an input database file."
659
+ )
660
+
661
+ # A destination need never have been loaded in qPlot. Conservatively
662
+ # protect every recognizable SQLite file, without opening SQLite or
663
+ # trying to distinguish QCoDeS schemas (which could live only in a WAL).
664
+ header = _read_export_candidate_header(filename, parent_fd=parent_fd)
665
+ if header.startswith(_SQLITE_ARTIFACT_HEADERS):
666
+ raise UnsafeExportDestinationError(
667
+ "The selected export path refers to a SQLite database or journal; "
668
+ "export replacement cannot overwrite database files."
669
+ )
670
+ for suffix in SQLITE_SIDECAR_SUFFIXES:
671
+ if filename.casefold().endswith(suffix):
672
+ database_path = filename[:-len(suffix)]
673
+ if _read_export_candidate_header(
674
+ database_path, parent_fd=parent_fd,
675
+ ).startswith(_SQLITE_HEADER):
676
+ raise UnsafeExportDestinationError(
677
+ "The selected export path is reserved for a SQLite "
678
+ "database sidecar."
679
+ )
680
+
681
+
682
+ def _read_export_candidate_header(
683
+ filename: str,
684
+ *,
685
+ parent_fd: int | None,
686
+ ) -> bytes:
687
+ """Read at most 16 bytes from a stable regular file, physically read-only.
688
+
689
+ No SQLite connection, locks, schema traversal, recovery, or sidecar
690
+ creation is involved. Unreadable or changing candidates fail closed.
691
+ NONBLOCK and the regular-file checks avoid waiting on a substituted FIFO.
692
+ The same bounded inspection also handles a sidecar's associated main file.
693
+ """
694
+ signature = _target_signature(filename, parent_fd=parent_fd)
695
+ if signature is None:
696
+ return b""
697
+ flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
698
+ flags |= getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_NOFOLLOW", 0)
699
+ flags |= getattr(os, "O_CLOEXEC", 0)
700
+ try:
701
+ fd = os.open(
702
+ filename if parent_fd is None else os.path.basename(filename),
703
+ flags,
704
+ **({} if parent_fd is None else {"dir_fd": parent_fd}),
705
+ )
706
+ try:
707
+ opened = os.fstat(fd)
708
+ stat_fields = (
709
+ "st_dev", "st_ino", "st_mode", "st_nlink", "st_size",
710
+ "st_mtime_ns", "st_ctime_ns",
711
+ )
712
+ opened_signature = tuple(int(getattr(opened, field)) for field in stat_fields)
713
+ # On Windows, lstat may report CreationTime as ctime while
714
+ # fstat reports ChangeTime. Compare their common fields, then
715
+ # retain the full timestamps in each API's before/after checks.
716
+ common_fields = 6 if os.name == "nt" else 7
717
+ if opened_signature[:common_fields] != signature[:common_fields]:
718
+ raise UnsafeExportDestinationError(
719
+ "The selected export file changed while it was being inspected."
720
+ )
721
+ header = os.read(fd, len(_SQLITE_HEADER))
722
+ after_read = os.fstat(fd)
723
+ if tuple(int(getattr(after_read, field)) for field in stat_fields) != opened_signature:
724
+ raise UnsafeExportDestinationError(
725
+ "The selected export file changed while it was being inspected."
726
+ )
727
+ finally:
728
+ os.close(fd)
729
+ except OSError as err:
730
+ raise UnsafeExportDestinationError(
731
+ "The selected export file could not be inspected safely."
732
+ ) from err
733
+ if _target_signature(filename, parent_fd=parent_fd) != signature:
734
+ raise UnsafeExportDestinationError(
735
+ "The selected export file changed while it was being inspected."
736
+ )
737
+ return header
738
+
739
+
740
+ def _filesystem_alias_key(filename: str) -> str:
741
+ """Return a conservative key for case/Unicode-equivalent path aliases.
742
+
743
+ ``normcase`` alone does not model case-insensitive macOS volumes, and
744
+ APFS/HFS resolve canonically equivalent NFC/NFD names to the same entry.
745
+ Treating those spellings as aliases on every platform may reject a rare
746
+ legitimate export on a case-sensitive filesystem, but it cannot let an
747
+ export create an input database's currently absent SQLite sidecar.
748
+ """
749
+ return unicodedata.normalize("NFC", filename).casefold()
750
+
751
+
752
+ def _directory_identity(file_stat: os.stat_result) -> tuple[int, ...]:
753
+ """Return fields stable while entries inside a directory are modified."""
754
+ birthtime_ns = int(getattr(file_stat, "st_birthtime_ns", 0) or 0)
755
+ if not birthtime_ns:
756
+ birthtime = getattr(file_stat, "st_birthtime", None)
757
+ if birthtime is not None:
758
+ birthtime_ns = round(float(birthtime) * 1_000_000_000)
759
+ return (
760
+ int(file_stat.st_dev),
761
+ int(file_stat.st_ino),
762
+ int(file_stat.st_mode),
763
+ birthtime_ns,
764
+ )
765
+
766
+
767
+ def _directory_identity_is_stable(file_stat: os.stat_result) -> bool:
768
+ """Return whether a directory observation can detect replacement."""
769
+ return bool(
770
+ int(getattr(file_stat, "st_ino", 0) or 0)
771
+ or int(getattr(file_stat, "st_birthtime_ns", 0) or 0)
772
+ or getattr(file_stat, "st_birthtime", None)
773
+ )
774
+
775
+
776
+ def _target_signature(
777
+ filename: str,
778
+ *,
779
+ parent_fd: int | None = None,
780
+ ) -> tuple[object, ...] | None:
781
+ """Capture an existing ordinary target without following its final link."""
782
+ try:
783
+ if parent_fd is None:
784
+ file_stat = os.lstat(filename)
785
+ else:
786
+ file_stat = os.stat(
787
+ os.path.basename(filename),
788
+ dir_fd=parent_fd,
789
+ follow_symlinks=False,
790
+ )
791
+ except FileNotFoundError:
792
+ return None
793
+ except OSError as err:
794
+ raise UnsafeExportDestinationError(
795
+ "The selected export file could not be inspected safely."
796
+ ) from err
797
+
798
+ if stat.S_ISLNK(file_stat.st_mode):
799
+ raise UnsafeExportDestinationError(
800
+ "Symbolic-link export destinations are not supported."
801
+ )
802
+ if not stat.S_ISREG(file_stat.st_mode):
803
+ raise UnsafeExportDestinationError(
804
+ "The selected export destination is not a regular file."
805
+ )
806
+ if int(file_stat.st_nlink) != 1:
807
+ raise UnsafeExportDestinationError(
808
+ "Hard-linked export destinations are not supported."
809
+ )
810
+
811
+ inode = int(getattr(file_stat, "st_ino", 0) or 0)
812
+ stable_identity: DatabaseFileIdentity | None
813
+ if inode:
814
+ stable_identity = (int(file_stat.st_dev), inode)
815
+ else:
816
+ stable_identity = database_file_identity(filename)
817
+ if stable_identity is None:
818
+ raise UnsafeExportDestinationError(
819
+ "The selected export file has no stable filesystem identity."
820
+ )
821
+
822
+ return (
823
+ int(file_stat.st_dev),
824
+ inode,
825
+ int(file_stat.st_mode),
826
+ int(file_stat.st_nlink),
827
+ int(file_stat.st_size),
828
+ int(file_stat.st_mtime_ns),
829
+ int(file_stat.st_ctime_ns),
830
+ stable_identity,
831
+ )
832
+
833
+
834
+ def _run_existing_target_validator(
835
+ destination: ExportDestinationTransaction,
836
+ current_signature: tuple[object, ...] | None,
837
+ ) -> None:
838
+ validator = destination.existing_target_validator
839
+ if current_signature is None or validator is None:
840
+ return
841
+ try:
842
+ result = validator(destination.filename)
843
+ except UnsafeExportDestinationError:
844
+ raise
845
+ except Exception as err:
846
+ raise UnsafeExportDestinationError(
847
+ "The existing export file could not be validated safely."
848
+ ) from err
849
+ if result is False:
850
+ raise UnsafeExportDestinationError(
851
+ "The existing export file did not match the selected format."
852
+ )
853
+ if _target_signature(destination.filename) != current_signature:
854
+ raise UnsafeExportDestinationError(
855
+ "The selected export file changed while it was being inspected."
856
+ )
857
+
858
+
859
+ def _ensure_parent_is_unchanged(
860
+ destination: ExportDestinationTransaction,
861
+ *,
862
+ parent_fd: int | None,
863
+ ) -> None:
864
+ try:
865
+ current_parent = os.stat(destination.parent_path)
866
+ except OSError as err:
867
+ raise UnsafeExportDestinationError(
868
+ "The selected folder changed while printing or exporting."
869
+ ) from err
870
+ if (
871
+ not stat.S_ISDIR(current_parent.st_mode)
872
+ or not _directory_identity_is_stable(current_parent)
873
+ or _directory_identity(current_parent) != destination.parent_identity
874
+ ):
875
+ raise UnsafeExportDestinationError(
876
+ "The selected folder changed while printing or exporting."
877
+ )
878
+ if parent_fd is not None:
879
+ try:
880
+ opened_parent = os.fstat(parent_fd)
881
+ except OSError as err:
882
+ raise UnsafeExportDestinationError(
883
+ "The selected export folder is no longer available."
884
+ ) from err
885
+ if _directory_identity(opened_parent) != destination.parent_identity:
886
+ raise UnsafeExportDestinationError(
887
+ "The selected folder changed while printing or exporting."
888
+ )
889
+
890
+
891
+ def _ensure_windows_export_path_is_unambiguous(filename: str) -> None:
892
+ """Reject Win32 spellings that alias or attach streams to another file."""
893
+ if os.name != "nt":
894
+ return
895
+
896
+ _drive, tail = ntpath.splitdrive(filename)
897
+ components = tuple(
898
+ component
899
+ for component in re.split(r"[\\/]", tail)
900
+ if component
901
+ )
902
+ reserved_names = {"con", "prn", "aux", "nul", "conin$", "conout$"}
903
+ reserved_names.update(f"com{number}" for number in range(1, 10))
904
+ reserved_names.update(f"lpt{number}" for number in range(1, 10))
905
+
906
+ for component in components:
907
+ if component.endswith((" ", ".")) or ":" in component:
908
+ raise UnsafeExportDestinationError(
909
+ "Ambiguous Windows export paths and alternate data streams "
910
+ "are not supported."
911
+ )
912
+ device_name = component.split(".", 1)[0].casefold()
913
+ if device_name in reserved_names:
914
+ raise UnsafeExportDestinationError(
915
+ "Windows device-name export destinations are not supported."
916
+ )
917
+
918
+
919
+ def _open_parent_directory(
920
+ destination: ExportDestinationTransaction,
921
+ ) -> int | None:
922
+ flags = os.O_RDONLY
923
+ flags |= getattr(os, "O_DIRECTORY", 0)
924
+ flags |= getattr(os, "O_CLOEXEC", 0)
925
+ try:
926
+ parent_fd = os.open(destination.parent_path, flags)
927
+ except OSError as err:
928
+ if os.name == "nt":
929
+ return None
930
+ raise UnsafeExportDestinationError(
931
+ "The selected export folder could not be anchored safely."
932
+ ) from err
933
+ try:
934
+ _ensure_parent_is_unchanged(destination, parent_fd=parent_fd)
935
+ except Exception:
936
+ os.close(parent_fd)
937
+ raise
938
+ return parent_fd
939
+
940
+
941
+ def _stage_identity(file_stat: os.stat_result) -> tuple[int, ...] | None:
942
+ inode = int(getattr(file_stat, "st_ino", 0) or 0)
943
+ birthtime_ns = int(getattr(file_stat, "st_birthtime_ns", 0) or 0)
944
+ if not birthtime_ns:
945
+ birthtime = getattr(file_stat, "st_birthtime", None)
946
+ if birthtime is not None:
947
+ birthtime_ns = round(float(birthtime) * 1_000_000_000)
948
+ if not inode and not birthtime_ns:
949
+ return None
950
+ return (
951
+ int(file_stat.st_dev),
952
+ inode,
953
+ stat.S_IFMT(int(file_stat.st_mode)),
954
+ birthtime_ns,
955
+ )
956
+
957
+
958
+ def _stage_path_stat(
959
+ stage_path: str,
960
+ stage_name: str,
961
+ *,
962
+ parent_fd: int | None,
963
+ ) -> os.stat_result | None:
964
+ try:
965
+ if parent_fd is None:
966
+ return os.lstat(stage_path)
967
+ return os.stat(
968
+ stage_name,
969
+ dir_fd=parent_fd,
970
+ follow_symlinks=False,
971
+ )
972
+ except OSError:
973
+ return None
974
+
975
+
976
+ def _stage_database_identity(
977
+ stage_path: str,
978
+ stage_stat: os.stat_result,
979
+ ) -> DatabaseFileIdentity | None:
980
+ inode = int(getattr(stage_stat, "st_ino", 0) or 0)
981
+ if inode:
982
+ return (int(stage_stat.st_dev), inode)
983
+ return database_file_identity(stage_path)
984
+
985
+
986
+ def _stage_refers_to_protected_database(
987
+ destination: ExportDestinationTransaction,
988
+ stage_path: str,
989
+ stage_stat: os.stat_result,
990
+ ) -> bool:
991
+ protected_files = destination.protected_database_files.merged(
992
+ collect_protected_database_files(destination.owner)
993
+ )
994
+ stage_database_identity = _stage_database_identity(stage_path, stage_stat)
995
+ return (
996
+ stage_database_identity is None
997
+ or stage_database_identity in protected_files.identities
998
+ )
999
+
1000
+
1001
+ def _verify_owned_stage(
1002
+ destination: ExportDestinationTransaction,
1003
+ stage_path: str,
1004
+ stage_name: str,
1005
+ stage_identity: tuple[int, ...] | None,
1006
+ *,
1007
+ parent_fd: int | None,
1008
+ ) -> None:
1009
+ if stage_identity is None:
1010
+ raise UnsafeExportDestinationError(
1011
+ "The private export staging file has no stable identity."
1012
+ )
1013
+ _ensure_parent_is_unchanged(destination, parent_fd=parent_fd)
1014
+ stage_stat = _stage_path_stat(
1015
+ stage_path,
1016
+ stage_name,
1017
+ parent_fd=parent_fd,
1018
+ )
1019
+ if stage_stat is None or _stage_identity(stage_stat) != stage_identity:
1020
+ raise UnsafeExportDestinationError(
1021
+ "The private export staging file changed unexpectedly."
1022
+ )
1023
+ if not stat.S_ISREG(stage_stat.st_mode) or int(stage_stat.st_nlink) != 1:
1024
+ raise UnsafeExportDestinationError(
1025
+ "The private export staging target is not an unlinked regular file."
1026
+ )
1027
+ if _stage_refers_to_protected_database(
1028
+ destination,
1029
+ stage_path,
1030
+ stage_stat,
1031
+ ):
1032
+ raise UnsafeExportDestinationError(
1033
+ "The private export staging file refers to an input database."
1034
+ )
1035
+
1036
+
1037
+ def _replace_approved_target(
1038
+ destination: ExportDestinationTransaction,
1039
+ stage_path: str,
1040
+ stage_name: str,
1041
+ stage_identity: tuple[int, ...] | None,
1042
+ *,
1043
+ parent_fd: int | None,
1044
+ ) -> None:
1045
+ # Keep the target/protection check in the publication helper so no qPlot
1046
+ # callback or format-specific code can sit between it and the rename.
1047
+ destination.validate(parent_fd=parent_fd)
1048
+ _verify_owned_stage(
1049
+ destination,
1050
+ stage_path,
1051
+ stage_name,
1052
+ stage_identity,
1053
+ parent_fd=parent_fd,
1054
+ )
1055
+ if not destination.replacement_confirmed:
1056
+ raise UnsafeExportDestinationError(
1057
+ "Replacement of the existing export file was not confirmed."
1058
+ )
1059
+ if parent_fd is None:
1060
+ os.replace(stage_path, destination.filename)
1061
+ else:
1062
+ os.replace(
1063
+ stage_name,
1064
+ os.path.basename(destination.filename),
1065
+ src_dir_fd=parent_fd,
1066
+ dst_dir_fd=parent_fd,
1067
+ )
1068
+
1069
+
1070
+ def _publish_new_target(
1071
+ destination: ExportDestinationTransaction,
1072
+ stage_path: str,
1073
+ stage_name: str,
1074
+ stage_identity: tuple[int, ...] | None,
1075
+ *,
1076
+ parent_fd: int | None,
1077
+ ) -> None:
1078
+ """Publish with atomic no-clobber semantics for an absent approval."""
1079
+ destination.validate(parent_fd=parent_fd)
1080
+ _verify_owned_stage(
1081
+ destination,
1082
+ stage_path,
1083
+ stage_name,
1084
+ stage_identity,
1085
+ parent_fd=parent_fd,
1086
+ )
1087
+ try:
1088
+ if os.name == "nt":
1089
+ # Win32 rename is atomic and refuses an existing destination. It
1090
+ # also supports FAT/exFAT volumes where hard links are unavailable.
1091
+ os.rename(stage_path, destination.filename)
1092
+ elif parent_fd is None:
1093
+ os.link(stage_path, destination.filename, follow_symlinks=False)
1094
+ else:
1095
+ os.link(
1096
+ stage_name,
1097
+ os.path.basename(destination.filename),
1098
+ src_dir_fd=parent_fd,
1099
+ dst_dir_fd=parent_fd,
1100
+ follow_symlinks=False,
1101
+ )
1102
+ except FileExistsError as err:
1103
+ raise UnsafeExportDestinationError(
1104
+ "The selected export file appeared before publication; it was "
1105
+ "not replaced."
1106
+ ) from err
1107
+ except OSError as err:
1108
+ raise UnsafeExportDestinationError(
1109
+ "The new export could not be published with no-clobber semantics."
1110
+ ) from err
1111
+
1112
+ _unlink_owned_stage(
1113
+ destination,
1114
+ stage_path,
1115
+ stage_name,
1116
+ stage_identity,
1117
+ parent_fd=parent_fd,
1118
+ )
1119
+
1120
+
1121
+ def _unlink_owned_stage(
1122
+ destination: ExportDestinationTransaction,
1123
+ stage_path: str,
1124
+ stage_name: str,
1125
+ stage_identity: tuple[int, ...] | None,
1126
+ *,
1127
+ parent_fd: int | None,
1128
+ ) -> None:
1129
+ """Unlink only the exact private entry created by this transaction."""
1130
+ if stage_identity is None:
1131
+ return
1132
+ current_stat = _stage_path_stat(
1133
+ stage_path,
1134
+ stage_name,
1135
+ parent_fd=parent_fd,
1136
+ )
1137
+ if current_stat is None or _stage_identity(current_stat) != stage_identity:
1138
+ return
1139
+ if _stage_refers_to_protected_database(
1140
+ destination,
1141
+ stage_path,
1142
+ current_stat,
1143
+ ):
1144
+ return
1145
+ try:
1146
+ if parent_fd is None:
1147
+ current_parent = os.stat(destination.parent_path)
1148
+ if _directory_identity(current_parent) != destination.parent_identity:
1149
+ return
1150
+ os.unlink(stage_path)
1151
+ else:
1152
+ os.unlink(stage_name, dir_fd=parent_fd)
1153
+ except FileNotFoundError:
1154
+ pass
1155
+ except OSError:
1156
+ # Leaving an untrusted or unverifiable private entry is safer than
1157
+ # unlinking a pathname that may now refer to another file instance.
1158
+ pass
1159
+
1160
+
1161
+ def _sync_parent_directory(parent_fd: int | None) -> None:
1162
+ if parent_fd is None:
1163
+ return
1164
+ try:
1165
+ os.fsync(parent_fd)
1166
+ except OSError:
1167
+ pass