Source code for phenotypic.sdk_._atomic_io
"""Crash-safe file writes — write to a temp sibling, then ``os.replace``.
A reader of a half-written JSON/parquet marker (a killed worker, a full disk
mid-write) must never observe a truncated file. These helpers write the full
payload to a temporary file **in the same directory** as the target, ``fsync``
it, then atomically rename it over the target via :func:`os.replace` (atomic on
POSIX; a best-effort replace on Windows). On any failure the temp file is
removed, so a pre-existing target is left untouched and no ``.tmp`` debris
lingers.
The sibling-directory placement matters: :func:`os.replace` is only atomic when
the source and destination are on the same filesystem, which a same-directory
temp guarantees (a ``/tmp`` temp could land on a different mount and degrade to
a non-atomic copy). Mirrors the callable-based
:func:`phenotypic._cli._cli_output_manager._atomic_write`, but with a plain
``text``/``bytes`` payload for the tune writers.
"""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from typing import Union
def _atomic_replace(target: Path, data: bytes) -> None:
"""Write ``data`` to a same-dir temp file, fsync, then replace ``target``.
The shared core of :func:`atomic_write_text` and :func:`atomic_write_bytes`:
the parent directory is created if missing, the bytes are written to a
``NamedTemporaryFile`` beside the target, flushed to disk, and atomically
renamed over the target. Any exception removes the temp file before
re-raising, so a pre-existing ``target`` is never clobbered by a partial
write and no ``.tmp`` debris is left behind.
Args:
target: The final destination path.
data: The exact bytes to write.
Raises:
OSError: Propagated from the write/replace after the temp file is
cleaned up.
"""
target = Path(target)
target.parent.mkdir(parents=True, exist_ok=True)
tmp_path: Union[str, None] = None
try:
handle = tempfile.NamedTemporaryFile(
dir=target.parent,
prefix=f".{target.name}.",
suffix=".tmp",
delete=False,
)
tmp_path = handle.name
try:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
finally:
handle.close()
os.replace(tmp_path, target)
except BaseException:
if tmp_path is not None:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
[docs]
def atomic_write_text(
path: Union[str, Path], text: str, *, encoding: str = "utf-8"
) -> None:
"""Atomically write ``text`` to ``path`` (temp sibling + ``os.replace``).
A drop-in replacement for ``Path(path).write_text(text)`` that never leaves a
half-written file: a concurrent reader sees either the old contents or the
complete new ones, and an exception mid-write leaves any pre-existing file
intact with no ``.tmp`` debris.
Args:
path: The destination file path.
text: The full text payload to write.
encoding: The text encoding (default ``"utf-8"``).
Raises:
OSError: If the write or rename fails (the temp file is removed first).
"""
_atomic_replace(Path(path), text.encode(encoding))
[docs]
def atomic_write_bytes(path: Union[str, Path], data: bytes) -> None:
"""Atomically write ``data`` to ``path`` (temp sibling + ``os.replace``).
The bytes counterpart of :func:`atomic_write_text` for binary payloads
(e.g. a serialized parquet buffer). Same crash-safety guarantees: an
all-or-nothing replace and no partial/leftover temp file on failure.
Args:
path: The destination file path.
data: The full binary payload to write.
Raises:
OSError: If the write or rename fails (the temp file is removed first).
"""
_atomic_replace(Path(path), bytes(data))