File size: 6,910 Bytes
3e2ec7a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
#!/usr/bin/env python3
"""Talk to the deployed Space from a terminal: status, logs, and test generations.

Removes the copy-paste loop of pulling container logs out of the web UI.

    python tools/space.py status                 # runtime stage + hardware
    python tools/space.py logs                   # our [nv-generate] lines only
    python tools/space.py logs --all             # everything, unfiltered
    python tools/space.py logs --build           # build log instead of run log
    python tools/space.py wait                   # block until RUNNING (or fail)
    python tools/space.py timings                # just the phase/banner lines
    python tools/space.py generate --model mr    # fire one generation, report timing

Auth comes from $HF_TOKEN (read access is enough for status and logs).
`generate` consumes real ZeroGPU quota, so it asks before running unless -y.
"""
from __future__ import annotations

import argparse
import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.request

SPACE = os.environ.get("NV_SPACE_ID", "nvidia/nv-generate")
API = f"https://huggingface.co/api/spaces/{SPACE}"
APP_URL = os.environ.get("NV_SPACE_URL", f"https://{SPACE.replace('/', '-')}.hf.space")

# Startup can take minutes: ~20 GB of weights plus the mask archive extraction.
READY_MARKER = "DONE - READY FOR USE"
DEGRADED_MARKER = "DEGRADED - READY WITH FAILURES"


def _token() -> str:
    tok = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
    if not tok:
        sys.exit("set HF_TOKEN (read scope is enough for status/logs)")
    return tok


def _get(url: str, timeout: int = 30) -> bytes:
    req = urllib.request.Request(url, headers={"Authorization": f"Bearer {_token()}"})
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            return r.read()
    except urllib.error.HTTPError as e:
        sys.exit(f"HTTP {e.code} from {url}\n{e.read().decode('utf-8', 'replace')[:400]}")


def runtime() -> dict:
    return json.loads(_get(API)).get("runtime", {})


def log_lines(build: bool = False, timeout: int = 25) -> list[tuple[str, str]]:
    """Return [(timestamp, text)] from the log endpoint.

    The endpoint is an SSE stream that replays the backlog and then stays open
    forever, so the read has to be bounded by wall time rather than EOF. curl's
    --max-time does exactly that and behaves consistently here; urllib's socket
    timeouts did not reliably surface partial stream data.
    """
    url = f"{API}/logs/{'build' if build else 'run'}"
    proc = subprocess.run(
        ["curl", "-s", "--max-time", str(timeout),
         "-H", f"Authorization: Bearer {_token()}", url],
        capture_output=True,
    )
    out = []
    for line in proc.stdout.decode("utf-8", "replace").splitlines():
        if not line.startswith("data: "):
            continue
        try:
            rec = json.loads(line[6:])
        except json.JSONDecodeError:
            continue
        out.append((rec.get("timestamp", ""), rec.get("data", "").rstrip("\n")))
    return out


def cmd_status(args) -> int:
    r = runtime()
    print(f"space    : {SPACE}")
    print(f"stage    : {r.get('stage')}")
    print(f"hardware : {r.get('hardware', {}).get('current')}")
    print(f"url      : {APP_URL}")
    return 0


def cmd_logs(args) -> int:
    for ts, text in log_lines(build=args.build, timeout=args.timeout):
        if args.all or "[nv-generate]" in text or "Error" in text or "Traceback" in text:
            print(f"{ts[11:19] if len(ts) > 19 else ts:9} {text}")
    return 0


def cmd_timings(args) -> int:
    keep = ("[nv-generate]",)
    for ts, text in log_lines(timeout=args.timeout):
        if any(k in text for k in keep):
            print(f"{ts[11:19] if len(ts) > 19 else ts:9} {text}")
    return 0


def cmd_wait(args) -> int:
    """Poll until the app reports ready, or the stage goes terminal."""
    deadline = time.time() + args.timeout
    last = None
    while time.time() < deadline:
        stage = runtime().get("stage")
        if stage != last:
            print(f"[{time.strftime('%H:%M:%S')}] stage={stage}", flush=True)
            last = stage
        if stage in ("RUNTIME_ERROR", "BUILD_ERROR", "CONFIG_ERROR"):
            print("space failed to start; last log lines:")
            for ts, text in log_lines()[-25:]:
                print("   ", text)
            return 1
        if stage == "RUNNING":
            for _ts, text in log_lines():
                if READY_MARKER in text or DEGRADED_MARKER in text:
                    print(f"ready: {text.split('] ', 1)[-1]}")
                    return 0
        time.sleep(args.interval)
    print("timed out waiting for the space to become ready")
    return 1


def cmd_generate(args) -> int:
    """Trigger one generation through the Gradio API and report its timing."""
    try:
        from gradio_client import Client
    except ImportError:
        sys.exit("pip install gradio_client")
    if not args.yes:
        reply = input(
            f"This runs a real generation on {SPACE} and consumes ZeroGPU quota "
            f"(CT is billed 2x on xlarge). Continue? [y/N] "
        )
        if reply.strip().lower() not in ("y", "yes"):
            return 1
    before = {t for t, _ in log_lines()}
    client = Client(APP_URL, hf_token=_token(), verbose=False)
    print(f"calling {args.api_name} ...", flush=True)
    t0 = time.perf_counter()
    result = client.predict(api_name=args.api_name)
    wall = time.perf_counter() - t0
    print(f"returned in {wall:.1f}s; result type {type(result).__name__}")
    for ts, text in log_lines():
        if ts not in before and "[nv-generate]" in text:
            print("   ", text)
    return 0


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = ap.add_subparsers(dest="cmd", required=True)

    sub.add_parser("status").set_defaults(fn=cmd_status)

    p = sub.add_parser("logs")
    p.add_argument("--all", action="store_true", help="do not filter to our lines")
    p.add_argument("--build", action="store_true", help="build log instead of run log")
    p.add_argument("--timeout", type=int, default=30)
    p.set_defaults(fn=cmd_logs)

    p = sub.add_parser("timings")
    p.add_argument("--timeout", type=int, default=30)
    p.set_defaults(fn=cmd_timings)

    p = sub.add_parser("wait")
    p.add_argument("--timeout", type=int, default=900)
    p.add_argument("--interval", type=int, default=15)
    p.set_defaults(fn=cmd_wait)

    p = sub.add_parser("generate")
    p.add_argument("--api-name", default="/_generate")
    p.add_argument("-y", "--yes", action="store_true", help="skip the quota prompt")
    p.set_defaults(fn=cmd_generate)

    args = ap.parse_args()
    return args.fn(args)


if __name__ == "__main__":
    raise SystemExit(main())