"""Failure artifacts for the fault and race suites (US07-04). A randomized concurrency failure that leaves nothing behind is a failure nobody can diagnose: the temporary library is deleted, the database goes with it, and the seed that produced the interleaving is gone. So when a test fails, everything needed to reproduce and read it is copied out of the temporary directory: // seeds.json recorded properties (``race_seed``) and the failing test id manifest.json every file under the temporary directory: path, size, sha256 files/... the databases (with -wal/-shm), journals, and logs themselves The manifest covers the whole tree — including files too large or too private to copy — so a missing or unexpected file is still visible afterwards. Copying is bounded by ``MAX_COPY_BYTES``: artifacts must not turn a failing CI run into a disk-full one. Set ``PHOTO_PIPELINE_TEST_ARTIFACTS`` to choose the destination; the default is ``.artifacts/`` in the repository root. """ from __future__ import annotations import hashlib import json import os import re import shutil from pathlib import Path REPO = Path(__file__).resolve().parents[1] DEFAULT_DIR = REPO / ".artifacts" MAX_COPY_BYTES = 25 * 1024 * 1024 # Databases (and their write-ahead logs), journals exported as files, and logs. COPY_SUFFIXES = (".db", ".db-wal", ".db-shm", ".sqlite", ".log", ".json", ".jsonl", ".argv") def artifacts_dir() -> Path: return Path(os.environ.get("PHOTO_PIPELINE_TEST_ARTIFACTS", DEFAULT_DIR)) def _slug(test_id: str) -> str: return re.sub(r"[^A-Za-z0-9._-]+", "_", test_id)[:120] def manifest(root: Path) -> list[dict]: """Every file under ``root``: relative path, byte size, and SHA-256. The filesystem state at the moment of failure — what was moved, what was left behind, what was half-written. """ entries = [] for path in sorted(root.rglob("*")): if not path.is_file() or path.is_symlink(): continue try: body = path.read_bytes() except OSError as error: entries.append({"path": str(path.relative_to(root)), "error": str(error)}) continue entries.append( { "path": str(path.relative_to(root)), "bytes": len(body), "sha256": hashlib.sha256(body).hexdigest(), } ) return entries def collect(root: Path, test_id: str, *, properties: dict | None = None) -> Path: """Copy the evidence for one failed test out of ``root``. Returns its directory.""" destination = artifacts_dir() / _slug(test_id) files = destination / "files" files.mkdir(parents=True, exist_ok=True) entries = manifest(root) (destination / "manifest.json").write_text(json.dumps(entries, indent=1)) (destination / "seeds.json").write_text( json.dumps({"test": test_id, "properties": properties or {}}, indent=1) ) budget = MAX_COPY_BYTES for path in sorted(root.rglob("*")): if not path.is_file() or path.is_symlink(): continue if not path.name.endswith(COPY_SUFFIXES): continue size = path.stat().st_size if size > budget: continue # the manifest still records it; the copy is what is skipped target = files / path.relative_to(root) target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(path, target) budget -= size return destination