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())