#!/usr/bin/env python3 """Deploy Her · हेर to a Hugging Face ZeroGPU Space — reproducibly, in one command. Creates (or updates) the Space + its storage bucket, sets every required variable, attaches the bucket at /data, requests ZeroGPU, uploads the repo (excluding trace content / venv / node_modules), and prints the URL. Idempotent. Examples -------- # update the private test space (bucket already mounted -> no factory reboot): python scripts/deploy.py --space geekwrestler/her-trace # stand up the public hackathon space from scratch (creates space + bucket, # mounts the volume via a factory reboot, makes it public): python scripts/deploy.py --space my-hack-org/her-trace --public --create Auth: your HF token (HF_TOKEN env, else `hf auth login`). Needs write + space mgmt. """ from __future__ import annotations import argparse import os import re import sys import time from huggingface_hub import HfApi, Volume REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) MODEL_DEFAULT = "nvidia/Nemotron-Mini-4B-Instruct" IGNORE = [ ".git*", "**/.git/**", ".claude/**", ".claude", "**/.claude/**", ".venv-deploy/**", ".venv/**", "venv/**", "**/.venv*/**", "**/__pycache__/**", "**/*.pyc", "node_modules/**", "**/node_modules/**", ".uploads/**", "her-staging/**", "**/*.gguf", "models/**", "fixtures/*.jsonl", "demo/**", # the 298 MB demo mp4 lives on the bucket (/data/_assets), not in the repo "scripts/her_upload.py", # shipped via a per-space-patched step below, not verbatim "ui/public/fixture-analysis*.json", "ui/public/binary-logos/**", "narrator/knowledge/binaries.learned.json", "narrator/.cache/**", "**/*.log", ".env", ".env.*", "cloudflare/**", "**/.DS_Store", ] def main() -> int: ap = argparse.ArgumentParser(description="Deploy Her to an HF ZeroGPU Space.") ap.add_argument("--space", required=True, help="owner/name of the Space") ap.add_argument("--bucket", default=None, help="storage bucket id (default: /-data)") ap.add_argument("--model", default=os.environ.get("SPACE_MODEL_REPO", MODEL_DEFAULT)) ap.add_argument("--public", action="store_true", help="make the Space public (default: private)") ap.add_argument("--create", action="store_true", help="create Space + bucket if missing, then factory-reboot to mount the volume") ap.add_argument("--factory", action="store_true", help="force a factory reboot (needed the first time a bucket is attached)") ap.add_argument("--no-zerogpu", action="store_true", help="skip requesting ZeroGPU hardware") args = ap.parse_args() owner, _, name = args.space.partition("/") if not owner or not name: print("--space must be owner/name", file=sys.stderr); return 2 bucket = args.bucket or f"{owner}/{name}-data" private = not args.public api = HfApi() print(f"deploying → {args.space} (private={private}, bucket={bucket}, model={args.model})", flush=True) # 1) Space repo api.create_repo(repo_id=args.space, repo_type="space", space_sdk="gradio", private=private, exist_ok=True) # enforce visibility (create_repo won't change an existing repo's privacy) try: api.update_repo_settings(repo_id=args.space, repo_type="space", private=private) except Exception as e: print(" (visibility update skipped:", repr(e)[:120], ")", flush=True) # 2) storage bucket + volume — PROVISIONING ONLY (create/factory). Re-setting a # bucket volume that's already mounted can force another factory reboot, so a plain # code update leaves the existing mount alone. provision = args.create or args.factory if provision: api.create_bucket(bucket, private=True, exist_ok=True) # 3) variables for k, v in ( ("SPACE_MODEL_REPO", args.model), ("HER_DATA_DIR", "/data"), ("HER_EXTRA_ROOT", "/data"), ("HER_LEARNED_PATH", "/data/_registry/binaries.learned.json"), ("HER_SHARE", "0"), # no third-party R2 egress on the hosted Space (self-enriches) ("HER_ENRICH", "0"), # no public-API egress (npm/brew/pypi/logo CDNs) — fully local ): api.add_space_variable(args.space, k, v) # 4) attach the bucket at /data (read-write) — only when provisioning if provision: api.set_space_volumes(args.space, volumes=[Volume(type="bucket", source=bucket, mount_path="/data")]) # 5) upload the app print("uploading repo …", flush=True) api.upload_folder(repo_id=args.space, repo_type="space", folder_path=REPO_DIR, ignore_patterns=IGNORE, # prune superseded hashed bundles so ui/dist/assets/ doesn't grow # by one dead index-*.js every deploy. Scoped to that folder only, # so it can never touch fixtures/demo-session.jsonl (uploaded # separately below) or anything else. delete_patterns=["ui/dist/assets/*"], commit_message="Deploy Her (Gradio Server / ZeroGPU + bucket + per-client isolation + enrichment)") # 5b) the bundled demo session. The blanket `fixtures/*.jsonl` ignore above keeps a # user's LOCAL fixtures from ever shipping (no-egress), but the ONE committed, # identity-sanitized demo IS meant to ship so the landing demo button works. # ignore_patterns has no negation, so add it back explicitly here. (It is never a # default — the server only serves it via the explicit __demo__ sentinel.) demo = os.path.join(REPO_DIR, "fixtures", "demo-session.jsonl") if os.path.isfile(demo): api.upload_file(path_or_fileobj=demo, path_in_repo="fixtures/demo-session.jsonl", repo_id=args.space, repo_type="space", commit_message="Bundle sanitized demo session") print(" bundled demo session", flush=True) # 5c) the bundled uploader (her_upload.py) — ship a copy whose DEFAULT_SPACE points at # THIS space, not the author's. Otherwise a visitor who grabs it from the public Files # tab would upload their sessions into someone else's Space. Excluded from the folder # upload above; patched + shipped here so each deploy self-references its own target. up = os.path.join(REPO_DIR, "scripts", "her_upload.py") if os.path.isfile(up): src = open(up, encoding="utf-8").read() m = re.search(r'^DEFAULT_SPACE = "([^"]*)"', src, flags=re.M) if m and m.group(1) != args.space: # replace the author's default everywhere it appears (DEFAULT_SPACE + the # docstring usage example) so nothing in the shipped copy names another Space. patched = src.replace(m.group(1), args.space) else: if not m: print(" !! could not find her_upload.py DEFAULT_SPACE — shipping verbatim", flush=True) patched = src api.upload_file(path_or_fileobj=patched.encode("utf-8"), path_in_repo="scripts/her_upload.py", repo_id=args.space, repo_type="space", commit_message=f"Point bundled uploader at {args.space}") print(f" bundled uploader → DEFAULT_SPACE={args.space}", flush=True) # 6) ZeroGPU if not args.no_zerogpu: try: api.request_space_hardware(repo_id=args.space, hardware="zero-a10g") print(" requested ZeroGPU (zero-a10g)", flush=True) except Exception as e: print(" ZeroGPU request failed (set it in Space → Settings):", repr(e)[:120], flush=True) # 7) a FACTORY reboot is required the first time a bucket volume is attached if args.create or args.factory: print("factory reboot (mounts the bucket) …", flush=True) api.restart_space(repo_id=args.space, factory_reboot=True) time.sleep(6) rt = api.get_space_runtime(args.space) print(f"stage: {getattr(rt,'stage','?')} | hardware: {getattr(rt,'hardware','?')}", flush=True) print(f"URL: https://huggingface.co/spaces/{args.space}", flush=True) return 0 if __name__ == "__main__": raise SystemExit(main())