diff --git a/README.md b/README.md index ddd12cf..ed6ccd6 100644 --- a/README.md +++ b/README.md @@ -110,9 +110,10 @@ All yamltrip errors inherit from `YAMLTripError`: (`doc.has_anchors()`) but not resolved during value extraction. - **Large integers may lose precision.** YAML integers outside the signed 64-bit range (i64) may become `float` during deserialization. -- **Editor write-back is not atomic.** `Editor` detects external file changes - between enter and exit, but the check-then-write is racy. Do not use it - with concurrent writers. +- **Editor write-back is best-effort atomic.** `Editor` writes to a + temporary file then does an `os.replace`, so the target file is never + left half-written. External modifications between enter and exit are + detected, but the check is not locked against concurrent writers. ## Design Decisions diff --git a/src/yamltrip/editor.py b/src/yamltrip/editor.py index 01f35b6..27f6416 100644 --- a/src/yamltrip/editor.py +++ b/src/yamltrip/editor.py @@ -2,6 +2,9 @@ from __future__ import annotations +import contextlib +import os +import tempfile from pathlib import Path from typing import TYPE_CHECKING, Any @@ -62,7 +65,19 @@ def __exit__( if current_source != self._original_source: msg = f"File was modified externally: {self._path}" raise RuntimeError(msg) - self._path.write_text(self._document.dumps(), encoding="utf-8") + content = self._document.dumps() + fd, tmp = tempfile.mkstemp(dir=self._path.parent, suffix=".tmp") + try: + os.write(fd, content.encode("utf-8")) + os.close(fd) + fd = -1 + os.replace(tmp, self._path) + except BaseException: + if fd >= 0: + os.close(fd) + with contextlib.suppress(OSError): + os.unlink(tmp) + raise self._original = None self._document = None self._original_source = None