hyperstudy-perception 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hyperstudy_perception-0.1.0.dist-info/METADATA +53 -0
- hyperstudy_perception-0.1.0.dist-info/RECORD +82 -0
- hyperstudy_perception-0.1.0.dist-info/WHEEL +4 -0
- hyperstudy_perception-0.1.0.dist-info/entry_points.txt +2 -0
- hyperstudy_perception-0.1.0.dist-info/licenses/LICENSE +21 -0
- perception/__init__.py +0 -0
- perception/_pipeline/RELEASE.json +59 -0
- perception/_pipeline/cloud/runpod/guard.sh +37 -0
- perception/_pipeline/cloud/runpod/image/Dockerfile +39 -0
- perception/_pipeline/cloud/runpod/image/start.sh +11 -0
- perception/_pipeline/cloud/runpod/image/tag.py +40 -0
- perception/_pipeline/cloud/skypilot/gpu-faces.yaml +39 -0
- perception/_pipeline/cloud/skypilot/gpu-tune.yaml +55 -0
- perception/_pipeline/cloud/skypilot/guard.sh +20 -0
- perception/_pipeline/cloud/skypilot/iam-perception-node.json +18 -0
- perception/_pipeline/cloud/skypilot/launch.sh +63 -0
- perception/_pipeline/cloud/skypilot/node-gpu.yaml +25 -0
- perception/_pipeline/cloud/skypilot/node-setup.sh +22 -0
- perception/_pipeline/cloud/skypilot/sky.yaml +7 -0
- perception/_pipeline/cloud/skypilot/smoke.yaml +28 -0
- perception/_pipeline/envs/api/pyproject.toml +12 -0
- perception/_pipeline/envs/api/uv.lock +276 -0
- perception/_pipeline/envs/audio/pyproject.toml +12 -0
- perception/_pipeline/envs/audio/uv.lock +575 -0
- perception/_pipeline/envs/core/pyproject.toml +12 -0
- perception/_pipeline/envs/core/uv.lock +157 -0
- perception/_pipeline/envs/faces/pyproject.toml +12 -0
- perception/_pipeline/envs/faces/uv.lock +1180 -0
- perception/_pipeline/graphs/decode.yaml +11 -0
- perception/_pipeline/graphs/faces.yaml +14 -0
- perception/_pipeline/graphs/full.yaml +31 -0
- perception/_pipeline/graphs/mvp-captions.yaml +27 -0
- perception/_pipeline/graphs/mvp.yaml +17 -0
- perception/_pipeline/nodes/audio/asr/manifest.yaml +20 -0
- perception/_pipeline/nodes/audio/asr/node.py +74 -0
- perception/_pipeline/nodes/audio/asr/prepare.py +12 -0
- perception/_pipeline/nodes/audio/asr/test_wav.py +38 -0
- perception/_pipeline/nodes/audio/asr/wav.py +19 -0
- perception/_pipeline/nodes/conftest.py +64 -0
- perception/_pipeline/nodes/core/decode/manifest.yaml +19 -0
- perception/_pipeline/nodes/core/decode/media.py +104 -0
- perception/_pipeline/nodes/core/decode/node.py +56 -0
- perception/_pipeline/nodes/core/decode/test_media.py +65 -0
- perception/_pipeline/nodes/testdata/README.md +10 -0
- perception/_pipeline/nodes/testdata/rotated-90.mp4 +0 -0
- perception/_pipeline/nodes/vision/face-track/frames.py +88 -0
- perception/_pipeline/nodes/vision/face-track/loader.py +18 -0
- perception/_pipeline/nodes/vision/face-track/manifest.yaml +30 -0
- perception/_pipeline/nodes/vision/face-track/node.py +117 -0
- perception/_pipeline/nodes/vision/face-track/prepare.py +8 -0
- perception/_pipeline/nodes/vision/face-track/test_frames.py +46 -0
- perception/_pipeline/nodes/vision/face-track/test_loader.py +16 -0
- perception/_pipeline/nodes/vision/face-track/test_thumbnails.py +66 -0
- perception/_pipeline/nodes/vision/face-track/thumbnails.py +68 -0
- perception/_pipeline/nodes/vision/face-track/tracking.py +150 -0
- perception/_pipeline/nodes/vlm/shot-captions/manifest.yaml +29 -0
- perception/_pipeline/nodes/vlm/shot-captions/node.py +185 -0
- perception/_pipeline/schema/README.md +54 -0
- perception/_pipeline/schema/bundle-0.1.schema.json +371 -0
- perception/_pipeline/sdk/pyproject.toml +13 -0
- perception/_pipeline/sdk/src/perception_node/__init__.py +157 -0
- perception/artifacts.py +180 -0
- perception/backends/__init__.py +0 -0
- perception/backends/base.py +179 -0
- perception/backends/cloud.py +214 -0
- perception/backends/native.py +140 -0
- perception/backends/probe.py +44 -0
- perception/backends/remote.py +140 -0
- perception/cli.py +780 -0
- perception/estimates.py +68 -0
- perception/export.py +100 -0
- perception/hosts.py +201 -0
- perception/ir.py +245 -0
- perception/keys.py +259 -0
- perception/manifests.py +169 -0
- perception/plan.py +170 -0
- perception/release.py +61 -0
- perception/remote_driver.py +990 -0
- perception/runpod.py +100 -0
- perception/scheduler.py +552 -0
- perception/setup.py +72 -0
- perception/store.py +219 -0
perception/cli.py
ADDED
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
"""perception: run video perception graphs and publish track bundles.
|
|
2
|
+
|
|
3
|
+
perception doctor
|
|
4
|
+
perception models list
|
|
5
|
+
perception graph validate GRAPH.yaml
|
|
6
|
+
perception plan GRAPH.yaml [--explain]
|
|
7
|
+
perception run GRAPH.yaml [--input video=PATH] [--max-cost-usd N] [--allow-downloads]
|
|
8
|
+
perception status [RUN_ID]
|
|
9
|
+
perception logs RUN_ID [--follow] [--node ID]
|
|
10
|
+
perception resume RUN_ID
|
|
11
|
+
perception rerun RUN_ID --from NODE [--no-cache]
|
|
12
|
+
perception cancel RUN_ID
|
|
13
|
+
perception artifacts RUN_ID
|
|
14
|
+
perception export RUN_ID --format bundle --output DIR
|
|
15
|
+
perception hosts add NAME USER@HOST [--root DIR] | list | check NAME | remove NAME | clean NAME
|
|
16
|
+
perception cloud on | off | status [aws | runpod]
|
|
17
|
+
perception keys set NAME | list | remove NAME
|
|
18
|
+
perception setup [GRAPH.yaml] [--only NODE[,NODE]] [--place NODE=PLACE]
|
|
19
|
+
|
|
20
|
+
The store (artifacts, run state) lives in $PERCEPTION_HOME (default ~/.hyperstudy-perception). Keys come from the
|
|
21
|
+
environment, the system keychain (`perception keys set`) and ~/.config/hyperstudy/*.env files only you can read (keys.py);
|
|
22
|
+
their values are never printed or written to run files.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import asyncio
|
|
29
|
+
import itertools
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import sys
|
|
33
|
+
import time
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
import yaml
|
|
37
|
+
|
|
38
|
+
from . import export as export_mod
|
|
39
|
+
from .backends.base import Backend
|
|
40
|
+
from .backends.native import NativeBackend, SelfBackend
|
|
41
|
+
from .backends.probe import probe
|
|
42
|
+
from .backends.cloud import Cloud, CloudBackend, RunPodBackend
|
|
43
|
+
from .backends.remote import SshBackend
|
|
44
|
+
from .hosts import HostError, Hosts, ssh_command
|
|
45
|
+
from .keys import load_secrets
|
|
46
|
+
from .ir import GraphError, compile_graph
|
|
47
|
+
from .manifests import Registry
|
|
48
|
+
from .plan import plan as make_plan
|
|
49
|
+
from .scheduler import Coordinator
|
|
50
|
+
from .store import Store
|
|
51
|
+
|
|
52
|
+
from .release import REPO, home, release
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def backends() -> list[Backend]:
|
|
56
|
+
"""This machine's, then the hosts `perception hosts add` recorded (their places are only ever chosen, never a
|
|
57
|
+
default: they receive a node's inputs)."""
|
|
58
|
+
offered = Cloud(home())
|
|
59
|
+
cloud = [CloudBackend()] if offered.enabled else []
|
|
60
|
+
if offered.runpod: # its key for the driver alone (RunPodBackend.submit)
|
|
61
|
+
cloud.append(RunPodBackend(api_key=load_secrets().get("RUNPOD_API_KEY")))
|
|
62
|
+
return [NativeBackend(), SelfBackend(), *(SshBackend(h) for h in Hosts(home()).load().values()), *cloud]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def registry(args) -> Registry:
|
|
66
|
+
roots = [REPO / "nodes", *map(Path, args.nodes or [])]
|
|
67
|
+
return Registry.load(*[r for r in roots if r.exists()])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def coordinator(args) -> Coordinator:
|
|
71
|
+
return Coordinator(Store(home()), registry(args), backends(), load_secrets())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def only(doc: dict, chosen: list[str]) -> dict:
|
|
75
|
+
"""The graph with just the chosen nodes and the nodes they need (their inputs' $nodes.X, transitively); graph outputs
|
|
76
|
+
of nodes left out go too. The app's stage picker: run part of a graph."""
|
|
77
|
+
nodes = {n.get("id"): n for n in doc.get("nodes", [])}
|
|
78
|
+
keep, todo = set(), [c for item in chosen for c in item.split(",") if c]
|
|
79
|
+
while todo:
|
|
80
|
+
name = todo.pop()
|
|
81
|
+
if name not in nodes:
|
|
82
|
+
raise GraphError([f"--only {name}: the graph has no node {name!r}"])
|
|
83
|
+
if name in keep:
|
|
84
|
+
continue
|
|
85
|
+
keep.add(name)
|
|
86
|
+
for ref in nodes[name].get("inputs", {}).values():
|
|
87
|
+
if isinstance(ref, str) and ref.startswith("$nodes."):
|
|
88
|
+
todo.append(ref.split(".")[1])
|
|
89
|
+
outputs = {k: v for k, v in doc.get("outputs", {}).items() if not (isinstance(v, str) and v.startswith("$nodes.") and v.split(".")[1] not in keep)}
|
|
90
|
+
return {**doc, "nodes": [n for n in doc["nodes"] if n.get("id") in keep], **({"outputs": outputs} if "outputs" in doc else {})}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def graph_path(arg: str) -> Path:
|
|
94
|
+
"""A graph argument: a file, or else one of the pipeline's own graphs by name (`perception graphs` lists them; an
|
|
95
|
+
installed package has no graphs/ folder of the user's to point at)."""
|
|
96
|
+
path = Path(arg)
|
|
97
|
+
named = REPO / "graphs" / arg
|
|
98
|
+
if not path.exists() and path.name == arg and named.is_file():
|
|
99
|
+
return named
|
|
100
|
+
return path
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def graphs() -> list[dict]:
|
|
104
|
+
"""The pipeline's graphs: file name, pipeline name, the comment each starts with, and where it is."""
|
|
105
|
+
out = []
|
|
106
|
+
for path in sorted((REPO / "graphs").glob("*.yaml")):
|
|
107
|
+
lines = path.read_text().splitlines()
|
|
108
|
+
comment = itertools.takewhile(lambda l: l.startswith("#"), lines)
|
|
109
|
+
description = " ".join(c for c in (l.lstrip("#").strip() for l in comment) if c)
|
|
110
|
+
pipeline = next((l.split(":", 1)[1].strip() for l in lines if l.startswith("pipeline:")), "")
|
|
111
|
+
out.append({"file": path.name, "pipeline": pipeline, "description": description, "path": str(path)})
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def cmd_graphs(args):
|
|
116
|
+
if args.json:
|
|
117
|
+
emit("graphs", graphs=graphs())
|
|
118
|
+
else:
|
|
119
|
+
for g in graphs():
|
|
120
|
+
print(f"{g['file']:20s} {g['description']}")
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def read_graph(path: Path, reg: Registry, max_cost: float | None = None, inputs: list[str] | None = None,
|
|
125
|
+
places: list[str] | None = None, chosen: list[str] | None = None, sets: list[str] | None = None):
|
|
126
|
+
doc = yaml.safe_load(path.read_text())
|
|
127
|
+
for item in sets or []: # --set node.key=value: a node's setting for this run (compile checks it against the manifest)
|
|
128
|
+
target, eq, value = item.partition("=")
|
|
129
|
+
name, dot, key = target.partition(".")
|
|
130
|
+
if not (eq and dot and key):
|
|
131
|
+
raise GraphError([f"--set {item}: expected NODE.KEY=VALUE"])
|
|
132
|
+
node = next((n for n in doc.get("nodes", []) if n.get("id") == name), None)
|
|
133
|
+
if node is None:
|
|
134
|
+
raise GraphError([f"--set {name}: the graph has no node {name!r}"])
|
|
135
|
+
node.setdefault("config", {})[key] = yaml.safe_load(value) # 8 -> int, true -> bool, claude-opus-5-5 -> str
|
|
136
|
+
for item in places or []: # --place node=backend:accelerator chooses where a node runs (plan lists the options)
|
|
137
|
+
name, _, place = item.partition("=")
|
|
138
|
+
node = next((n for n in doc.get("nodes", []) if n.get("id") == name), None)
|
|
139
|
+
if node is None:
|
|
140
|
+
raise GraphError([f"--place {name}: the graph has no node {name!r}"])
|
|
141
|
+
node["place"] = place
|
|
142
|
+
if chosen: # after --place and --set: they may name a node --only leaves out (it goes, with them)
|
|
143
|
+
doc = only(doc, chosen)
|
|
144
|
+
for item in inputs or []: # --input name=path overrides a graph input's location
|
|
145
|
+
name, _, value = item.partition("=")
|
|
146
|
+
if name not in doc.get("inputs", {}):
|
|
147
|
+
raise GraphError([f"--input {name}: the graph has no input {name!r}"])
|
|
148
|
+
doc["inputs"][name]["uri"] = Path(value).expanduser().resolve().as_uri()
|
|
149
|
+
if max_cost is not None:
|
|
150
|
+
doc.setdefault("policy", {})["max_cost_usd"] = max_cost
|
|
151
|
+
graph = compile_graph(doc, reg)
|
|
152
|
+
graph.source = path.resolve()
|
|
153
|
+
return graph, doc
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --- machine-readable output (--json): what the annotator app reads when it drives this CLI -------------------------
|
|
157
|
+
|
|
158
|
+
JSON_PROTOCOL = 1 # bump when a field changes meaning or goes away; adding fields is compatible
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def emit(type_: str, /, **fields):
|
|
162
|
+
"""One JSON object per line, flushed at once: the app reads events as they happen. Positional-only, so a field may be
|
|
163
|
+
called `kind` (events have one)."""
|
|
164
|
+
print(json.dumps({"v": JSON_PROTOCOL, "type": type_, **fields}), flush=True)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def iso(t: float | None) -> str | None:
|
|
168
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(t)) if t else None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def graph_error(args, e: GraphError) -> int:
|
|
172
|
+
if args.json:
|
|
173
|
+
emit("error", problems=e.problems)
|
|
174
|
+
else:
|
|
175
|
+
print("invalid graph:\n" + "\n".join(f" - {p}" for p in e.problems))
|
|
176
|
+
return 1
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def plan_json(p) -> dict:
|
|
180
|
+
nodes = [{"id": n.id, "uses": n.uses, "where": n.where, "why_not": n.why_not, "cached": n.cached,
|
|
181
|
+
"downloads": [m.model_dump(include={"id", "source", "size_gb", "gated", "licence_mode"}) for m in n.downloads],
|
|
182
|
+
"missing_secrets": n.missing_secrets, "cost_usd": n.cost_usd, "cost_note": n.cost_note, "uploads": n.uploads, "upload_to": n.upload_to,
|
|
183
|
+
"place": n.place, "options": n.options, "estimate_s": n.estimate_s, "estimate_basis": n.estimate_basis,
|
|
184
|
+
"needs": n.needs, "estimates": n.estimates, "title": n.title, "service": n.service, "description": n.description, "outputs": n.outputs,
|
|
185
|
+
"models": [m.model_dump(include={"id", "source", "licence_mode", "size_gb"}) for m in n.models], "settings": n.settings}
|
|
186
|
+
for n in p.nodes]
|
|
187
|
+
return {"ok": p.ok, "nodes": nodes, "places": p.places, "max_cost_usd": p.max_cost_usd, "estimated_usd": round(sum(n.cost_usd for n in p.nodes if not n.cached), 4)}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# --- commands -----------------------------------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def pipeline_version() -> dict:
|
|
193
|
+
"""This pipeline: its package version (None in a checkout not installed), its commit (the package's, from
|
|
194
|
+
RELEASE.json; None in a checkout) and where its files are."""
|
|
195
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
196
|
+
try:
|
|
197
|
+
v = version("hyperstudy-perception")
|
|
198
|
+
except PackageNotFoundError:
|
|
199
|
+
v = None
|
|
200
|
+
r = release()
|
|
201
|
+
return {"version": v, "commit": r["commit"] if r else None, "root": str(REPO)}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def cmd_doctor(args):
|
|
205
|
+
caps = probe()
|
|
206
|
+
if args.json:
|
|
207
|
+
reg = registry(args)
|
|
208
|
+
coord = Coordinator(Store(home()), reg, backends())
|
|
209
|
+
nodes = []
|
|
210
|
+
for m in reg:
|
|
211
|
+
placement, why = coord.place(m)
|
|
212
|
+
nodes.append({"ref": m.ref, "stage": m.stage or m.id, "description": m.description,
|
|
213
|
+
"where": f"{placement.backend.kind} ({placement.runner.accelerator})" if placement else None,
|
|
214
|
+
"why_not": "" if placement else why})
|
|
215
|
+
emit("doctor", platform=caps.platform, cpu_cores=caps.cpu_cores, memory_gb=caps.memory_gb,
|
|
216
|
+
accelerators=sorted(caps.accelerators), vram_gb=caps.vram_gb, tools=caps.tools, store=str(home()), nodes=nodes,
|
|
217
|
+
pipeline=pipeline_version())
|
|
218
|
+
return 0
|
|
219
|
+
v = pipeline_version()
|
|
220
|
+
print(f"pipeline {v['version'] or 'not installed'}" + (f" ({v['commit'][:7]})" if v["commit"] else " (checkout)") + f", {v['root']}")
|
|
221
|
+
print(f"platform {caps.platform}")
|
|
222
|
+
print(f"cpu {caps.cpu_cores} cores")
|
|
223
|
+
print(f"memory {caps.memory_gb} GB")
|
|
224
|
+
print(f"accelerators {', '.join(sorted(caps.accelerators))}" + (f" ({caps.vram_gb} GB)" if caps.vram_gb else ""))
|
|
225
|
+
for tool, path in caps.tools.items():
|
|
226
|
+
print(f"{tool:12s} {path or 'not found'}")
|
|
227
|
+
print(f"store {home()}")
|
|
228
|
+
reg = registry(args)
|
|
229
|
+
coord = Coordinator(Store(home()), reg, backends())
|
|
230
|
+
print("\nnodes")
|
|
231
|
+
for m in reg:
|
|
232
|
+
placement, why = coord.place(m)
|
|
233
|
+
where = f"{placement.backend.kind} ({placement.runner.accelerator})" if placement else f"cannot run here: {why}"
|
|
234
|
+
print(f" {m.ref:32s} {where}")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def cmd_nodes(args):
|
|
238
|
+
"""Every node the app can offer: what it takes and makes, where it can run here, what it costs and sends, its models."""
|
|
239
|
+
reg = registry(args)
|
|
240
|
+
coord = Coordinator(Store(home()), reg, backends())
|
|
241
|
+
nodes = []
|
|
242
|
+
for m in reg:
|
|
243
|
+
options, why = coord.placements(m)
|
|
244
|
+
nodes.append({
|
|
245
|
+
"ref": m.ref, "id": m.id, "version": m.version, "stage": m.stage or m.id, "description": m.description,
|
|
246
|
+
"inputs": m.inputs, "outputs": m.outputs, "config": m.config, "places": [p.label for p in options], "why_not": why,
|
|
247
|
+
"cost": m.cost.model_dump() if m.cost else None, "uploads": list(m.privacy.external_upload), "secrets": m.secrets,
|
|
248
|
+
"models": [x.model_dump(include={"id", "source", "size_gb", "gated", "licence_mode"}) for x in m.models],
|
|
249
|
+
"gpu": m.resources.gpu})
|
|
250
|
+
if args.json:
|
|
251
|
+
emit("nodes", nodes=nodes)
|
|
252
|
+
else:
|
|
253
|
+
for n in nodes:
|
|
254
|
+
print(f"{n['ref']:32s} {', '.join(n['places']) or 'cannot run here: ' + n['why_not']}")
|
|
255
|
+
return 0
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def cmd_models(args):
|
|
259
|
+
reg = registry(args)
|
|
260
|
+
if args.json:
|
|
261
|
+
emit("models", models=[{"node": m.ref, **x.model_dump(include={"id", "source", "size_gb", "gated", "licence_mode"})}
|
|
262
|
+
for m in reg for x in m.models])
|
|
263
|
+
return 0
|
|
264
|
+
for m in reg:
|
|
265
|
+
for x in m.models:
|
|
266
|
+
size = f"{x.size_gb:.1f} GB" if x.size_gb else "?"
|
|
267
|
+
print(f"{m.ref:30s} {x.id:45s} {x.licence_mode:14s} {size:>8s} {'gated ' if x.gated else ''}{x.source or ''}")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def cmd_validate(args):
|
|
271
|
+
try:
|
|
272
|
+
graph, _ = read_graph(graph_path(args.graph), registry(args))
|
|
273
|
+
except GraphError as e:
|
|
274
|
+
if args.json:
|
|
275
|
+
emit("graph", valid=False, nodes=[], problems=e.problems)
|
|
276
|
+
return 1
|
|
277
|
+
print(f"{args.graph}: invalid")
|
|
278
|
+
for p in e.problems:
|
|
279
|
+
print(f" - {p}")
|
|
280
|
+
return 1
|
|
281
|
+
if args.json:
|
|
282
|
+
emit("graph", valid=True, nodes=list(graph.nodes), problems=[])
|
|
283
|
+
return 0
|
|
284
|
+
print(f"{args.graph}: valid ({len(graph.nodes)} nodes: {' -> '.join(graph.nodes)})")
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def cmd_plan(args):
|
|
289
|
+
reg = registry(args)
|
|
290
|
+
try:
|
|
291
|
+
graph, _ = read_graph(graph_path(args.graph), reg, args.max_cost_usd, args.input, args.place, args.only, args.set)
|
|
292
|
+
except GraphError as e:
|
|
293
|
+
return graph_error(args, e)
|
|
294
|
+
p = make_plan(coordinator(args), graph)
|
|
295
|
+
if args.json:
|
|
296
|
+
emit("plan", **plan_json(p))
|
|
297
|
+
else:
|
|
298
|
+
print(p.render(explain=args.explain))
|
|
299
|
+
return 0 if p.ok else 1
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def last_event(store: Store, run_id: str) -> int:
|
|
303
|
+
return store.db.execute("SELECT COALESCE(MAX(id), 0) FROM events WHERE run_id=?", (run_id,)).fetchone()[0]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _run(coord: Coordinator, run_id: str, as_json: bool = False, after: int = 0) -> int:
|
|
307
|
+
"""Run and stream events; `after` skips the run's earlier history (resume and rerun show only what they do)."""
|
|
308
|
+
if as_json:
|
|
309
|
+
emit("run.created", run=run_id, store=str(coord.store.root))
|
|
310
|
+
else:
|
|
311
|
+
print(f"run {run_id} (store {coord.store.root})")
|
|
312
|
+
seen = after
|
|
313
|
+
|
|
314
|
+
def flush():
|
|
315
|
+
nonlocal seen
|
|
316
|
+
for e in coord.store.events(run_id, seen):
|
|
317
|
+
seen = e["id"]
|
|
318
|
+
if as_json:
|
|
319
|
+
emit("event", run=run_id, node=e["node_id"], kind=e["kind"], message=e["message"] or "", at=iso(e["at"]))
|
|
320
|
+
else:
|
|
321
|
+
print(f" {time.strftime('%H:%M:%S', time.localtime(e['at']))} {e['node_id'] or '':14s} {e['kind']:16s} {e['message'] or ''}")
|
|
322
|
+
|
|
323
|
+
async def watch():
|
|
324
|
+
while True:
|
|
325
|
+
flush()
|
|
326
|
+
await asyncio.sleep(0.3)
|
|
327
|
+
|
|
328
|
+
async def main():
|
|
329
|
+
w = asyncio.create_task(watch())
|
|
330
|
+
status = await coord.run(run_id)
|
|
331
|
+
w.cancel() # flush() below prints what it had not seen yet
|
|
332
|
+
return status
|
|
333
|
+
|
|
334
|
+
status = asyncio.run(main())
|
|
335
|
+
flush() # events written after the watcher's last look
|
|
336
|
+
if as_json:
|
|
337
|
+
emit("run.finished", run=run_id, status=status)
|
|
338
|
+
else:
|
|
339
|
+
print(f"run {run_id}: {status}")
|
|
340
|
+
return 0 if status == "succeeded" else 1
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def cmd_run(args):
|
|
344
|
+
coord = coordinator(args)
|
|
345
|
+
try:
|
|
346
|
+
graph, doc = read_graph(graph_path(args.graph), coord.registry, args.max_cost_usd, args.input, args.place, args.only, args.set)
|
|
347
|
+
except GraphError as e:
|
|
348
|
+
return graph_error(args, e)
|
|
349
|
+
p = make_plan(coord, graph, allow_downloads=args.allow_downloads, allow_uploads=args.allow_uploads)
|
|
350
|
+
if not p.ok:
|
|
351
|
+
if args.json:
|
|
352
|
+
emit("plan", **plan_json(p))
|
|
353
|
+
else:
|
|
354
|
+
print(p.render(explain=True))
|
|
355
|
+
return 1
|
|
356
|
+
return _run(coord, coord.create_run(graph, doc, allow_uploads=args.allow_uploads), args.json)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def cmd_resume(args):
|
|
360
|
+
coord = coordinator(args)
|
|
361
|
+
if args.allow_uploads:
|
|
362
|
+
coord.allow_uploads(args.run_id)
|
|
363
|
+
return _run(coord, args.run_id, args.json, after=last_event(coord.store, args.run_id))
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def cmd_rerun(args):
|
|
367
|
+
coord = coordinator(args)
|
|
368
|
+
if args.allow_uploads:
|
|
369
|
+
coord.allow_uploads(args.run_id)
|
|
370
|
+
after = last_event(coord.store, args.run_id) # before the rerun writes its own run.rerun event
|
|
371
|
+
coord.rerun(args.run_id, args.from_node, use_cache=not args.no_cache)
|
|
372
|
+
return _run(coord, args.run_id, args.json, after=after)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def cmd_cancel(args):
|
|
376
|
+
coord = coordinator(args)
|
|
377
|
+
if not known_run(args, coord.store):
|
|
378
|
+
return 1
|
|
379
|
+
coord.cancel(args.run_id)
|
|
380
|
+
if args.json:
|
|
381
|
+
emit("canceled", run=args.run_id)
|
|
382
|
+
else:
|
|
383
|
+
print(f"run {args.run_id}: canceled")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def known_run(args, store: Store) -> bool:
|
|
387
|
+
"""Whether the run exists; if not, says so (as an error object under --json) for the caller to return 1."""
|
|
388
|
+
if store.db.execute("SELECT 1 FROM runs WHERE id=?", (args.run_id,)).fetchone():
|
|
389
|
+
return True
|
|
390
|
+
if args.json:
|
|
391
|
+
emit("error", problems=[f"no run {args.run_id}"])
|
|
392
|
+
else:
|
|
393
|
+
print(f"no run {args.run_id}")
|
|
394
|
+
return False
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def run_json(store: Store, run) -> dict:
|
|
398
|
+
tasks = [{"node": t["node_id"], "state": t["state"], "attempt": t["attempt"], "runner": t["runner"],
|
|
399
|
+
"started_at": iso(t["started_at"]), "finished_at": iso(t["finished_at"]),
|
|
400
|
+
"seconds": round(t["finished_at"] - t["started_at"], 1) if t["finished_at"] and t["started_at"] else None,
|
|
401
|
+
"cost_usd": t["cost_usd"], "error_code": t["error_code"], "error": t["error"]}
|
|
402
|
+
for t in store.db.execute("SELECT * FROM tasks WHERE run_id=?", (run["id"],))]
|
|
403
|
+
return {"run": run["id"], "pipeline": run["pipeline"], "status": run["status"], "created_at": iso(run["created_at"]),
|
|
404
|
+
"spent_usd": run["spent_usd"], "max_cost_usd": run["max_cost_usd"], "tasks": tasks}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def cmd_status(args):
|
|
408
|
+
store = Store(home())
|
|
409
|
+
if args.json:
|
|
410
|
+
if not args.run_id:
|
|
411
|
+
emit("runs", runs=[{"run": r["id"], "pipeline": r["pipeline"], "status": r["status"], "created_at": iso(r["created_at"])}
|
|
412
|
+
for r in store.db.execute("SELECT * FROM runs ORDER BY created_at DESC LIMIT 20")])
|
|
413
|
+
return 0
|
|
414
|
+
run = store.db.execute("SELECT * FROM runs WHERE id=?", (args.run_id,)).fetchone()
|
|
415
|
+
if not run:
|
|
416
|
+
emit("error", problems=[f"no run {args.run_id}"])
|
|
417
|
+
return 1
|
|
418
|
+
emit("status", **run_json(store, run))
|
|
419
|
+
return 0
|
|
420
|
+
if not args.run_id:
|
|
421
|
+
for r in store.db.execute("SELECT * FROM runs ORDER BY created_at DESC LIMIT 20"):
|
|
422
|
+
print(f"{r['id']} {r['pipeline']:20s} {r['status']:10s} {time.strftime('%Y-%m-%d %H:%M', time.localtime(r['created_at']))}")
|
|
423
|
+
return 0
|
|
424
|
+
run = store.db.execute("SELECT * FROM runs WHERE id=?", (args.run_id,)).fetchone()
|
|
425
|
+
if not run:
|
|
426
|
+
print(f"no run {args.run_id}")
|
|
427
|
+
return 1
|
|
428
|
+
print(f"{run['id']} {run['pipeline']} {run['status']} spent ${run['spent_usd']:.2f} of ${run['max_cost_usd']:.2f}")
|
|
429
|
+
for t in store.db.execute("SELECT * FROM tasks WHERE run_id=?", (args.run_id,)):
|
|
430
|
+
took = f"{t['finished_at'] - t['started_at']:.1f} s" if t["finished_at"] and t["started_at"] else ""
|
|
431
|
+
err = f" {t['error_code']}: {t['error']}" if t["state"] == "failed" else ""
|
|
432
|
+
print(f" {t['node_id']:16s} {t['state']:10s} attempt {t['attempt']} {t['runner'] or '':12s} {took}{err}")
|
|
433
|
+
return 0
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def cmd_logs(args):
|
|
437
|
+
store = Store(home())
|
|
438
|
+
if not known_run(args, store): # --follow would otherwise wait forever for a run that never ends
|
|
439
|
+
return 1
|
|
440
|
+
seen = 0
|
|
441
|
+
while True:
|
|
442
|
+
for e in store.events(args.run_id, seen):
|
|
443
|
+
seen = e["id"]
|
|
444
|
+
if args.node and e["node_id"] != args.node:
|
|
445
|
+
continue
|
|
446
|
+
if args.json:
|
|
447
|
+
emit("event", run=args.run_id, node=e["node_id"], kind=e["kind"], message=e["message"] or "", at=iso(e["at"]))
|
|
448
|
+
else:
|
|
449
|
+
print(f"{time.strftime('%H:%M:%S', time.localtime(e['at']))} {e['node_id'] or '':14s} {e['kind']:16s} {e['message'] or ''}")
|
|
450
|
+
if args.node and not args.json: # the node's own log is text for people; as JSON, logs streams events only
|
|
451
|
+
t = store.db.execute("SELECT attempt FROM tasks WHERE run_id=? AND node_id=?", (args.run_id, args.node)).fetchone()
|
|
452
|
+
if t and t["attempt"]:
|
|
453
|
+
log = store.root / "work" / args.run_id / args.node / str(t["attempt"]) / "log.txt"
|
|
454
|
+
if log.exists() and not args.follow:
|
|
455
|
+
print(f"--- {log}\n{log.read_text()[-20000:]}")
|
|
456
|
+
run = store.db.execute("SELECT status FROM runs WHERE id=?", (args.run_id,)).fetchone()
|
|
457
|
+
if not args.follow or (run and run["status"] != "running"):
|
|
458
|
+
return 0
|
|
459
|
+
time.sleep(0.5)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def cmd_artifacts(args):
|
|
463
|
+
store = Store(home())
|
|
464
|
+
if not known_run(args, store):
|
|
465
|
+
return 1
|
|
466
|
+
if args.json:
|
|
467
|
+
emit("artifacts", run=args.run_id, artifacts=[
|
|
468
|
+
{"node": t["node_id"], "name": name, "type": a["type"], "hash": a["hash"], "path": a["path"]}
|
|
469
|
+
for t in store.db.execute("SELECT node_id, outputs FROM tasks WHERE run_id=?", (args.run_id,))
|
|
470
|
+
for name, a in json.loads(t["outputs"] or "{}").items()])
|
|
471
|
+
return 0
|
|
472
|
+
for t in store.db.execute("SELECT node_id, state, outputs FROM tasks WHERE run_id=?", (args.run_id,)):
|
|
473
|
+
for name, a in json.loads(t["outputs"] or "{}").items():
|
|
474
|
+
print(f"{t['node_id']}.{name:12s} {a['type']:22s} {a['hash'][:12]} {a['path']}")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def cmd_hosts(args):
|
|
478
|
+
"""Lab servers nodes can run on, reached over SSH with your own keys (batch mode: nothing asks for a password)."""
|
|
479
|
+
hosts = Hosts(home())
|
|
480
|
+
try:
|
|
481
|
+
if args.action == "add":
|
|
482
|
+
if not args.dest:
|
|
483
|
+
raise HostError("hosts add NAME USER@HOST")
|
|
484
|
+
hosts.add(args.name, args.dest, args.root)
|
|
485
|
+
elif args.action in ("check", "remove", "clean") and not args.name:
|
|
486
|
+
raise HostError(f"hosts {args.action} NAME")
|
|
487
|
+
elif args.action == "check":
|
|
488
|
+
hosts.check(args.name)
|
|
489
|
+
elif args.action == "remove":
|
|
490
|
+
# a node running there could no longer be stopped (cancel finds its place by the host's name)
|
|
491
|
+
busy = Store(home()).db.execute("SELECT run_id, node_id FROM tasks WHERE state IN ('running', 'leased') AND runner LIKE ?",
|
|
492
|
+
(f"ssh-{args.name}:%",)).fetchone()
|
|
493
|
+
if busy:
|
|
494
|
+
raise HostError(f"run {busy['run_id']} has {busy['node_id']} on {args.name}: cancel or finish it first "
|
|
495
|
+
f"(perception cancel {busy['run_id']})")
|
|
496
|
+
hosts.remove(args.name)
|
|
497
|
+
elif args.action == "clean": # the inputs and code kept on the host between runs (never a running attempt's folder)
|
|
498
|
+
host = hosts.load().get(args.name)
|
|
499
|
+
if host is None:
|
|
500
|
+
raise HostError(f"no host {args.name!r}")
|
|
501
|
+
import shlex
|
|
502
|
+
import subprocess
|
|
503
|
+
q = shlex.quote
|
|
504
|
+
root = q(host.root)
|
|
505
|
+
# not while a node runs there (an attempt's pid that is still that attempt's node); folders a cancel, a lost
|
|
506
|
+
# connection or a stop mark left are removed with the inputs and code. Only perception's own root
|
|
507
|
+
# (hosts.MARKER), and inputs are read-only there (made writable to be removed).
|
|
508
|
+
script = (f'[ -e {root}/.hyperstudy-perception ] || {{ echo "{host.root} is not marked as perception\'s" >&2; exit 4; }}\n'
|
|
509
|
+
f'for f in {root}/work/*/*/*/pid; do [ -f "$f" ] || continue; p=$(cat "$f"); '
|
|
510
|
+
f'if ps -ww -p "$p" -o command= 2>/dev/null | grep -qF -- "${{f%/pid}}/request.json"; then '
|
|
511
|
+
f'echo "a node is running there" >&2; exit 4; fi; done\n'
|
|
512
|
+
f"chmod -R u+w {root}/objects 2>/dev/null; rm -rf {root}/objects {root}/code {root}/work")
|
|
513
|
+
out = subprocess.run([*ssh_command(), host.dest, f"sh -c {q(script)}"], capture_output=True, text=True, timeout=600)
|
|
514
|
+
if out.returncode:
|
|
515
|
+
raise HostError(f"could not clean {host.name}: {out.stderr.strip()[-300:]}")
|
|
516
|
+
except HostError as e:
|
|
517
|
+
if args.json:
|
|
518
|
+
emit("error", problems=[str(e)])
|
|
519
|
+
else:
|
|
520
|
+
print(f"perception hosts: {e}", file=sys.stderr)
|
|
521
|
+
return 1
|
|
522
|
+
listed = [h.to_json() for h in hosts.load().values()]
|
|
523
|
+
if args.json:
|
|
524
|
+
emit("hosts", hosts=listed)
|
|
525
|
+
return 0
|
|
526
|
+
for h in listed:
|
|
527
|
+
c = h["caps"]
|
|
528
|
+
tools = ", ".join(f"{t} {'yes' if p else 'missing'}" for t, p in c["tools"].items())
|
|
529
|
+
print(f"{h['name']:12s} {h['dest']:28s} {c['platform']}, {c['cpu_cores']} cores, {c['memory_gb']} GB, "
|
|
530
|
+
f"{'/'.join(c['accelerators'])}" + (f" ({c['vram_gb']} GB)" if "cuda" in c["accelerators"] else "") + f"; {tools}")
|
|
531
|
+
return 0
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def cmd_cloud(args):
|
|
535
|
+
"""Whether nodes with a CUDA runner are offered a cloud GPU machine (started for the node, deleted after it): on AWS
|
|
536
|
+
(SkyPilot, your AWS credentials) or RunPod (an API key). `on` checks that the provider can be used first."""
|
|
537
|
+
cloud = Cloud(home())
|
|
538
|
+
problem = None
|
|
539
|
+
if args.action == "on":
|
|
540
|
+
problem = runpod_problem() if args.provider == "runpod" else aws_problem()
|
|
541
|
+
if problem is None:
|
|
542
|
+
cloud.set(True, args.provider)
|
|
543
|
+
elif args.action == "off":
|
|
544
|
+
# a node on a cloud machine could no longer be stopped (cancel finds its place by its kind), nor its machine deleted
|
|
545
|
+
kind = "runpod" if args.provider == "runpod" else "cloud"
|
|
546
|
+
busy = Store(home()).db.execute("SELECT run_id, node_id FROM tasks WHERE state IN ('running', 'leased') AND runner LIKE ?",
|
|
547
|
+
(f"{kind}:%",)).fetchone()
|
|
548
|
+
if busy:
|
|
549
|
+
problem = f"run {busy['run_id']} has {busy['node_id']} on a cloud machine: cancel or finish it first (perception cancel {busy['run_id']})"
|
|
550
|
+
else:
|
|
551
|
+
cloud.set(False, args.provider)
|
|
552
|
+
if problem:
|
|
553
|
+
if args.json:
|
|
554
|
+
emit("error", problems=[problem])
|
|
555
|
+
else:
|
|
556
|
+
print(f"perception cloud: {problem}", file=sys.stderr)
|
|
557
|
+
return 1
|
|
558
|
+
aws, rp = CloudBackend(), RunPodBackend()
|
|
559
|
+
if args.json:
|
|
560
|
+
emit("cloud", enabled=cloud.enabled, machine=aws.machine_title, region=aws.region, hourly_usd=aws.hourly_usd,
|
|
561
|
+
runpod={"enabled": cloud.runpod, "machine": rp.machine_title, "region": rp.region, "hourly_usd": rp.hourly_usd})
|
|
562
|
+
else:
|
|
563
|
+
print(f"aws {'on ' if cloud.enabled else 'off'} {aws.machine_title}, {aws.region}, ${aws.hourly_usd}/h")
|
|
564
|
+
print(f"runpod {'on ' if cloud.runpod else 'off'} {rp.machine_title}; {rp.region}, at most ${rp.hourly_usd}/h")
|
|
565
|
+
return 0
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def aws_problem() -> str | None:
|
|
569
|
+
import re
|
|
570
|
+
import shutil
|
|
571
|
+
import subprocess
|
|
572
|
+
sky = os.environ.get("PERCEPTION_SKY") or shutil.which("sky")
|
|
573
|
+
if not sky:
|
|
574
|
+
return "SkyPilot is not installed (uv tool install 'skypilot[aws]')"
|
|
575
|
+
check = subprocess.run([sky, "check", "aws"], capture_output=True, text=True, timeout=300)
|
|
576
|
+
if check.returncode or not re.search(r"\bAWS: enabled", re.sub(r"\x1b\[[0-9;]*m", "", check.stdout)):
|
|
577
|
+
return f"SkyPilot cannot use AWS with your credentials yet (sky check aws): {(check.stdout + check.stderr).strip()[-400:]}"
|
|
578
|
+
return None
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def runpod_problem() -> str | None:
|
|
582
|
+
"""A RunPod API key, which RunPod accepts (listing your pods: nothing is created)."""
|
|
583
|
+
from .runpod import RunPod, RunPodError
|
|
584
|
+
key = load_secrets().get("RUNPOD_API_KEY")
|
|
585
|
+
if not key:
|
|
586
|
+
return ("no RunPod API key: create one at https://console.runpod.io/user/settings and put RUNPOD_API_KEY=... in "
|
|
587
|
+
"~/.config/hyperstudy/runpod.env (chmod 600)")
|
|
588
|
+
try:
|
|
589
|
+
RunPod(key).pods()
|
|
590
|
+
except RunPodError as e:
|
|
591
|
+
return f"RunPod did not accept the key in RUNPOD_API_KEY ({e})" if e.status in (401, 403) else f"RunPod could not be checked: {e}"
|
|
592
|
+
return None
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def cmd_keys(args):
|
|
596
|
+
"""Your keys for nodes and cloud places (keys.py). `set` reads the value from the terminal (not echoed) or from stdin,
|
|
597
|
+
never from the command line (shell history, the process list); `list` gives names and where each comes from."""
|
|
598
|
+
import getpass
|
|
599
|
+
from .keys import Keys
|
|
600
|
+
keys = Keys()
|
|
601
|
+
try:
|
|
602
|
+
if args.action == "set":
|
|
603
|
+
if not args.name:
|
|
604
|
+
raise ValueError("keys set NAME: which key?")
|
|
605
|
+
value = getpass.getpass(f"{args.name}: ") if sys.stdin.isatty() else sys.stdin.readline()
|
|
606
|
+
where = keys.set(args.name, value)
|
|
607
|
+
result = {"name": args.name, "stored": where}
|
|
608
|
+
elif args.action == "remove":
|
|
609
|
+
if not args.name:
|
|
610
|
+
raise ValueError("keys remove NAME: which key?")
|
|
611
|
+
result = {"name": args.name, "removed": keys.remove(args.name)}
|
|
612
|
+
else:
|
|
613
|
+
found = keys.load()
|
|
614
|
+
result = {"keys": [{"name": k, "source": found.sources[k]} for k in sorted(found.values)], "problems": found.problems}
|
|
615
|
+
except ValueError as e:
|
|
616
|
+
if args.json:
|
|
617
|
+
emit("error", problems=[str(e)])
|
|
618
|
+
else:
|
|
619
|
+
print(f"perception keys: {e}", file=sys.stderr)
|
|
620
|
+
return 1
|
|
621
|
+
if args.json:
|
|
622
|
+
emit("keys", **result)
|
|
623
|
+
elif args.action == "set":
|
|
624
|
+
print(f"{args.name} stored in {result['stored']}")
|
|
625
|
+
elif args.action == "remove":
|
|
626
|
+
print(f"{args.name} removed from {', '.join(result['removed'])}" if result["removed"] else f"{args.name} was not stored by perception keys")
|
|
627
|
+
else:
|
|
628
|
+
for k in result["keys"]:
|
|
629
|
+
print(f"{k['name']:28s} {k['source']}")
|
|
630
|
+
for p in result["problems"]:
|
|
631
|
+
print(f"refused: {p}")
|
|
632
|
+
return 0
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def cmd_setup(args):
|
|
636
|
+
"""Everything the nodes need here, ready before a run (setup.py): each environment synced from its lock, each node's
|
|
637
|
+
weights fetched. With a graph, the nodes it would run here (--only, --place as for run); without,
|
|
638
|
+
every node that can run here. A node placed on another machine is set up there when it runs."""
|
|
639
|
+
from . import setup
|
|
640
|
+
reg = registry(args)
|
|
641
|
+
coord = coordinator(args)
|
|
642
|
+
if args.graph:
|
|
643
|
+
try:
|
|
644
|
+
graph, _ = read_graph(graph_path(args.graph), reg, places=args.place, chosen=args.only)
|
|
645
|
+
except GraphError as e:
|
|
646
|
+
return graph_error(args, e)
|
|
647
|
+
chosen = [(n.manifest, *coord.place(n.manifest, n.spec.backend, n.spec.place, graph.spec.policy), n.config)
|
|
648
|
+
for n in graph.nodes.values()]
|
|
649
|
+
else: # every node: those that cannot run here are simply not this machine's to set up
|
|
650
|
+
chosen = [(m, coord.place(m)[0], "", None) for m in reg]
|
|
651
|
+
result = setup.Setup()
|
|
652
|
+
|
|
653
|
+
def report(step):
|
|
654
|
+
result.steps.append(step)
|
|
655
|
+
if args.json:
|
|
656
|
+
emit("setup.step", node=step.node, what=step.what, ok=step.ok, seconds=step.seconds, message=step.message)
|
|
657
|
+
else:
|
|
658
|
+
print(f" {step.what:12s} {step.node or 'this machine':32s} {'ok' if step.ok else 'FAILED':6s} {step.seconds:6.1f} s {step.message}")
|
|
659
|
+
|
|
660
|
+
for m, placement, why, config in chosen:
|
|
661
|
+
if m.service:
|
|
662
|
+
continue # runs on its API's machines: nothing to set up here
|
|
663
|
+
if placement is None:
|
|
664
|
+
if args.graph: # the graph would not run either: say so, not ready
|
|
665
|
+
report(setup.Step(m.ref, "place", False, 0.0, f"cannot run here: {why}"))
|
|
666
|
+
continue
|
|
667
|
+
if placement.backend.remote:
|
|
668
|
+
if not args.json:
|
|
669
|
+
print(f" {'':12s} {m.ref:32s} runs on {placement.backend.title}: set up there when it runs")
|
|
670
|
+
continue
|
|
671
|
+
for step in setup.setup_node(m, placement.runner, config=config, secrets=coord.secrets):
|
|
672
|
+
report(step)
|
|
673
|
+
if args.json:
|
|
674
|
+
emit("setup", ok=result.ok, steps=[s.__dict__ for s in result.steps])
|
|
675
|
+
else:
|
|
676
|
+
print("ready" if result.ok else "not ready: see the steps marked FAILED")
|
|
677
|
+
return 0 if result.ok else 1
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def cmd_export(args):
|
|
681
|
+
coord = coordinator(args)
|
|
682
|
+
out = export_mod.bundle(coord, args.run_id, Path(args.output))
|
|
683
|
+
if args.json:
|
|
684
|
+
emit("exported", run=args.run_id, path=str(out))
|
|
685
|
+
else:
|
|
686
|
+
print(f"wrote {out}")
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def main(argv: list[str] | None = None) -> int:
|
|
690
|
+
ap = argparse.ArgumentParser(prog="perception", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
691
|
+
ap.add_argument("--nodes", action="append", help="another directory of node manifests")
|
|
692
|
+
ap.add_argument("--json", action="store_true",
|
|
693
|
+
help="machine-readable output: one JSON object per line, each with a protocol version (for the annotator app)")
|
|
694
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
695
|
+
sub.add_parser("doctor").set_defaults(fn=cmd_doctor)
|
|
696
|
+
sub.add_parser("graphs", help="the pipeline's graphs, which run and plan take by name").set_defaults(fn=cmd_graphs)
|
|
697
|
+
sub.add_parser("nodes", help="every node, with where it can run here").set_defaults(fn=cmd_nodes)
|
|
698
|
+
models = sub.add_parser("models")
|
|
699
|
+
models.add_argument("action", choices=["list"])
|
|
700
|
+
models.set_defaults(fn=cmd_models)
|
|
701
|
+
g = sub.add_parser("graph")
|
|
702
|
+
g.add_argument("action", choices=["validate"])
|
|
703
|
+
g.add_argument("graph")
|
|
704
|
+
g.set_defaults(fn=cmd_validate)
|
|
705
|
+
p = sub.add_parser("plan")
|
|
706
|
+
p.add_argument("graph")
|
|
707
|
+
p.add_argument("--explain", action="store_true")
|
|
708
|
+
p.add_argument("--estimate-cost", action="store_true", help="(always shown)")
|
|
709
|
+
p.add_argument("--max-cost-usd", type=float)
|
|
710
|
+
p.add_argument("--input", action="append", metavar="NAME=PATH", help="set a graph input's file")
|
|
711
|
+
p.add_argument("--place", action="append", metavar="NODE=BACKEND:ACCELERATOR", help="where a node runs (plan lists its options)")
|
|
712
|
+
p.add_argument("--only", action="append", metavar="NODE[,NODE]", help="just these nodes and what they need")
|
|
713
|
+
p.add_argument("--set", action="append", metavar="NODE.KEY=VALUE", help="a node's setting (plan lists the choices)")
|
|
714
|
+
p.set_defaults(fn=cmd_plan)
|
|
715
|
+
r = sub.add_parser("run")
|
|
716
|
+
r.add_argument("graph")
|
|
717
|
+
r.add_argument("--max-cost-usd", type=float)
|
|
718
|
+
r.add_argument("--input", action="append", metavar="NAME=PATH", help="set a graph input's file")
|
|
719
|
+
r.add_argument("--allow-downloads", action="store_true", help="let nodes fetch model weights that are not on this machine yet")
|
|
720
|
+
r.add_argument("--place", action="append", metavar="NODE=BACKEND:ACCELERATOR", help="where a node runs (plan lists its options)")
|
|
721
|
+
r.add_argument("--only", action="append", metavar="NODE[,NODE]", help="just these nodes and what they need")
|
|
722
|
+
r.add_argument("--set", action="append", metavar="NODE.KEY=VALUE", help="a node's setting (plan lists the choices)")
|
|
723
|
+
r.add_argument("--allow-uploads", action="store_true", help="let nodes send what the plan says off this machine (within policy.allow_upload)")
|
|
724
|
+
r.set_defaults(fn=cmd_run)
|
|
725
|
+
for name, fn in (("resume", cmd_resume), ("cancel", cmd_cancel), ("artifacts", cmd_artifacts)):
|
|
726
|
+
x = sub.add_parser(name)
|
|
727
|
+
x.add_argument("run_id")
|
|
728
|
+
if name == "resume":
|
|
729
|
+
x.add_argument("--allow-uploads", action="store_true", help="let the run's nodes send data off this machine from now on")
|
|
730
|
+
x.set_defaults(fn=fn)
|
|
731
|
+
s = sub.add_parser("status")
|
|
732
|
+
s.add_argument("run_id", nargs="?")
|
|
733
|
+
s.set_defaults(fn=cmd_status)
|
|
734
|
+
lg = sub.add_parser("logs")
|
|
735
|
+
lg.add_argument("run_id")
|
|
736
|
+
lg.add_argument("--follow", action="store_true")
|
|
737
|
+
lg.add_argument("--node")
|
|
738
|
+
lg.set_defaults(fn=cmd_logs)
|
|
739
|
+
rr = sub.add_parser("rerun")
|
|
740
|
+
rr.add_argument("run_id")
|
|
741
|
+
rr.add_argument("--from", dest="from_node", required=True)
|
|
742
|
+
rr.add_argument("--no-cache", action="store_true", help="run the nodes even if the cache has their results")
|
|
743
|
+
rr.add_argument("--allow-uploads", action="store_true", help="let the run's nodes send data off this machine from now on")
|
|
744
|
+
rr.set_defaults(fn=cmd_rerun)
|
|
745
|
+
h = sub.add_parser("hosts", help="lab servers nodes can run on, over SSH")
|
|
746
|
+
h.add_argument("action", choices=["add", "list", "check", "remove", "clean"])
|
|
747
|
+
h.add_argument("name", nargs="?")
|
|
748
|
+
h.add_argument("dest", nargs="?", help="add: an ssh destination (user@host, or a ~/.ssh/config alias)")
|
|
749
|
+
h.add_argument("--root", default=".cache/hyperstudy-perception", help="add: where on the host its files go (under its home if relative)")
|
|
750
|
+
h.set_defaults(fn=cmd_hosts)
|
|
751
|
+
c = sub.add_parser("cloud", help="offer a cloud GPU machine to nodes that can use one")
|
|
752
|
+
c.add_argument("action", choices=["on", "off", "status"])
|
|
753
|
+
c.add_argument("provider", nargs="?", choices=Cloud.providers, default="aws")
|
|
754
|
+
c.set_defaults(fn=cmd_cloud)
|
|
755
|
+
k = sub.add_parser("keys", help="your API keys and tokens: store one in the keychain, list where each comes from")
|
|
756
|
+
k.add_argument("action", choices=["set", "list", "remove"])
|
|
757
|
+
k.add_argument("name", nargs="?")
|
|
758
|
+
k.set_defaults(fn=cmd_keys)
|
|
759
|
+
st = sub.add_parser("setup", help="install what nodes need here before a run: environments and weights")
|
|
760
|
+
st.add_argument("graph", nargs="?")
|
|
761
|
+
st.add_argument("--only", action="append", metavar="NODE[,NODE]", help="just these nodes and what they need")
|
|
762
|
+
st.add_argument("--place", action="append", metavar="NODE=BACKEND:ACCELERATOR", help="where a node runs (as for run)")
|
|
763
|
+
st.set_defaults(fn=cmd_setup)
|
|
764
|
+
ex = sub.add_parser("export")
|
|
765
|
+
ex.add_argument("run_id")
|
|
766
|
+
ex.add_argument("--format", choices=["bundle"], default="bundle")
|
|
767
|
+
ex.add_argument("--output", required=True)
|
|
768
|
+
ex.set_defaults(fn=cmd_export)
|
|
769
|
+
args = ap.parse_args(argv)
|
|
770
|
+
if not args.json:
|
|
771
|
+
return args.fn(args) or 0
|
|
772
|
+
try:
|
|
773
|
+
return args.fn(args) or 0
|
|
774
|
+
except Exception as e: # the app reads one error object, not a traceback meant for people
|
|
775
|
+
emit("error", problems=[f"{type(e).__name__}: {e}"])
|
|
776
|
+
return 1
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
if __name__ == "__main__":
|
|
780
|
+
sys.exit(main())
|