mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
155 lines
4.4 KiB
Python
155 lines
4.4 KiB
Python
"""Atomic file write utility using tempfile + os.rename.
|
|
|
|
Provides safe atomic write patterns for all persistent state:
|
|
manifests, audit logs, cache, and reports.
|
|
|
|
Key guarantees:
|
|
- Writes to a temporary file first (in the same directory as the target).
|
|
- os.rename is atomic on the same filesystem — it either replaces or it doesn't.
|
|
- A process crash mid-write leaves the previous valid state intact.
|
|
- The target file is never partially written or truncated.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import tempfile
|
|
from typing import Any
|
|
|
|
|
|
def atomic_write_text(
|
|
path: str,
|
|
content: str,
|
|
encoding: str = "utf-8",
|
|
mode: int = 0o644,
|
|
) -> None:
|
|
"""Atomically write text content to a file.
|
|
|
|
Writes content to a temporary file in the same directory, then atomically
|
|
renames it to the target path. If the process crashes mid-write, the
|
|
temporary file is left behind and the target file is unaffected.
|
|
|
|
Args:
|
|
path: Target file path.
|
|
content: Text content to write.
|
|
encoding: Character encoding (default utf-8).
|
|
mode: File permissions (default 0o644).
|
|
"""
|
|
dirname = os.path.dirname(path)
|
|
fd, tmp_path = tempfile.mkstemp(dir=dirname, suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "w", encoding=encoding) as f:
|
|
f.write(content)
|
|
os.chmod(tmp_path, mode)
|
|
os.replace(tmp_path, path) # Atomic rename on same filesystem
|
|
except BaseException:
|
|
# Clean up temp file on any error, then re-raise
|
|
with contextlib.suppress(OSError):
|
|
os.unlink(tmp_path)
|
|
raise
|
|
|
|
|
|
def atomic_write_json(
|
|
path: str,
|
|
data: dict[str, Any],
|
|
indent: int = 2,
|
|
encoding: str = "utf-8",
|
|
mode: int = 0o644,
|
|
) -> None:
|
|
"""Atomically write JSON data to a file.
|
|
|
|
Serializes the data to JSON, then atomically writes it using
|
|
atomic_write_text. Invalid JSON data (non-serializable) raises
|
|
before any file is touched.
|
|
|
|
Args:
|
|
path: Target file path.
|
|
data: JSON-serializable dict to write.
|
|
indent: JSON indentation level.
|
|
encoding: Character encoding.
|
|
mode: File permissions.
|
|
"""
|
|
content = json.dumps(data, indent=indent, ensure_ascii=False)
|
|
atomic_write_text(path, content, encoding=encoding, mode=mode)
|
|
|
|
|
|
def atomic_append_text(
|
|
path: str,
|
|
line: str,
|
|
encoding: str = "utf-8",
|
|
mode: int = 0o644,
|
|
) -> None:
|
|
"""Atomically append a single line to a file.
|
|
|
|
For append-only files like audit logs (events.jsonl), this reads the
|
|
existing content, appends the line, and writes atomically. This ensures
|
|
no partial lines or interleaving in the canonical file.
|
|
|
|
Args:
|
|
path: Target file path.
|
|
line: Single line to append (newline added if not present).
|
|
encoding: Character encoding.
|
|
mode: File permissions.
|
|
"""
|
|
if not line.endswith("\n"):
|
|
line += "\n"
|
|
|
|
# Read existing content or start fresh
|
|
try:
|
|
with open(path, encoding=encoding) as f:
|
|
existing = f.read()
|
|
except FileNotFoundError:
|
|
existing = ""
|
|
|
|
new_content = existing + line
|
|
atomic_write_text(path, new_content, encoding=encoding, mode=mode)
|
|
|
|
|
|
def atomic_write_binary(
|
|
path: str,
|
|
data: bytes,
|
|
mode: int = 0o644,
|
|
) -> None:
|
|
"""Atomically write binary data to a file.
|
|
|
|
Writes binary data to a temporary file, then renames atomically.
|
|
|
|
Args:
|
|
path: Target file path.
|
|
data: Binary content to write.
|
|
mode: File permissions.
|
|
"""
|
|
dirname = os.path.dirname(path)
|
|
fd, tmp_path = tempfile.mkstemp(dir=dirname, suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "wb") as f:
|
|
f.write(data)
|
|
os.chmod(tmp_path, mode)
|
|
os.replace(tmp_path, path)
|
|
except BaseException:
|
|
with contextlib.suppress(OSError):
|
|
os.unlink(tmp_path)
|
|
raise
|
|
|
|
|
|
def atomic_write_lines(
|
|
path: str,
|
|
lines: list[str],
|
|
encoding: str = "utf-8",
|
|
mode: int = 0o644,
|
|
) -> None:
|
|
"""Atomically write a list of lines to a file.
|
|
|
|
Each line is written with a trailing newline.
|
|
|
|
Args:
|
|
path: Target file path.
|
|
lines: List of lines to write.
|
|
encoding: Character encoding.
|
|
mode: File permissions.
|
|
"""
|
|
content = "".join(line if line.endswith("\n") else line + "\n" for line in lines)
|
|
atomic_write_text(path, content, encoding=encoding, mode=mode)
|