208 lines
9.4 KiB
Python
208 lines
9.4 KiB
Python
#!/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())
|