Skip to content

Commit f876d50

Browse files
fix(reports): preserve previous exports when a write fails (#154)
* fix(reports): preserve previous exports when a write fails Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> * fix(reports): preserve normal permissions for new exports Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> --------- Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> Co-authored-by: n-papaioannou <n.papaioannou@ime.life>
1 parent 03514eb commit f876d50

2 files changed

Lines changed: 139 additions & 5 deletions

File tree

‎ifixai/cli/reports.py‎

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import os
12
import re
3+
import stat
4+
from contextlib import suppress
25
from pathlib import Path
6+
from uuid import uuid4
37

48
import click
59

@@ -19,9 +23,33 @@ def _slugify(value: str) -> str:
1923
return cleaned or "unknown"
2024

2125

26+
def _write_report_atomic(path: Path, content: str) -> None:
27+
mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else None
28+
temporary = path.parent / f".ifixai-report-{uuid4().hex}.tmp"
29+
# New exports follow normal file-creation permissions, including the umask.
30+
# Replacements stay private until the existing target mode is restored.
31+
descriptor = os.open(
32+
temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL,
33+
0o666 if mode is None else 0o600,
34+
)
35+
os.close(descriptor)
36+
try:
37+
temporary.write_text(content, encoding="utf-8")
38+
if mode is not None:
39+
temporary.chmod(mode)
40+
os.replace(temporary, path)
41+
finally:
42+
with suppress(OSError):
43+
temporary.unlink(missing_ok=True)
44+
45+
2246
def save_reports(
23-
result: TestRunResult, output_dir: str, report_format: str, run_nonce: str | None = None,
24-
*, run_id: str | None = None,
47+
result: TestRunResult,
48+
output_dir: str,
49+
report_format: str,
50+
run_nonce: str | None = None,
51+
*,
52+
run_id: str | None = None,
2553
) -> None:
2654
out_path = Path(output_dir)
2755
out_path.mkdir(parents=True, exist_ok=True)
@@ -39,14 +67,14 @@ def save_reports(
3967

4068
if report_format in ("markdown", "both"):
4169
summary_path = out_path / f"{base_name}-summary.md"
42-
summary_path.write_text(generate_summary_report(result), encoding="utf-8")
70+
_write_report_atomic(summary_path, generate_summary_report(result))
4371
click.echo(f" Summary (start here): {summary_path}")
4472

4573
md_path = out_path / f"{base_name}.md"
46-
md_path.write_text(generate_markdown_report(result), encoding="utf-8")
74+
_write_report_atomic(md_path, generate_markdown_report(result))
4775
click.echo(f" Full report: {md_path}")
4876

4977
if report_format in ("json", "both"):
5078
json_path = out_path / f"{base_name}.json"
51-
json_path.write_text(generate_json_report(result), encoding="utf-8")
79+
_write_report_atomic(json_path, generate_json_report(result))
5280
click.echo(f" JSON (machine): {json_path}")
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import errno
2+
from pathlib import Path
3+
4+
import pytest
5+
6+
from ifixai.cli.reports import save_reports
7+
from ifixai.core.types import TestRunResult
8+
9+
10+
@pytest.mark.parametrize(
11+
"report_format,suffix", [("json", ".json"), ("markdown", "-summary.md")]
12+
)
13+
def test_failed_report_write_preserves_the_previous_artifact(
14+
tmp_path, monkeypatch, report_format, suffix
15+
):
16+
result = TestRunResult(system_name="agent", fixture_name="support")
17+
target = tmp_path / f"ifixai-agent-support{suffix}"
18+
old = b"previous successful report\n"
19+
target.write_bytes(old)
20+
original = Path.write_text
21+
writes = []
22+
23+
def fail_during_actual_write(path, data, *args, **kwargs):
24+
writes.append(path)
25+
with path.open("w", encoding="utf-8") as handle:
26+
handle.write(data[:10])
27+
handle.flush()
28+
raise OSError(errno.ENOSPC, "owned injected disk-full failure")
29+
30+
monkeypatch.setattr(Path, "write_text", fail_during_actual_write)
31+
with pytest.raises(OSError) as exc:
32+
save_reports(result, str(tmp_path), report_format)
33+
assert exc.value.errno == errno.ENOSPC
34+
assert writes
35+
assert target.read_bytes() == old
36+
assert list(tmp_path.iterdir()) == [target]
37+
monkeypatch.setattr(Path, "write_text", original)
38+
39+
40+
def test_successful_json_report_still_publishes_normally(tmp_path):
41+
import json
42+
43+
save_reports(
44+
TestRunResult(system_name="agent", fixture_name="support"),
45+
str(tmp_path),
46+
"json",
47+
)
48+
assert (
49+
json.loads((tmp_path / "ifixai-agent-support.json").read_text())["metadata"][
50+
"system_name"
51+
]
52+
== "agent"
53+
)
54+
55+
56+
def test_successful_replacement_preserves_existing_report_permissions(tmp_path):
57+
import os
58+
import stat
59+
60+
if os.name == "nt":
61+
pytest.skip("POSIX permission bits")
62+
target = tmp_path / "ifixai-agent-support.json"
63+
target.write_text("old")
64+
target.chmod(0o640)
65+
save_reports(
66+
TestRunResult(system_name="agent", fixture_name="support"),
67+
str(tmp_path),
68+
"json",
69+
)
70+
assert stat.S_IMODE(target.stat().st_mode) == 0o640
71+
assert len(list(tmp_path.iterdir())) == 1
72+
73+
74+
@pytest.mark.parametrize("mask", ["022", "027", "077"])
75+
def test_new_reports_follow_normal_file_creation_umask(tmp_path, mask):
76+
import os
77+
import subprocess
78+
import sys
79+
80+
if os.name == "nt":
81+
pytest.skip("POSIX permission bits")
82+
# Change the umask only in this owned child, never the pytest process.
83+
script = """
84+
import os
85+
import stat
86+
import sys
87+
from pathlib import Path
88+
from ifixai.cli.reports import save_reports
89+
from ifixai.core.types import TestRunResult
90+
mask = int(sys.argv[2], 8)
91+
os.umask(mask)
92+
root = Path(sys.argv[1])
93+
save_reports(TestRunResult(system_name="agent", fixture_name="support"), str(root), "both")
94+
reports = list(root.iterdir())
95+
assert len(reports) == 3
96+
for report in reports:
97+
mode = stat.S_IMODE(report.stat().st_mode)
98+
assert mode == 0o666 & ~mask, (report.name, oct(mode), oct(0o666 & ~mask))
99+
"""
100+
result = subprocess.run(
101+
[sys.executable, "-c", script, str(tmp_path), mask],
102+
env={"PATH": os.environ.get("PATH", ""),
103+
"PYTHONPATH": os.environ.get("PYTHONPATH", "")},
104+
capture_output=True, text=True, check=False,
105+
)
106+
assert result.returncode == 0, result.stdout + result.stderr

0 commit comments

Comments
 (0)