Skip to content

Commit e927c06

Browse files
Pigbibicodex
andcommitted
fix: atomically install preview bundles
Co-Authored-By: Codex <noreply@openai.com>
1 parent 343c20f commit e927c06

2 files changed

Lines changed: 67 additions & 27 deletions

File tree

src/quant_advisor_research/preview_bundle.py

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -106,45 +106,46 @@ def artifact(name: str, role: str, content: bytes) -> dict[str, str]:
106106
}
107107

108108

109-
def _output_dir(path: str | Path) -> Path:
109+
def _output_parent(path: str | Path) -> Path:
110110
output = Path(path)
111111
try:
112-
if not output.is_dir():
113-
raise _error("output_directory_invalid")
114-
if any(output.iterdir()):
115-
raise _error("output_not_empty")
112+
if output.exists():
113+
raise _error("output_exists")
114+
if not output.parent.is_dir():
115+
raise _error("output_parent_invalid")
116116
except PreviewBundleError:
117117
raise
118118
except (OSError, TypeError, ValueError):
119-
raise _error("output_directory_invalid") from None
119+
raise _error("output_parent_invalid") from None
120120
return output
121121

122122

123123
def build_preview_bundle(report: Mapping[str, Any], output_dir: str | Path) -> PreviewBundleEvidence:
124124
"""Validate once, build three deterministic bytes, then write an empty directory."""
125125
snapshot = _validated_source(report)
126-
output = _output_dir(output_dir)
126+
output = _output_parent(output_dir)
127127
report_bytes = _canonical_json(snapshot)
128128
html_bytes = _render_html(snapshot, report_bytes)
129129
manifest = _manifest(snapshot, report_bytes, html_bytes)
130130
manifest_bytes = _canonical_json(manifest)
131131
files = {"report.json": report_bytes, "report.html": html_bytes, "manifest.json": manifest_bytes}
132-
temp_dir: str | None = None
132+
staging_dir: str | None = None
133133
try:
134-
temp_dir = tempfile.mkdtemp(prefix=".qar-preview-", dir=output)
134+
staging_dir = tempfile.mkdtemp(prefix=f".{output.name}.staging-", dir=output.parent)
135135
for name, content in files.items():
136-
Path(temp_dir, name).write_bytes(content)
137-
for name in files:
138-
os.replace(Path(temp_dir, name), output / name)
139-
os.rmdir(temp_dir)
140-
temp_dir = None
136+
Path(staging_dir, name).write_bytes(content)
137+
read_preview_bundle(staging_dir)
138+
os.rename(staging_dir, output)
139+
staging_dir = None
140+
except FileExistsError:
141+
raise _error("output_exists") from None
141142
except (OSError, TypeError, ValueError):
142143
raise _error("output_write_failed") from None
143144
finally:
144-
if temp_dir is not None:
145-
for child in Path(temp_dir).iterdir():
145+
if staging_dir is not None:
146+
for child in Path(staging_dir).iterdir():
146147
child.unlink(missing_ok=True)
147-
Path(temp_dir).rmdir()
148+
Path(staging_dir).rmdir()
148149
return PreviewBundleEvidence(MappingProxyType(snapshot), MappingProxyType(manifest))
149150

150151

tests/test_preview_bundle.py

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import pytest
88

