img2threejs / scripts /verify_space.py
Mike0021's picture
Add persistent community gallery and v1.3 previews
37e3d5a verified
Raw
History Blame Contribute Delete
15.5 kB
#!/usr/bin/env python3
"""Verify the deployed Docker Space and, optionally, a real conversion.
Default verification is read-only: ``hf spaces wait/info/logs`` followed by
GET probes of the public application. A conversion is submitted only when an
actual image is supplied with ``--e2e IMAGE``. The e2e path consumes the job's
SSE stream to a terminal event, downloads the real artifacts, and executes the
downloaded bundle with ``scripts/node_smoke.mjs``.
Examples:
python3 scripts/verify_space.py
python3 scripts/verify_space.py --e2e tests/fixtures/mug_photo.png
"""
from __future__ import annotations
import argparse
import json
import mimetypes
import shlex
import subprocess
import sys
from pathlib import Path
from typing import Any
import httpx
REPO_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_SPACE_ID = "Mike0021/img2threejs"
DEFAULT_SPACE_URL = "https://mike0021-img2threejs.hf.space"
EXPECTED_ARTIFACTS = (
"reference.png",
"probe.json",
"spec.json",
"compile-spec.json",
"validation.json",
"factory.ts",
"model.bundle.js",
"standalone.html",
)
def _command(argv: list[str], *, capture: bool = False) -> subprocess.CompletedProcess[str]:
print("->", shlex.join(argv), flush=True)
return subprocess.run(
argv,
check=False,
text=True,
capture_output=capture,
)
def _show_failure_logs(space_id: str) -> None:
for mode in (("--build", "--tail", "200"), ("--tail", "200")):
result = _command(["hf", "spaces", "logs", space_id, *mode], capture=True)
if result.stdout:
print(result.stdout, end="" if result.stdout.endswith("\n") else "\n")
if result.stderr:
print(result.stderr, file=sys.stderr, end="" if result.stderr.endswith("\n") else "\n")
def _runtime_stage(payload: dict[str, Any]) -> tuple[str | None, Any, Any]:
runtime = payload.get("runtime") if isinstance(payload.get("runtime"), dict) else payload
raw = runtime.get("raw") if isinstance(runtime.get("raw"), dict) else {}
return (
runtime.get("stage") or raw.get("stage"),
runtime.get("hardware") or raw.get("hardware"),
runtime.get("requested_hardware") or raw.get("requested_hardware"),
)
def _probe_get_surface(client: httpx.Client) -> bool:
health_response = client.get("/health")
health_response.raise_for_status()
health = health_response.json()
if health.get("status") != "ok":
raise RuntimeError(f"unexpected /health payload: {health}")
llm_configured = bool(health.get("llm_configured"))
print(f" GET /health ok (llm_configured={llm_configured})")
ui = client.get("/")
ui.raise_for_status()
if "img2threejs" not in ui.text:
raise RuntimeError("GET / did not return the img2threejs UI")
print(" GET / ok")
config_response = client.get("/api/config")
config_response.raise_for_status()
config = config_response.json()
forbidden_keys = {"api_key", "llm_api_key", "anthropic_api_key", "token", "secret"}
if forbidden_keys.intersection({str(key).lower() for key in config}):
raise RuntimeError("GET /api/config exposed a credential-bearing key")
serialized = json.dumps(config).lower()
if "sk-ant-" in serialized or '"hf_' in serialized:
raise RuntimeError("GET /api/config appears to contain credential material")
print(" GET /api/config ok (credential-free)")
gallery_response = client.get("/api/gallery", params={"offset": 0, "limit": 24})
gallery_response.raise_for_status()
gallery = gallery_response.json()
if not isinstance(gallery.get("items"), list):
raise RuntimeError("GET /api/gallery did not return an items array")
if not isinstance(gallery.get("total"), int) or gallery["total"] < 0:
raise RuntimeError("GET /api/gallery returned an invalid total")
if gallery.get("offset") != 0 or gallery.get("limit") != 24:
raise RuntimeError("GET /api/gallery did not honor pagination")
for item in gallery["items"]:
if not isinstance(item, dict) or not isinstance(item.get("id"), str):
raise RuntimeError("GET /api/gallery returned a malformed item")
if len(item["id"]) != 32 or any(c not in "0123456789abcdef" for c in item["id"]):
raise RuntimeError("GET /api/gallery returned an invalid item id")
if not str(item.get("thumbnailUrl", "")).startswith("/api/gallery/"):
raise RuntimeError("GET /api/gallery returned an invalid thumbnail URL")
print(f" GET /api/gallery ok ({gallery['total']} persistent items)")
return llm_configured
def _terminal_event(client: httpx.Client, events_url: str) -> dict[str, Any]:
terminal: dict[str, Any] | None = None
print(f" streaming {events_url}")
with client.stream("GET", events_url) as response:
response.raise_for_status()
for line in response.iter_lines():
if not line.startswith("data:"):
continue
payload = json.loads(line[5:].strip())
stage = payload.get("stage", "?")
status = payload.get("status", "?")
message = payload.get("message", "")
print(f" SSE {stage}/{status}: {message}", flush=True)
if status == "error" or (stage == "done" and status == "done"):
terminal = payload
break
if terminal is None:
raise RuntimeError("SSE stream ended without a terminal event")
return terminal
def _download_artifacts(
client: httpx.Client,
job_id: str,
result: dict[str, Any],
output_root: Path,
) -> Path:
job_dir = output_root.resolve() / job_id
job_dir.mkdir(parents=True, exist_ok=False)
advertised = result.get("artifacts") if isinstance(result.get("artifacts"), dict) else {}
for name in EXPECTED_ARTIFACTS:
url = advertised.get(name) or f"/api/jobs/{job_id}/artifacts/{name}"
response = client.get(str(url))
response.raise_for_status()
if not response.content:
raise RuntimeError(f"downloaded artifact is empty: {name}")
(job_dir / name).write_bytes(response.content)
print(f" artifact {name}: {len(response.content)} bytes")
spec = json.loads((job_dir / "spec.json").read_text(encoding="utf-8"))
if spec.get("reviewHistory"):
raise RuntimeError("downloaded spec claims review history for an unreviewed hosted preview")
compile_spec = json.loads(
(job_dir / "compile-spec.json").read_text(encoding="utf-8"))
if compile_spec.get("reviewHistory"):
raise RuntimeError("compile-spec.json contains fabricated review history")
pipeline = compile_spec.get("sculptPipeline") or {}
if pipeline.get("passGateMode") != "hosted-unreviewed-preview":
raise RuntimeError("compile-spec.json is not labeled as an unreviewed hosted preview")
validation = json.loads(
(job_dir / "validation.json").read_text(encoding="utf-8"))
if validation.get("ok") is not True:
raise RuntimeError("live validation.json does not record a passing deterministic gate")
factory = (job_dir / "factory.ts").read_text(encoding="utf-8")
if "TODO:" in factory:
raise RuntimeError("factory.ts contains an unsupported-primitive TODO fallback")
standalone = (job_dir / "standalone.html").read_text(encoding="utf-8")
if "await import(url)" not in standalone:
raise RuntimeError("standalone.html does not import its embedded ESM bundle")
return job_dir
def _node_smoke(bundle_path: Path, spec_path: Path) -> None:
harness = REPO_ROOT / "scripts" / "node_smoke.mjs"
if not (REPO_ROOT / "node_modules" / "three").is_dir():
raise RuntimeError("node_modules/three is missing; run `npm ci` before live e2e verification")
result = _command(["node", str(harness), str(bundle_path), str(spec_path)])
if result.returncode != 0:
raise RuntimeError(f"node smoke failed with exit code {result.returncode}")
def _verify_gallery_item(
client: httpx.Client,
*,
job_id: str,
result: dict[str, Any],
job_dir: Path,
) -> dict[str, Any]:
if result.get("shareRequested") is not True:
raise RuntimeError("default submission did not record shareRequested=true")
if result.get("shared") is not True:
raise RuntimeError("default submission was not committed to the gallery")
summary = result.get("galleryItem")
if not isinstance(summary, dict) or summary.get("id") != job_id:
raise RuntimeError("terminal result has no matching galleryItem")
response = client.get(f"/api/gallery/{job_id}")
response.raise_for_status()
item = response.json()
if item.get("id") != job_id or item.get("targetName") != result.get("targetName"):
raise RuntimeError("gallery detail does not match the completed job")
if item.get("generation", {}).get("reviewStatus") != "unreviewed":
raise RuntimeError("gallery item lost the hosted-preview review status")
advertised = item.get("artifacts")
if not isinstance(advertised, dict):
raise RuntimeError("gallery detail has no artifact map")
for name in EXPECTED_ARTIFACTS:
url = advertised.get(name)
if not isinstance(url, str):
raise RuntimeError(f"gallery item does not advertise {name}")
gallery_response = client.get(url)
gallery_response.raise_for_status()
local_bytes = (job_dir / name).read_bytes()
if gallery_response.content != local_bytes:
raise RuntimeError(f"gallery artifact differs from job artifact: {name}")
if "immutable" not in gallery_response.headers.get("cache-control", ""):
raise RuntimeError(f"gallery artifact is not immutable: {name}")
print(f" gallery artifact {name}: stable and byte-identical")
listing = client.get("/api/gallery", params={"offset": 0, "limit": 24})
listing.raise_for_status()
if job_id not in {entry.get("id") for entry in listing.json().get("items", [])}:
raise RuntimeError("newly published item is absent from the gallery index")
friendly = client.get(f"/gallery/{job_id}")
friendly.raise_for_status()
if "img2threejs" not in friendly.text:
raise RuntimeError("friendly gallery route did not return the SPA")
print(f" gallery item {job_id}: detail, index, artifacts and SPA route ok")
return item
def _run_e2e(
client: httpx.Client,
image_path: Path,
output_root: Path,
llm_configured: bool,
) -> Path:
if not llm_configured:
raise RuntimeError("--e2e requires configured live LLM secrets; /health reports false")
image_path = image_path.resolve()
if not image_path.is_file():
raise RuntimeError(f"e2e image does not exist: {image_path}")
media_type = mimetypes.guess_type(image_path.name)[0] or "application/octet-stream"
with image_path.open("rb") as stream:
response = client.post(
"/api/jobs",
files={"file": (image_path.name, stream, media_type)},
)
if response.status_code != 202:
raise RuntimeError(
f"POST /api/jobs returned {response.status_code}: {response.text[:500]}"
)
accepted = response.json()
job_id = str(accepted["job_id"])
events_url = str(accepted.get("events_url") or f"/api/jobs/{job_id}/events")
print(f" accepted real image as job {job_id}")
terminal = _terminal_event(client, events_url)
if terminal.get("status") != "done":
raise RuntimeError("live job failed: " + json.dumps(terminal, ensure_ascii=False)[:1000])
data = terminal.get("data") if isinstance(terminal.get("data"), dict) else {}
result = data.get("result") if isinstance(data.get("result"), dict) else {}
if not result:
raise RuntimeError("terminal SSE event has no result payload")
if result.get("generationMode") != "hosted-unreviewed-preview":
raise RuntimeError(f"unexpected generation mode: {result.get('generationMode')}")
if result.get("reviewStatus") != "unreviewed":
raise RuntimeError(f"unexpected hosted review status: {result.get('reviewStatus')}")
job_dir = _download_artifacts(client, job_id, result, output_root)
_node_smoke(job_dir / "model.bundle.js", job_dir / "compile-spec.json")
_verify_gallery_item(
client,
job_id=job_id,
result=result,
job_dir=job_dir,
)
return job_dir
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--space-id", default=DEFAULT_SPACE_ID)
parser.add_argument("--space-url", default=DEFAULT_SPACE_URL)
parser.add_argument("--timeout", default="40m",
help="timeout passed to `hf spaces wait` (default: 40m)")
parser.add_argument("--logs-tail", type=int, default=80)
parser.add_argument("--e2e", metavar="IMAGE", type=Path,
help="submit this real image and require terminal artifacts")
parser.add_argument("--output-dir", type=Path,
default=REPO_ROOT / "verification" / "live-artifacts")
args = parser.parse_args(argv)
try:
wait = _command([
"hf", "spaces", "wait", args.space_id,
"--timeout", args.timeout, "--format", "json",
])
if wait.returncode != 0:
_show_failure_logs(args.space_id)
return wait.returncode or 1
info = _command([
"hf", "spaces", "info", args.space_id,
"--expand", "runtime", "--format", "json",
], capture=True)
if info.returncode != 0:
if info.stderr:
print(info.stderr, file=sys.stderr)
_show_failure_logs(args.space_id)
return info.returncode or 1
print(info.stdout, end="" if info.stdout.endswith("\n") else "\n")
payload = json.loads(info.stdout)
stage, hardware, requested = _runtime_stage(payload)
print(f"-> runtime stage={stage} hardware={hardware} requested={requested}")
if stage != "RUNNING":
_show_failure_logs(args.space_id)
raise RuntimeError(f"Space is not RUNNING (stage={stage})")
logs = _command([
"hf", "spaces", "logs", args.space_id,
"--tail", str(max(1, args.logs_tail)),
])
if logs.returncode != 0:
raise RuntimeError(f"runtime log retrieval failed with exit code {logs.returncode}")
timeout = httpx.Timeout(connect=30.0, read=30.0, write=60.0, pool=30.0)
with httpx.Client(
base_url=args.space_url.rstrip("/"),
follow_redirects=True,
timeout=timeout,
) as client:
llm_configured = _probe_get_surface(client)
artifact_dir: Path | None = None
if args.e2e is not None:
artifact_dir = _run_e2e(
client, args.e2e, args.output_dir, llm_configured
)
print("-> LIVE VERIFICATION PASSED")
print(f"-> {args.space_url.rstrip('/')}")
if artifact_dir is not None:
print(f"-> downloaded artifacts: {artifact_dir}")
return 0
except (OSError, ValueError, KeyError, RuntimeError, httpx.HTTPError) as exc:
print(f"verification failed: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())