#!/usr/bin/env python3 """Upload the Inspector to its target Hugging Face Space. Usage:: https://huggingface.co/hetchyy python scripts/deploy/upload_inspector.py dev # → hetchyy/quranic-inspector-dev python scripts/deploy/upload_inspector.py prod # → hetchyy/quranic-universal-audio Build steps: 1. Stage the Space build context: every git-tracked file, HARDLINKED into a temp tree (metadata-only — no byte copy; falls back to a copy only across volumes), plus two Space-specific overlays (root ``Dockerfile`` + frontmatter ``README.md``). The image's contents are defined solely by ``inspector/Dockerfile`` + ``.dockerignore`` (both tracked, both consumed identically here and by ``docker-publish.yml``'s repo-root build), so there is no hand-maintained file allowlist to drift from the Dockerfile's ``COPY`` set — ``.dockerignore`` prunes at build time. The frontend ``dist/`` is built inside the image (Dockerfile stage 1); nothing is pre-built here. 2. ``upload_folder`` to the Space repo (Hugging Face git+xet under the hood), with ``delete_patterns="*"`` so the Space tree mirrors the staged tree. Because the staged tree is the whole tracked repo, the Space build context equals the ``context: .`` context that ``docker-publish.yml`` builds on every push — a green CI image build is a faithful guarantee the Space will compile. Auth: reads ``HF_TOKEN`` (or ``INSPECTOR_HF_TOKEN``) from ``$REPO/.env`` via ``qua_shared._env.load_repo_env``. Idempotent: re-running uploads only the changed files (the Hub diffs by content hash). Safe to run from a fresh checkout. """ from __future__ import annotations import argparse import os import shutil import subprocess import sys import tempfile import time from pathlib import Path # Files NOT pushed to the Space repo: the uncompressed qpc_hafs.json is >10 MB # and would be auto-LFS-promoted, leaving a 133-byte pointer in the Space build # context that defeats the Dockerfile COPY. The committed ``.gz`` (~1.5 MB) is # what the image and the HF Jobs consume; the uncompressed copy stays in the # repo for local-dev tools that read it directly. _SKIP_FROM_STAGE = ("data/qpc_hafs.json",) # Git-LFS pointer files start with this magic line. If an asset got auto-LFS'd # (by extension or size) it ships as a ~133-byte pointer instead of real bytes, # silently breaking the image's Dockerfile COPY at runtime. The deploy guard # fails loudly when any staged file is a pointer rather than its content. _LFS_POINTER_MAGIC = b"version https://git-lfs.github.com/spec/" from huggingface_hub import HfApi from huggingface_hub.errors import HfHubHTTPError # HF rate limit is a fixed window (q=2500; w=300s) shared per-token across CI, # local dev, and bucket reads, so any concurrent burst can 429 the first call # of a deploy. These bound a polite Retry-After honoring backoff. _RATE_LIMIT_RETRIES = 5 _RATE_LIMIT_DEFAULT_WAIT = 30 # seconds, when no Retry-After header is sent # Bring in the .env loader so HF_TOKEN is available. sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) from qua_shared._env import load_repo_env, repo_root # noqa: E402 SPACE_REPOS = { "dev": "hetchyy/quranic-inspector-dev", "prod": "hetchyy/quranic-universal-audio", } # README frontmatter shipped to the Space. Mirrors the runbook §1 setup. SPACE_README = """--- title: Quranic Universal Audio{suffix} emoji: 🎙️ colorFrom: yellow colorTo: indigo short_description: Visualize, edit & verify Qur'anic recitation timestamps sdk: docker app_port: 7860 pinned: false hf_oauth: true hf_oauth_expiration_minutes: 480 --- """ def _git_tracked_files(repo: Path) -> list[str]: """Repo-relative paths of every git-tracked file (the Space build context).""" out = subprocess.run( ["git", "ls-files", "-z"], cwd=repo, check=True, capture_output=True, text=True, ) return [p for p in out.stdout.split("\0") if p] def _assert_no_lfs_pointers(stage_root: Path) -> None: """Abort the deploy if any staged file is a Git-LFS pointer, not its bytes. An asset that got auto-LFS-promoted (by extension or size) is staged as a ~133-byte pointer text file. Shipping that to the Space leaves the Dockerfile ``COPY`` pointing at a stub instead of real content — a silent runtime break that CI's image build doesn't catch. Fail loudly here so the operator sees it before the upload, not after the Space starts serving garbage. """ pointers: list[str] = [] n = len(_LFS_POINTER_MAGIC) for path in stage_root.rglob("*"): if not path.is_file(): continue with path.open("rb") as fh: head = fh.read(n) if head == _LFS_POINTER_MAGIC: pointers.append(str(path.relative_to(stage_root))) if pointers: listing = "\n ".join(sorted(pointers)) raise RuntimeError( "Refusing to deploy: the following staged files are Git-LFS " "pointers, not their real bytes (they were auto-LFS-promoted and " "would ship to the Space as ~133-byte stubs, breaking the " "Dockerfile COPY at runtime):\n " f"{listing}\n" "Fix by un-LFS'ing the asset, shipping a non-LFS variant (e.g. a " "compressed .gz), or inlining it — see _SKIP_FROM_STAGE above." ) def _link_or_copy(src: Path, dst: Path) -> None: """Hardlink ``src`` → ``dst`` (metadata-only, near-instant); fall back to a byte copy only on EXDEV / a filesystem that can't hardlink. The staged tree is READ-ONLY input to ``upload_folder`` + the LFS-pointer guard, so sharing the worktree's inode is safe and skips the O(all-files) byte copy that dominated deploy time on Windows. The temp dir is on the same volume as the repo (both on the system drive), so the hardlink path is taken; removing the temp tree just drops the extra link, never the worktree file. """ try: os.link(src, dst) except OSError: shutil.copy2(src, dst) def _stage(repo: Path, stage_root: Path, env: str, branch: str) -> None: """Stage the Space build context: every git-tracked file, hardlinked. The image's contents are defined solely by ``inspector/Dockerfile`` + ``.dockerignore`` (both tracked, both consumed identically here and by ``docker-publish.yml``'s ``context: .`` build), so there is no hand-curated allowlist to drift from the Dockerfile's ``COPY`` set — ``.dockerignore`` prunes at build time. Two Space-specific overlays are written on top of the tracked tree: * ``Dockerfile`` at the root — the Hub docker SDK reads it there, but ours lives under ``inspector/``. * ``README.md`` — the Space frontmatter, written last so it wins over the repo's tracked root ``README.md``. The frontend ``dist/`` is built inside the image (Dockerfile stage 1), so nothing is pre-built here. """ skip = set(_SKIP_FROM_STAGE) for rel in _git_tracked_files(repo): if rel in skip: continue src = repo / rel if not src.is_file(): continue # tracked but absent in the worktree (e.g. deleted) dst = stage_root / rel dst.parent.mkdir(parents=True, exist_ok=True) _link_or_copy(src, dst) _link_or_copy(repo / "inspector" / "Dockerfile", stage_root / "Dockerfile") suffix = " (dev)" if env == "dev" else "" (stage_root / "README.md").write_text( SPACE_README.format(suffix=suffix, branch=branch), encoding="utf-8", ) _assert_no_lfs_pointers(stage_root) def _retry_on_429(label: str, fn, *args, **kwargs): """Call ``fn``, retrying on HTTP 429 with a Retry-After honoring backoff. The Hub rate limit is a per-token fixed window shared across every process using the token (CI, local dev, bucket reads), so a transient 429 here is expected and retryable — not a fatal deploy error. """ for attempt in range(1, _RATE_LIMIT_RETRIES + 1): try: return fn(*args, **kwargs) except HfHubHTTPError as exc: resp = exc.response is_429 = resp is not None and resp.status_code == 429 if not is_429 or attempt == _RATE_LIMIT_RETRIES: raise wait = int(resp.headers.get("Retry-After", _RATE_LIMIT_DEFAULT_WAIT)) print( f"==> 429 rate-limited on {label}; sleeping {wait}s " f"(attempt {attempt}/{_RATE_LIMIT_RETRIES})", file=sys.stderr, ) time.sleep(wait) def _upload(stage_root: Path, repo_id: str, token: str, commit_msg: str) -> str: api = HfApi(token=token) # The prod/dev Spaces are permanent — skip the create_repo call (it 409s on # every deploy and still spends a request) unless the Space is actually # missing, e.g. a first-time contributor bootstrap. if not _retry_on_429("repo_exists", api.repo_exists, repo_id=repo_id, repo_type="space"): _retry_on_429( "create_repo", api.create_repo, repo_id=repo_id, repo_type="space", space_sdk="docker", private=True, exist_ok=True, ) _retry_on_429( "upload_folder", api.upload_folder, folder_path=str(stage_root), repo_id=repo_id, repo_type="space", commit_message=commit_msg, delete_patterns="*", ) # Code-only pushes don't change the Dockerfile hash, so HF doesn't # rebuild the container automatically — the new commit shows up in # the Space repo but the running container still serves the previous # bundle. Force a factory reboot so the new code actually runs. _retry_on_429("restart_space", api.restart_space, repo_id=repo_id, factory_reboot=True) return f"https://huggingface.co/spaces/{repo_id}" def _commit_sha(repo: Path) -> str: try: out = subprocess.run( ["git", "rev-parse", "--short", "HEAD"], cwd=repo, check=True, capture_output=True, text=True, ) return out.stdout.strip() except Exception: return "unknown" def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser(description=__doc__) p.add_argument("env", choices=("dev", "prod")) p.add_argument( "--dry-run", action="store_true", help="Stage but don't upload. Prints stage path and exits.", ) p.add_argument( "--verify-boot", action="store_true", help=( "Before uploading, build the staged context and boot it offline " "(filesystem fixtures), asserting /healthz is 200. Aborts the " "deploy if the image won't build or boot. Needs Docker." ), ) args = p.parse_args(argv) load_repo_env() token = os.environ.get("INSPECTOR_HF_TOKEN") or os.environ.get("HF_TOKEN") if not token: print( "ERROR: HF_TOKEN / INSPECTOR_HF_TOKEN missing in env. " "Add it to .env at the worktree root.", file=sys.stderr, ) return 2 repo = repo_root() repo_id = SPACE_REPOS[args.env] branch = "dev" if args.env == "dev" else "main" print(f"==> Deploying Inspector to {args.env} ({repo_id})") sha = _commit_sha(repo) commit_msg = f"deploy: inspector {args.env} @ {sha}" with tempfile.TemporaryDirectory(prefix=f"inspector-stage-{args.env}-") as tmp: stage_root = Path(tmp) print(f"==> Staging Space tree in {stage_root}") _stage(repo, stage_root, args.env, branch) if args.verify_boot: print("==> Verifying the staged image builds and boots...") smoke = repo / "scripts" / "deploy" / "smoke_boot.py" subprocess.run( [sys.executable, str(smoke), "--context", str(stage_root)], check=True, ) if args.dry_run: print(f"==> Dry run; stage at {stage_root} (not deleted on dry-run)") # Move out of the temp context so it survives. keep = repo / ".local" / "inspector-stage" / args.env keep.parent.mkdir(parents=True, exist_ok=True) if keep.exists(): shutil.rmtree(keep) shutil.copytree(stage_root, keep) print(f" Copied to {keep}") return 0 print(f"==> Uploading to {repo_id}") url = _upload(stage_root, repo_id, token, commit_msg) print(f"==> Done. Space: {url}") return 0 if __name__ == "__main__": sys.exit(main())