#!/usr/bin/env python3 """Inventory edit/highlight project state without modifying it.""" from __future__ import annotations import argparse import json import sys import time from pathlib import Path TERMINAL = {"RENDERED", "FAILED"} def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Inventory partial, inconsistent, and optionally stale edit/highlight projects." ) parser.add_argument("--repo", type=Path, default=Path("."), help="Repository root (default: current directory).") parser.add_argument("--edit-root", type=Path, help="Override edit-project root.") parser.add_argument("--highlight-root", type=Path, help="Override highlight-project root.") parser.add_argument( "--allow-missing-roots", action="store_true", help="Do not report missing edit/highlight roots as errors (use only when the absent workflow is intentional).", ) parser.add_argument("--stale-hours", type=float, help="Flag nonterminal projects not modified within this many hours.") parser.add_argument("--format", choices=("table", "json"), default="table") parser.add_argument( "--fail-on", choices=("error", "warning", "never"), default="error", help="Return 2 at or above this finding severity (default: error).", ) return parser.parse_args() def load_json(path: Path) -> tuple[dict | None, str | None]: try: value = json.loads(path.read_text(encoding="utf-8")) if not isinstance(value, dict): return None, "top-level JSON value is not an object" return value, None except (OSError, UnicodeError, json.JSONDecodeError) as exc: return None, str(exc) def qa_mode(path: Path) -> str: if not path.is_file(): return "missing" value, error = load_json(path) if error or value is None: return "invalid" names = {str(check.get("name")) for check in value.get("checks", []) if isinstance(check, dict)} measured = {"black_frames", "long_silence", "audio_clipping"} return "measured" if measured <= names else "partial" def finding(severity: str, code: str, detail: str) -> dict: return {"severity": severity, "code": code, "detail": detail} def inspect_project(kind: str, directory: Path, now: float, stale_hours: float | None) -> dict: project_file = directory / "project.json" project, error = load_json(project_file) findings: list[dict] = [] if error: findings.append(finding("error", "PROJECT_JSON_INVALID", error)) project = {} status = str(project.get("status", "UNKNOWN")) final = directory / "final.mp4" render_manifest = directory / "render-manifest.json" qa_report = directory / "qa-report.json" if status == "RENDERED": if not final.is_file(): findings.append(finding("error", "RENDERED_OUTPUT_MISSING", "status is RENDERED but final.mp4 is absent")) if not render_manifest.is_file(): findings.append(finding("error", "RENDERED_MANIFEST_MISSING", "status is RENDERED but render-manifest.json is absent")) if kind != "highlight" and not qa_report.is_file(): findings.append(finding("warning", "RENDERED_QA_MISSING", "status is RENDERED but qa-report.json is absent")) elif final.is_file(): findings.append(finding("error", "OUTPUT_STATUS_MISMATCH", f"final.mp4 exists while status is {status}")) if render_manifest.is_file() and not final.is_file(): findings.append(finding("error", "MANIFEST_OUTPUT_MISSING", "render-manifest.json exists but final.mp4 is absent")) if status == "FAILED" and not (project.get("failureReason") or project.get("failureMessage")): findings.append(finding("warning", "FAILURE_REASON_MISSING", "FAILED project has no recorded failure reason")) if stale_hours is not None and status not in TERMINAL: try: newest = max((path.stat().st_mtime for path in directory.rglob("*") if path.is_file()), default=directory.stat().st_mtime) age_hours = (now - newest) / 3600.0 if age_hours >= stale_hours: findings.append(finding("warning", "NONTERMINAL_STALE", f"newest artifact is {age_hours:.1f} hours old")) except OSError as exc: findings.append(finding("error", "STAT_FAILED", str(exc))) pending_assets = 0 highlight_count = 0 qa_modes: list[str] = [] if kind == "highlight": director_plan = directory / "director" / "edit-plan.json" if status in {"PLANNED", "RENDERING", "RENDERED"} and not director_plan.is_file(): findings.append(finding("error", "DIRECTOR_PLAN_MISSING", f"status is {status} but director/edit-plan.json is absent")) highlights = directory / "highlights" if highlights.is_dir(): for child in sorted((p for p in highlights.iterdir() if p.is_dir()), key=lambda p: p.name): highlight_count += 1 mode = qa_mode(child / "qa-report.json") if mode != "missing": qa_modes.append(mode) requests = child / "assets" / "requests" if requests.is_dir(): pending_assets += sum(1 for p in requests.glob("*.json") if p.is_file()) if status == "RENDERED" and not qa_report.is_file(): if qa_modes: findings.append(finding( "warning", "HIGHLIGHT_AGGREGATE_QA_MISSING", "project-root aggregate qa-report.json is absent; per-highlight QA exists but does not certify the concatenated final.mp4", )) else: findings.append(finding( "warning", "RENDERED_QA_MISSING", "no project-root aggregate or per-highlight qa-report.json was found", )) if "partial" in qa_modes: findings.append(finding("warning", "HIGHLIGHT_QA_PARTIAL", "per-highlight QA lacks one or more expected FFmpeg media probes")) if "invalid" in qa_modes: findings.append(finding("error", "HIGHLIGHT_QA_INVALID", "one or more per-highlight QA reports are invalid")) else: mode = qa_mode(qa_report) if mode != "missing": qa_modes.append(mode) return { "kind": kind, "projectId": str(project.get("id") or directory.name), "status": status, "path": str(directory), "finalExists": final.is_file(), "manifestExists": render_manifest.is_file(), "qaModes": sorted(set(qa_modes)), "highlightCount": highlight_count, "assetRequestCount": pending_assets, "findings": findings, } def scan_root(kind: str, root: Path, now: float, stale_hours: float | None) -> list[dict]: if not root.exists(): return [] if not root.is_dir(): raise ValueError(f"{kind} root is not a directory: {root}") projects = [] for directory in sorted((p for p in root.iterdir() if p.is_dir()), key=lambda p: p.name): if (directory / "project.json").is_file(): projects.append(inspect_project(kind, directory, now, stale_hours)) return projects def print_table(projects: list[dict]) -> None: print("KIND\tPROJECT\tSTATUS\tFINAL\tMANIFEST\tQA\tASSET_REQUESTS\tFINDINGS") for item in projects: codes = ",".join(f["severity"][0].upper() + ":" + f["code"] for f in item["findings"]) or "-" print("\t".join((item["kind"], item["projectId"], item["status"], str(item["finalExists"]).lower(), str(item["manifestExists"]).lower(), ",".join(item["qaModes"]) or "-", str(item["assetRequestCount"]), codes))) def main() -> int: args = parse_args() if args.stale_hours is not None and args.stale_hours < 0: print("error: --stale-hours must be nonnegative", file=sys.stderr) return 3 repo = args.repo.resolve() if not (repo / "pom.xml").is_file(): print(f"error: not a recognized repository root (pom.xml missing): {repo}", file=sys.stderr) return 3 roots = { "edit": (args.edit_root or repo / "output" / "edit-projects").resolve(), "highlight": (args.highlight_root or repo / "output" / "highlight-projects").resolve(), } try: now = time.time() missing_roots = [finding("error", "PROJECT_ROOT_MISSING", f"{kind} root is absent: {root}") for kind, root in roots.items() if not root.exists()] if args.allow_missing_roots: missing_roots = [] projects = scan_root("edit", roots["edit"], now, args.stale_hours) projects += scan_root("highlight", roots["highlight"], now, args.stale_hours) except (OSError, ValueError) as exc: print(f"error: {exc}", file=sys.stderr) return 3 if args.format == "json": print(json.dumps({"roots": {k: str(v) for k, v in roots.items()}, "rootFindings": missing_roots, "projects": projects}, indent=2, sort_keys=True)) else: for item in missing_roots: print(f"ROOT\t-\t-\t-\t-\t-\t-\t{item['severity'][0].upper()}:{item['code']} {item['detail']}") print_table(projects) severities = {f["severity"] for f in missing_roots} severities.update(f["severity"] for p in projects for f in p["findings"]) if args.fail_on == "error" and "error" in severities: return 2 if args.fail_on == "warning" and severities & {"warning", "error"}: return 2 return 0 if __name__ == "__main__": raise SystemExit(main())