diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b25bde..c7c036b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/diffractscout/quick_export.py b/src/diffractscout/quick_export.py index e5c78b8..c157035 100644 --- a/src/diffractscout/quick_export.py +++ b/src/diffractscout/quick_export.py @@ -7,6 +7,7 @@ from __future__ import annotations import argparse +import os import shutil import sys import tempfile @@ -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) diff --git a/tests/test_quick_export.py b/tests/test_quick_export.py index bad8895..8365cb4 100644 --- a/tests/test_quick_export.py +++ b/tests/test_quick_export.py @@ -1,4 +1,5 @@ from pathlib import Path +import shutil import pytest from openpyxl import load_workbook @@ -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 @@ -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: