286 lines
10 KiB
Python
286 lines
10 KiB
Python
"""pm.receipt: the universal machine-readable surface for venv ops.
|
|
|
|
Every pm sync writes one; same schema/dir as update receipts; the
|
|
updater embeds the sync sections via snapshot().
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
import pm.receipt as receipt
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolated_receipt_context():
|
|
import hermes_cli.update_receipt as update_receipt
|
|
variables = (receipt._current, receipt._completed_by_update, update_receipt._current)
|
|
tokens = [variable.set(None) for variable in variables]
|
|
yield
|
|
for variable, token in zip(variables, tokens):
|
|
variable.reset(token)
|
|
|
|
|
|
@pytest.fixture
|
|
def homed(tmp_path, monkeypatch):
|
|
"""Receipt dir inside a temp hermes home."""
|
|
import hermes_constants
|
|
|
|
monkeypatch.setattr(hermes_constants, "get_hermes_home", lambda: tmp_path)
|
|
return tmp_path
|
|
|
|
|
|
@pytest.mark.parametrize("outcome,exit_code", [("failed", 1), ("refused", 2)])
|
|
def test_begin_record_finalize_roundtrip(homed, outcome, exit_code):
|
|
assert receipt.latest() is None
|
|
assert not (homed / "logs").exists()
|
|
assert receipt.finalize("ok") is None
|
|
receipt.record_warning("orphan")
|
|
receipt.record_refusal("x", "y")
|
|
assert receipt.latest() is None
|
|
receipt.begin("sync")
|
|
assert receipt.snapshot()["update_id"] is None
|
|
receipt.record_step("uv-lock", True)
|
|
receipt.record_venv_rebuild(True)
|
|
receipt.record_feature_list(["web", "acp"])
|
|
assert receipt.snapshot()["kind"] == "sync"
|
|
path = receipt.finalize("ok")
|
|
assert path is not None and path.is_file()
|
|
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
assert receipt.latest() == data
|
|
assert data["kind"] == "sync" and data["outcome"] == "ok"
|
|
assert data["venv_rebuild"] == {"ok": True, "reason": ""}
|
|
assert data["steps"][0]["name"] == "uv-lock"
|
|
assert data["feature_list"] == ["web", "acp"]
|
|
assert receipt.snapshot() is None
|
|
assert receipt.finalize("ok") is None
|
|
assert receipt.last_for_update("any-id") is None
|
|
assert receipt.last_for_update(None) is None
|
|
receipt.begin("sync")
|
|
receipt.record_venv_rebuild(False, "uv sync exited 1")
|
|
receipt.record_warning("first")
|
|
receipt.record_warning("second")
|
|
receipt.record_refusal("lazy-install", "extras outside frozen set")
|
|
failed = receipt.finalize(outcome, exit_code)
|
|
latest = receipt.latest()
|
|
assert latest == json.loads(failed.read_text(encoding="utf-8-sig"))
|
|
assert latest["outcome"] == outcome and latest["exit_code"] == exit_code
|
|
assert latest["venv_rebuild"]["reason"] == "uv sync exited 1"
|
|
assert latest["refusal"]["code"] == "lazy-install"
|
|
assert latest["refusal"]["detail"] == "extras outside frozen set"
|
|
assert [w["message"] for w in latest["warnings"]] == ["first", "second"]
|
|
assert all(w["at"] for w in latest["warnings"])
|
|
assert json.loads(path.read_text(encoding="utf-8-sig")) == data
|
|
|
|
|
|
def test_bare_python_can_report_a_failed_bootstrap(tmp_path, monkeypatch):
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
|
|
repo = Path(__file__).resolve().parents[2]
|
|
code = """
|
|
import json
|
|
from pm import receipt
|
|
from pm.package import InstallError
|
|
try:
|
|
token = receipt.begin('sync')
|
|
try:
|
|
raise InstallError('venv', 'original dependency failure')
|
|
except InstallError as exc:
|
|
receipt.record_step('dependency-sync', False, str(exc))
|
|
raise
|
|
finally:
|
|
receipt.finalize('failed', 1, token=token)
|
|
except InstallError as exc:
|
|
assert 'original dependency failure' in str(exc)
|
|
row = receipt.latest()
|
|
assert row['outcome'] == 'failed' and row['exit_code'] == 1
|
|
assert 'original dependency failure' in row['steps'][0]['detail']
|
|
print(json.dumps(row))
|
|
"""
|
|
child = subprocess.run(
|
|
[sys.executable, "-S", "-c", code], cwd=repo, env=dict(os.environ),
|
|
capture_output=True, text=True, encoding="utf-8", timeout=30,
|
|
)
|
|
assert child.returncode == 0, child.stdout + child.stderr
|
|
row = json.loads(child.stdout)
|
|
assert row["steps"][0]["ok"] is False
|
|
|
|
|
|
def test_concurrent_finalize_writes_unique_names(homed):
|
|
"""Overlapping syncs (threaded cadence/ensure) must each get their own
|
|
receipt file — no stamp collision overwrites."""
|
|
import threading
|
|
|
|
paths: list = []
|
|
errors: list = []
|
|
|
|
def run(i):
|
|
try:
|
|
receipt.begin("sync")
|
|
receipt.record_step(f"step-{i}", True)
|
|
paths.append(receipt.finalize("ok"))
|
|
except Exception as exc: # pragma: no cover
|
|
errors.append(exc)
|
|
|
|
threads = [threading.Thread(target=run, args=(i,)) for i in range(8)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
assert not errors
|
|
assert all(p is not None and p.is_file() for p in paths)
|
|
assert len({p.name for p in paths}) == 8
|
|
|
|
|
|
def test_rotation_only_removes_pm_receipts(homed):
|
|
"""The shared receipts dir also holds the updater's update_*.json —
|
|
pm rotation must never delete those."""
|
|
d = homed / "logs" / "update_receipts"
|
|
d.mkdir(parents=True)
|
|
updater = d / "update_20260101_000000_123.json"
|
|
updater.write_text('{"kind": "update"}\n', encoding="utf-8")
|
|
for i in range(25):
|
|
(d / f"pm_20260101T0000{i:02d}Z-sync-1-0001.json").write_text(
|
|
'{"kind": "sync"}\n', encoding="utf-8"
|
|
)
|
|
receipt._rotate(d)
|
|
assert updater.is_file()
|
|
kept = sorted(p.name for p in d.glob("pm_*.json"))
|
|
assert len(kept) == 20 # oldest pm receipts rotated, updater receipt untouched
|
|
|
|
|
|
def test_receipt_state_is_thread_scoped(homed):
|
|
"""begin/record must not leak across threads: an overlapping sync in
|
|
another thread neither sees nor clobbers this one's in-flight receipt."""
|
|
import threading
|
|
|
|
with receipt.worker_context("mine"):
|
|
receipt.begin("sync")
|
|
receipt.record_step("mine", True)
|
|
seen: dict = {}
|
|
|
|
def other():
|
|
seen["snapshot_from_other"] = receipt.snapshot()
|
|
with receipt.worker_context("theirs"):
|
|
receipt.begin("update")
|
|
receipt.record_step("theirs", False)
|
|
receipt.finalize("failed")
|
|
seen["after_other"] = receipt.snapshot()
|
|
|
|
t = threading.Thread(target=other)
|
|
t.start()
|
|
t.join()
|
|
# the other thread never saw our in-flight receipt
|
|
assert seen["snapshot_from_other"] is None
|
|
# its begin/finalize did not clobber ours
|
|
snap = receipt.snapshot()
|
|
assert snap is not None and snap["kind"] == "sync"
|
|
assert [s["name"] for s in snap["steps"]] == ["mine"]
|
|
path = receipt.finalize("ok")
|
|
assert path is not None and path.is_file()
|
|
assert receipt.last_for_update("theirs") is None
|
|
assert receipt.last_for_update("mine")["steps"][0]["name"] == "mine"
|
|
|
|
|
|
def test_copied_context_does_not_corrupt_parent_receipt():
|
|
"""A copied context (asyncio.to_thread / task group pattern) inherits
|
|
the SAME ContextVar dict — record_step must copy-on-write, never
|
|
mutate the parent's in-flight receipt in place."""
|
|
import contextvars
|
|
|
|
receipt.begin("sync")
|
|
receipt.record_step("parent", True)
|
|
parent_before = receipt.snapshot()
|
|
|
|
def child():
|
|
receipt.record_step("child", False)
|
|
return receipt.snapshot()
|
|
|
|
ctx = contextvars.copy_context()
|
|
child_seen = ctx.run(child)
|
|
|
|
# the child saw the parent's receipt (ambient inheritance) and
|
|
# appended its own step — in ITS copy only
|
|
assert [s["name"] for s in child_seen["steps"]] == ["parent", "child"]
|
|
# the parent's receipt is untouched by the child's record
|
|
parent_after = receipt.snapshot()
|
|
assert [s["name"] for s in parent_after["steps"]] == ["parent"]
|
|
assert parent_after == parent_before
|
|
receipt.finalize("ok")
|
|
|
|
|
|
def test_copied_context_finalize_does_not_finish_parent(homed):
|
|
import contextvars
|
|
|
|
receipt.begin("sync")
|
|
contextvars.copy_context().run(receipt.finalize, "failed", 1)
|
|
assert receipt.snapshot()["outcome"] is None
|
|
|
|
|
|
def test_recorded_values_are_not_mutable_through_the_input():
|
|
receipt.begin("sync")
|
|
checks = [{"plugin": "a", "result": {"compatible": True}}]
|
|
receipt.record_plugin_checks(checks)
|
|
checks[0]["result"]["compatible"] = False
|
|
assert receipt.snapshot()["plugin_checks"][0]["result"]["compatible"] is True
|
|
|
|
|
|
def test_snapshot_returns_a_copy():
|
|
"""Mutating the snapshot must not touch the authoritative receipt."""
|
|
receipt.begin("sync")
|
|
receipt.record_step("a", True)
|
|
snap = receipt.snapshot()
|
|
snap["steps"].append({"name": "injected", "ok": True})
|
|
snap["kind"] = "hijacked"
|
|
live = receipt.snapshot()
|
|
assert [s["name"] for s in live["steps"]] == ["a"]
|
|
assert live["kind"] == "sync"
|
|
|
|
|
|
def test_nested_begin_with_token_restores_outer():
|
|
"""A nested begin (same context) with the token from finalize must
|
|
restore the OUTER receipt instead of discarding it."""
|
|
outer = receipt.begin("sync")
|
|
receipt.record_step("outer-step", True)
|
|
inner = receipt.begin("update")
|
|
receipt.record_step("inner-step", True)
|
|
receipt.finalize("ok", token=inner)
|
|
# outer receipt survives the inner finalize
|
|
live = receipt.snapshot()
|
|
assert live is not None and live["kind"] == "sync"
|
|
assert [s["name"] for s in live["steps"]] == ["outer-step"]
|
|
path = receipt.finalize("ok", token=outer)
|
|
assert path is not None and path.is_file()
|
|
assert receipt.snapshot() is None
|
|
|
|
|
|
def test_sync_begin_after_update_finalized_has_no_update_id(homed, monkeypatch):
|
|
"""The correlation id comes from the OPEN update receipt: after its
|
|
finalize pops it, a sync begun is standalone."""
|
|
import hermes_cli.update_receipt as ur
|
|
|
|
monkeypatch.setattr(ur, "_receipt_dir", lambda: homed / "logs" / "update_receipts")
|
|
ur.begin_update_receipt()
|
|
ur.finalize_update_receipt("success")
|
|
assert ur.current_correlation_id() is None
|
|
receipt.begin("sync")
|
|
assert receipt.snapshot()["update_id"] is None
|
|
|
|
|
|
def test_last_for_update_returns_a_copy(homed):
|
|
import hermes_cli.update_receipt as ur
|
|
|
|
ur.begin_update_receipt()
|
|
my_id = ur.current_correlation_id()
|
|
receipt.begin("sync")
|
|
receipt.finalize("ok")
|
|
snap = receipt.last_for_update(my_id)
|
|
snap["outcome"] = "tampered"
|
|
assert receipt.last_for_update(my_id)["outcome"] == "ok"
|