Spaces:
Running
Running
| #!/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()) | |