diff --git a/src/documents/export/sinks.py b/src/documents/export/sinks.py index a2e85d116..bc605d1bf 100644 --- a/src/documents/export/sinks.py +++ b/src/documents/export/sinks.py @@ -193,6 +193,9 @@ class DirectoryExportSink(ExportSink): raise RuntimeError("A stream is already open on this sink") target = (self._target / arcname).resolve() tmp = target.with_suffix(target.suffix + ".tmp") + # A stale tmp from an interrupted run is overwritten and consumed here, + # so it must not be pruned as a leftover at finalize. + self._snapshot.discard(tmp) target.parent.mkdir(parents=True, exist_ok=True) handle = tmp.open("w", encoding="utf-8") self._stream_open = True diff --git a/src/documents/tests/export/test_sinks.py b/src/documents/tests/export/test_sinks.py index 7c66b8fa5..5c54051b9 100644 --- a/src/documents/tests/export/test_sinks.py +++ b/src/documents/tests/export/test_sinks.py @@ -157,6 +157,28 @@ class TestDirectoryExportSink: assert not stale.exists() assert (target / "originals" / "doc.pdf").exists() + def test_delete_with_stale_stream_tmp_from_interrupted_run( + self, + tmp_path: Path, + ) -> None: + # A previous run killed mid-stream leaves manifest.json.tmp behind; the + # next run reuses that path, so pruning must not trip over it. + target: Path = tmp_path / "out" + target.mkdir() + (target / "manifest.json.tmp").write_text("[", encoding="utf-8") + with DirectoryExportSink( + target, + compare_checksums=False, + compare_json=False, + delete=True, + ) as sink: + with sink.stream("manifest.json") as handle: + writer: StreamingManifestWriter = StreamingManifestWriter(handle) + writer.write_record({"pk": 1}) + writer.close() + assert json.loads((target / "manifest.json").read_text()) == [{"pk": 1}] + assert not (target / "manifest.json.tmp").exists() + def test_no_delete_keeps_unwritten_files( self, tmp_path: Path,