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