9+
import quant_advisor_research.preview_bundle as preview_bundle
910
from quant_advisor_research.advisory_report import build_advisory_report
1011
from quant_advisor_research.preview_bundle import (
1112
BUNDLE_CONTRACT,
@@ -29,7 +30,6 @@ def report(*, cadence="daily", as_of="2026-06-20"):
2930

3031
def test_daily_bundle_builds_and_readback_validates(tmp_path):
3132
output = tmp_path / "preview"
32-
output.mkdir()
3333
result = build_preview_bundle(report(), output)
3434

3535
assert result.bundle_contract == BUNDLE_CONTRACT
@@ -64,10 +64,9 @@ def test_daily_bundle_builds_and_readback_validates(tmp_path):
6464
@pytest.mark.parametrize("cadence", ["weekly", "monthly"])
6565
def test_non_daily_source_is_rejected_before_output(cadence, tmp_path):
6666
output = tmp_path / "preview"
67-
output.mkdir()
6867
with pytest.raises(PreviewBundleError, match="daily_only"):
6968
build_preview_bundle(report(cadence=cadence), output)
70-
assert list(output.iterdir()) == []
69+
assert not output.exists()
7170

7271

7372
@pytest.mark.parametrize("mutation", [
@@ -79,17 +78,14 @@ def test_source_contract_mutations_fail_closed_without_partial_output(mutation,
7978
value = report()
8079
mutation(value)
8180
output = tmp_path / "preview"
82-
output.mkdir()
8381
with pytest.raises(PreviewBundleError):
8482
build_preview_bundle(value, output)
85-
assert list(output.iterdir()) == []
83+
assert not output.exists()
8684

8785

8886
def test_build_is_deterministic_for_equivalent_mapping_order(tmp_path):
8987
left = tmp_path / "left"
9088
right = tmp_path / "right"
91-
left.mkdir()
92-
right.mkdir()
9389
value = report()
9490
reordered = {key: value[key] for key in reversed(list(value))}
9591
build_preview_bundle(value, left)
@@ -101,7 +97,6 @@ def test_build_is_deterministic_for_equivalent_mapping_order(tmp_path):
10197

10298
def test_html_escapes_snapshot_and_has_only_fixed_relative_links(tmp_path):
10399
output = tmp_path / "preview"
104-
output.mkdir()
105100
value = report()
106101
value["source_artifacts"]["political_events"] = "<script>alert('x')</script>"
107102
build_preview_bundle(value, output)
@@ -121,7 +116,6 @@ def test_html_escapes_snapshot_and_has_only_fixed_relative_links(tmp_path):
121116
])
122117
def test_readback_tamper_and_extra_file_fail_closed(tamper, tmp_path):
123118
output = tmp_path / "preview"
124-
output.mkdir()
125119
build_preview_bundle(report(), output)
126120
tamper(output)
127121
with pytest.raises(PreviewBundleError):
@@ -133,6 +127,51 @@ def test_non_empty_output_fails_without_touching_sentinel(tmp_path):
133127
output.mkdir()
134128
sentinel = output / "sentinel"
135129
sentinel.write_text("keep")
136-
with pytest.raises(PreviewBundleError, match="output_not_empty"):
130+
with pytest.raises(PreviewBundleError, match="output_exists"):
137131
build_preview_bundle(report(), output)
138132
assert sentinel.read_text() == "keep"
133+
134+
135+
def test_destination_is_not_visible_during_staging_readback(tmp_path, monkeypatch):
136+
output = tmp_path / "preview"
137+
observed = []
138+
original = preview_bundle.read_preview_bundle
139+
140+
def inspect(path):
141+
observed.append(Path(path))
142+
assert not output.exists()
143+
return original(path)
144+
145+
monkeypatch.setattr(preview_bundle, "read_preview_bundle", inspect)
146+
build_preview_bundle(report(), output)
147+
assert len(observed) == 1
148+
assert output.is_dir()
149+
150+
151+
def test_concurrent_destination_winner_is_not_overwritten_or_cleaned(tmp_path, monkeypatch):
152+
output = tmp_path / "preview"
153+
real_rename = preview_bundle.os.rename
154+
155+
def concurrent_winner(source, destination):
156+
Path(destination).mkdir()
157+
raise FileExistsError(destination)
158+
159+
monkeypatch.setattr(preview_bundle.os, "rename", concurrent_winner)
160+
with pytest.raises(PreviewBundleError, match="output_exists"):
161+
build_preview_bundle(report(), output)
162+
assert output.is_dir()
163+
assert not list(tmp_path.glob(".preview.staging-*"))
164+
monkeypatch.setattr(preview_bundle.os, "rename", real_rename)
165+
166+
167+
def test_staging_directory_is_cleaned_when_install_fails(tmp_path, monkeypatch):
168+
output = tmp_path / "preview"
169+
170+
def fail_readback(_path):
171+
raise PreviewBundleError("forced_readback_failure")
172+
173+
monkeypatch.setattr(preview_bundle, "read_preview_bundle", fail_readback)
174+
with pytest.raises(PreviewBundleError, match="output_write_failed"):
175+
build_preview_bundle(report(), output)
176+
assert not output.exists()
177+
assert not list(tmp_path.glob(".preview.staging-*"))

0 commit comments

Comments
 (0)