her / scripts /deploy.py
geekwrestler's picture
Deploy Her (Gradio Server / ZeroGPU + bucket + per-client isolation + enrichment)
c6bf731 verified
#!/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())