Skip to content

Commit 561e588

Browse files
committed
fix: write json outputs atomically
1 parent 2e3fe96 commit 561e588

3 files changed

Lines changed: 85 additions & 3 deletions

File tree

arnio/io.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2353,6 +2353,43 @@ def write_parquet(
23532353
df.to_parquet(path, **kwargs)
23542354

23552355

2356+
@contextmanager
2357+
def _atomic_text_writer(
2358+
path: str,
2359+
*,
2360+
encoding: str,
2361+
errors: str | None = None,
2362+
newline: str | None = None,
2363+
) -> Iterator[io.TextIOBase]:
2364+
directory = os.path.dirname(os.path.abspath(path)) or "."
2365+
basename = os.path.basename(path)
2366+
fd, tmp_path_str = tempfile.mkstemp(
2367+
dir=directory,
2368+
prefix=f".{basename}.",
2369+
suffix=".tmp",
2370+
text=True,
2371+
)
2372+
tmp_path: str | None = tmp_path_str
2373+
try:
2374+
with os.fdopen(
2375+
fd,
2376+
"w",
2377+
encoding=encoding,
2378+
errors=errors,
2379+
newline=newline,
2380+
) as dst:
2381+
yield dst
2382+
assert tmp_path is not None
2383+
os.replace(tmp_path, path)
2384+
tmp_path = None
2385+
finally:
2386+
if tmp_path is not None:
2387+
try:
2388+
os.unlink(tmp_path)
2389+
except OSError:
2390+
pass
2391+
2392+
23562393
def write_json(
23572394
frame: ArFrame,
23582395
path: str | os.PathLike[str],
@@ -2421,7 +2458,7 @@ def write_json(
24212458

24222459
data = frame.to_dict(orient=orient)
24232460

2424-
with open(path, "w", encoding="utf-8") as f:
2461+
with _atomic_text_writer(path, encoding="utf-8") as f:
24252462
json.dump(data, f, indent=indent)
24262463

24272464

@@ -2450,9 +2487,8 @@ def write_jsonl(
24502487
) from exc
24512488

24522489
try:
2453-
with open(
2490+
with _atomic_text_writer(
24542491
path,
2455-
"w",
24562492
encoding=encoding,
24572493
errors=encoding_errors,
24582494
newline="",

tests/test_write_json.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import pytest
55

66
import arnio as ar
7+
import arnio.io as ar_io
78
from arnio.frame import ArFrame
89

910

@@ -64,6 +65,26 @@ def test_write_json_indent(sample_frame: ArFrame, tmp_path: pathlib.Path) -> Non
6465
assert ' "id":' in content
6566

6667

68+
def test_write_json_failure_preserves_existing_file(
69+
sample_frame: ArFrame, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
70+
) -> None:
71+
output_file = tmp_path / "output.json"
72+
original = '{"status":"complete"}'
73+
output_file.write_text(original, encoding="utf-8")
74+
75+
def fail_after_partial_write(_data, dst, **_kwargs):
76+
dst.write('{"partial":')
77+
raise RuntimeError("simulated write failure")
78+
79+
monkeypatch.setattr(ar_io.json, "dump", fail_after_partial_write)
80+
81+
with pytest.raises(RuntimeError, match="simulated write failure"):
82+
ar.write_json(sample_frame, output_file)
83+
84+
assert output_file.read_text(encoding="utf-8") == original
85+
assert not list(tmp_path.glob(f".{output_file.name}.*.tmp"))
86+
87+
6788
def test_write_json_invalid_frame(tmp_path: pathlib.Path) -> None:
6889
with pytest.raises(TypeError, match="frame must be an ArFrame"):
6990
ar.write_json({"a": [1]}, tmp_path / "out.json") # type: ignore

tests/test_write_jsonl.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import pytest
66

77
import arnio as ar
8+
import arnio.io as ar_io
89

910

1011
def test_write_jsonl_normal_frame(tmp_path):
@@ -23,6 +24,30 @@ def test_write_jsonl_normal_frame(tmp_path):
2324
]
2425

2526

27+
def test_write_jsonl_failure_preserves_existing_file(tmp_path, monkeypatch):
28+
frame = ar.from_pandas(pd.DataFrame({"id": [1, 2]}))
29+
path = tmp_path / "existing.jsonl"
30+
original = '{"id":0}\n'
31+
path.write_text(original, encoding="utf-8")
32+
calls = 0
33+
original_dumps = ar_io.json.dumps
34+
35+
def wrapped_dumps(row, **kwargs):
36+
nonlocal calls
37+
calls += 1
38+
if calls == 1:
39+
return original_dumps(row, **kwargs)
40+
raise ValueError("simulated serialization failure")
41+
42+
monkeypatch.setattr(ar_io.json, "dumps", wrapped_dumps)
43+
44+
with pytest.raises(ValueError, match="cannot be serialized"):
45+
ar.write_jsonl(frame, path)
46+
47+
assert path.read_text(encoding="utf-8") == original
48+
assert not list(tmp_path.glob(f".{path.name}.*.tmp"))
49+
50+
2651
def test_write_jsonl_empty_frame(tmp_path):
2752
frame = ar.from_pandas(pd.DataFrame({"id": [], "name": []}))
2853

0 commit comments

Comments
 (0)