Spaces:
Running on Zero
Running on Zero
File size: 8,215 Bytes
5f43c7d 1901fee 5f43c7d c6bf731 1901fee 5f43c7d 1901fee 761261e 1901fee 5f43c7d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 | #!/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())
|