Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ All notable changes are recorded here. The project follows semantic versioning a
- Validated run-wide diffraction and discovery settings before copying local inputs, contacting providers, or starting GUI workers.
- Snapshotted GUI run options on the Tk thread so background workers do not access mutable Tk state.
- Required explicit overwrite authorization for existing quick-export workbooks and replaced authorized workbooks atomically.
- Preserved workbooks created by another process during quick export when overwrite was not authorized.
- Rechecked bundle and benchmark targets immediately before commit so files created during a long-running calculation are not silently replaced.
- Rejected malformed or non-finite provider elasticity matrices instead of truncating oversized arrays to 6×6.
- Applied Materials Project stability thresholds before server-side result limits, with strict local verification so qualifying candidates are not lost to post-filtering.
Expand Down
14 changes: 13 additions & 1 deletion src/diffractscout/quick_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import argparse
import os
import shutil
import sys
import tempfile
Expand Down Expand Up @@ -53,7 +54,18 @@ def _copy_excel_atomic(source: Path, target: Path, *, overwrite: bool) -> None:
temporary_name = handle.name
temporary = Path(temporary_name)
shutil.copy2(source, temporary)
temporary.replace(target)
if overwrite:
temporary.replace(target)
else:
try:
# Publish the completed file atomically without replacing a
# target created after the preflight check.
os.link(temporary, target)
except FileExistsError as exc:
raise FileExistsError(
f"Excel output was created while exporting: {target}. "
"The new file was preserved; rerun with overwrite=True only if intended."
) from exc
finally:
if temporary_name:
Path(temporary_name).unlink(missing_ok=True)
Expand Down
26 changes: 25 additions & 1 deletion tests/test_quick_export.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from pathlib import Path
import shutil

import pytest
from openpyxl import load_workbook
Expand All @@ -7,7 +8,7 @@
from diffractscout.models import AnalysisSettings
from diffractscout.pipeline import analyze_cifs
from diffractscout.quick_export import main as quick_export_main
from diffractscout.quick_export import quick_export
from diffractscout.quick_export import _copy_excel_atomic, quick_export
from diffractscout.validation import verify_bundle


Expand Down Expand Up @@ -37,6 +38,29 @@ def test_quick_export_does_not_replace_existing_excel_without_authorization(
assert not (tmp_path / "existing_bundle").exists()


def test_atomic_excel_copy_preserves_target_created_during_export(
tmp_path: Path, monkeypatch
) -> None:
source = tmp_path / "source.xlsx"
target = tmp_path / "result.xlsx"
source.write_bytes(b"completed-workbook")

real_copy2 = shutil.copy2

def racing_copy(source_path: Path, temporary_path: Path) -> Path:
copied = real_copy2(source_path, temporary_path)
target.write_bytes(b"external-file")
return copied

monkeypatch.setattr("diffractscout.quick_export.shutil.copy2", racing_copy)

with pytest.raises(FileExistsError, match="created while exporting"):
_copy_excel_atomic(source, target, overwrite=False)

assert target.read_bytes() == b"external-file"
assert not list(tmp_path.glob(".result.xlsx.*.tmp"))


def test_quick_export_can_atomically_replace_existing_excel_when_authorized(
demo_inputs: Path, tmp_path: Path
) -> None:
Expand Down