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.
Files changed (82) hide show
  1. hyperstudy_perception-0.1.0.dist-info/METADATA +53 -0
  2. hyperstudy_perception-0.1.0.dist-info/RECORD +82 -0
  3. hyperstudy_perception-0.1.0.dist-info/WHEEL +4 -0
  4. hyperstudy_perception-0.1.0.dist-info/entry_points.txt +2 -0
  5. hyperstudy_perception-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. perception/__init__.py +0 -0
  7. perception/_pipeline/RELEASE.json +59 -0
  8. perception/_pipeline/cloud/runpod/guard.sh +37 -0
  9. perception/_pipeline/cloud/runpod/image/Dockerfile +39 -0
  10. perception/_pipeline/cloud/runpod/image/start.sh +11 -0
  11. perception/_pipeline/cloud/runpod/image/tag.py +40 -0
  12. perception/_pipeline/cloud/skypilot/gpu-faces.yaml +39 -0
  13. perception/_pipeline/cloud/skypilot/gpu-tune.yaml +55 -0
  14. perception/_pipeline/cloud/skypilot/guard.sh +20 -0
  15. perception/_pipeline/cloud/skypilot/iam-perception-node.json +18 -0
  16. perception/_pipeline/cloud/skypilot/launch.sh +63 -0
  17. perception/_pipeline/cloud/skypilot/node-gpu.yaml +25 -0
  18. perception/_pipeline/cloud/skypilot/node-setup.sh +22 -0
  19. perception/_pipeline/cloud/skypilot/sky.yaml +7 -0
  20. perception/_pipeline/cloud/skypilot/smoke.yaml +28 -0
  21. perception/_pipeline/envs/api/pyproject.toml +12 -0
  22. perception/_pipeline/envs/api/uv.lock +276 -0
  23. perception/_pipeline/envs/audio/pyproject.toml +12 -0
  24. perception/_pipeline/envs/audio/uv.lock +575 -0
  25. perception/_pipeline/envs/core/pyproject.toml +12 -0
  26. perception/_pipeline/envs/core/uv.lock +157 -0
  27. perception/_pipeline/envs/faces/pyproject.toml +12 -0
  28. perception/_pipeline/envs/faces/uv.lock +1180 -0
  29. perception/_pipeline/graphs/decode.yaml +11 -0
  30. perception/_pipeline/graphs/faces.yaml +14 -0
  31. perception/_pipeline/graphs/full.yaml +31 -0
  32. perception/_pipeline/graphs/mvp-captions.yaml +27 -0
  33. perception/_pipeline/graphs/mvp.yaml +17 -0
  34. perception/_pipeline/nodes/audio/asr/manifest.yaml +20 -0
  35. perception/_pipeline/nodes/audio/asr/node.py +74 -0
  36. perception/_pipeline/nodes/audio/asr/prepare.py +12 -0
  37. perception/_pipeline/nodes/audio/asr/test_wav.py +38 -0
  38. perception/_pipeline/nodes/audio/asr/wav.py +19 -0
  39. perception/_pipeline/nodes/conftest.py +64 -0
  40. perception/_pipeline/nodes/core/decode/manifest.yaml +19 -0
  41. perception/_pipeline/nodes/core/decode/media.py +104 -0
  42. perception/_pipeline/nodes/core/decode/node.py +56 -0
  43. perception/_pipeline/nodes/core/decode/test_media.py +65 -0
  44. perception/_pipeline/nodes/testdata/README.md +10 -0
  45. perception/_pipeline/nodes/testdata/rotated-90.mp4 +0 -0
  46. perception/_pipeline/nodes/vision/face-track/frames.py +88 -0
  47. perception/_pipeline/nodes/vision/face-track/loader.py +18 -0
  48. perception/_pipeline/nodes/vision/face-track/manifest.yaml +30 -0
  49. perception/_pipeline/nodes/vision/face-track/node.py +117 -0
  50. perception/_pipeline/nodes/vision/face-track/prepare.py +8 -0
  51. perception/_pipeline/nodes/vision/face-track/test_frames.py +46 -0
  52. perception/_pipeline/nodes/vision/face-track/test_loader.py +16 -0
  53. perception/_pipeline/nodes/vision/face-track/test_thumbnails.py +66 -0
  54. perception/_pipeline/nodes/vision/face-track/thumbnails.py +68 -0
  55. perception/_pipeline/nodes/vision/face-track/tracking.py +150 -0
  56. perception/_pipeline/nodes/vlm/shot-captions/manifest.yaml +29 -0
  57. perception/_pipeline/nodes/vlm/shot-captions/node.py +185 -0
  58. perception/_pipeline/schema/README.md +54 -0
  59. perception/_pipeline/schema/bundle-0.1.schema.json +371 -0
  60. perception/_pipeline/sdk/pyproject.toml +13 -0
  61. perception/_pipeline/sdk/src/perception_node/__init__.py +157 -0
  62. perception/artifacts.py +180 -0
  63. perception/backends/__init__.py +0 -0
  64. perception/backends/base.py +179 -0
  65. perception/backends/cloud.py +214 -0
  66. perception/backends/native.py +140 -0
  67. perception/backends/probe.py +44 -0
  68. perception/backends/remote.py +140 -0
  69. perception/cli.py +780 -0
  70. perception/estimates.py +68 -0
  71. perception/export.py +100 -0
  72. perception/hosts.py +201 -0
  73. perception/ir.py +245 -0
  74. perception/keys.py +259 -0
  75. perception/manifests.py +169 -0
  76. perception/plan.py +170 -0
  77. perception/release.py +61 -0
  78. perception/remote_driver.py +990 -0
  79. perception/runpod.py +100 -0
  80. perception/scheduler.py +552 -0
  81. perception/setup.py +72 -0
  82. 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())