kiteml-cli 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.
@@ -0,0 +1,356 @@
1
+ """`kite rl` — train robot policies in simulation via the public /v1/rl_runs API: a task spec in, one run per seed,
2
+ and a bundle out (the policy as ONNX and a checkpoint, the MuJoCo scene with a script that plays it, clips, and a
3
+ report with a verdict).
4
+
5
+ kite rl catalog robots, objectives with every parameter, hardware, budgets
6
+ kite rl plan open_duck_mini_v2 "walk forward briskly" -o walk.json English -> SPEC.json; creates nothing
7
+ kite rl validate walk.json free: what each reward term pays canned policies, and fixes
8
+ kite rl estimate walk.json minutes and tokens
9
+ kite rl train walk.json [--seeds 3] [--budget probe] [--wait] one run per seed (rlr_...)
10
+ kite rl list [--limit 20] [--after rlr_...] [--group-id rlg_...]
11
+ kite rl status rlr_... [--wait]
12
+ kite rl metrics rlr_... the training curve, every 5 iterations
13
+ kite rl report rlr_... the verdict, its checks and metrics
14
+ kite rl files rlr_... every file in the bundle (path, bytes, sha256)
15
+ kite rl download rlr_... [-o DIR] the bundle, unpacked to DIR/kiteml_<run_id>/
16
+ kite rl checkpoints rlr_... the iterations it saved
17
+ kite rl checkpoint rlr_... 1200 [--format onnx|pt] [-o FILE]
18
+ kite rl cancel rlr_... a run that trained keeps (and packages) its policy
19
+ kite rl fork rlr_... [--set sigma_lin=0.02 ...] [--seeds N] its weights and schedule, new reward parameters
20
+ kite rl continue rlr_... [--iterations N] the same, also keeping its optimizer
21
+
22
+ SPEC.json is a POST /v1/rl_runs body, {"robot", "task", "budget", ...}; `kite rl plan -o` writes one.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import hashlib
28
+ import json
29
+ import re
30
+ import uuid
31
+ import zipfile
32
+ from pathlib import Path, PurePosixPath
33
+ from typing import List, Optional
34
+
35
+ import httpx
36
+ import typer
37
+
38
+ from kite_cli.client import KiteAPIError, KiteClient, post_create
39
+ from kite_cli.output import print_error, print_json
40
+ from kite_cli.progress import wait_for_completion
41
+
42
+ app = typer.Typer(no_args_is_help=True, help="RL training in simulation: a task spec -> a trained, verified policy")
43
+
44
+ _TERMINAL = {"succeeded", "failed", "canceled"}
45
+ _POLL_S = 30.0
46
+ _SLOW_S = 180.0 # plan asks a model, validate rolls policies through the spec, create starts one job per seed
47
+ _SPEC = typer.Argument(..., help="A create body (JSON), e.g. from `kite rl plan -o`")
48
+ _RUN_ID = typer.Argument(..., help="Run id (rlr_...)")
49
+ _KEY = typer.Option(None, "--idempotency-key", help="Safe retries: same key + body -> the same runs "
50
+ "(default: a generated key)")
51
+
52
+
53
+ @app.command()
54
+ def catalog():
55
+ """Robots (with their verified command bands), objectives with every parameter's default and bounds, hardware
56
+ tiers and budget presets."""
57
+ try:
58
+ print_json(KiteClient().get("/v1/rl_runs/catalog"))
59
+ except KiteAPIError as e:
60
+ print_error(e.message, code=e.code, details=e.details)
61
+
62
+
63
+ @app.command()
64
+ def plan(
65
+ robot: str = typer.Argument(..., help="Robot id, e.g. open_duck_mini_v2 (see `kite rl catalog`)"),
66
+ prompt: str = typer.Argument(..., help="The behavior, in plain words"),
67
+ out: Optional[Path] = typer.Option(None, "-o", "--out", help="Write the plan as a create body (SPEC.json)"),
68
+ ):
69
+ """A task spec from a plain-English description, with the reasoning behind it. Creates nothing."""
70
+ try:
71
+ planned = KiteClient(timeout=_SLOW_S).post("/v1/rl_runs/plan", json_data={"robot": robot, "prompt": prompt})
72
+ if out is None:
73
+ print_json(planned)
74
+ return
75
+ out.write_text(json.dumps({k: planned[k] for k in ("robot", "task", "budget")}, indent=2) + "\n")
76
+ print_json({"ok": True, "data": planned, "spec": str(out.resolve())})
77
+ except KiteAPIError as e:
78
+ print_error(e.message, code=e.code, details=e.details)
79
+
80
+
81
+ @app.command()
82
+ def validate(spec: Path = _SPEC):
83
+ """Free, in seconds: the spec compiled and rolled with canned policies (zero action, random, a verified policy
84
+ for this robot), what each reward term pays them, and warnings for rewards that pay for the wrong thing, each
85
+ with a `patch` for the spec."""
86
+ body = _read(spec)
87
+ try:
88
+ print_json(KiteClient(timeout=_SLOW_S).post("/v1/rl_runs/validate", json_data=body))
89
+ except KiteAPIError as e:
90
+ print_error(e.message, code=e.code, details=e.details)
91
+
92
+
93
+ @app.command()
94
+ def estimate(spec: Path = _SPEC):
95
+ """Minutes and tokens per run; tokens are reserved at train and what training does not use is refunded."""
96
+ body = _read(spec)
97
+ try:
98
+ print_json(KiteClient().post("/v1/rl_runs/estimate", json_data=body))
99
+ except KiteAPIError as e:
100
+ print_error(e.message, code=e.code, details=e.details)
101
+
102
+
103
+ @app.command()
104
+ def train(
105
+ spec: Path = _SPEC,
106
+ seeds: Optional[int] = typer.Option(None, "--seeds", help="Runs to start, one per seed (1-5)"),
107
+ budget: Optional[str] = typer.Option(
108
+ None, "--budget", help="probe (300 iterations, a first signal in minutes) or standard; replaces the "
109
+ "spec's budget.iterations"
110
+ ),
111
+ wait: bool = typer.Option(False, "--wait", help="Poll every run until succeeded | failed | canceled"),
112
+ idempotency_key: Optional[str] = _KEY,
113
+ ):
114
+ """Train a policy: one queued run per seed, sharing a group_id. Tokens are reserved now and settled by the minute
115
+ when each run ends."""
116
+ body = _read(spec)
117
+ if seeds is not None:
118
+ body["seeds"] = seeds
119
+ if budget: # iterations would override the preset
120
+ body["budget"] = {**{k: v for k, v in (body.get("budget") or {}).items() if k != "iterations"}, "preset": budget}
121
+ _start(body, idempotency_key, wait)
122
+
123
+
124
+ @app.command("list")
125
+ def list_runs(
126
+ limit: int = typer.Option(20, "--limit", help="Max rows (1-100)"),
127
+ after: Optional[str] = typer.Option(None, "--after", help="Cursor: the previous page's next_cursor"),
128
+ group_id: Optional[str] = typer.Option(None, "--group-id", help="Only the runs one train started (rlg_...)"),
129
+ ):
130
+ """List your runs (newest first, last status seen; `status` has live progress)."""
131
+ params = {k: v for k, v in (("limit", limit), ("after", after), ("group_id", group_id)) if v}
132
+ try:
133
+ print_json(KiteClient().get("/v1/rl_runs", params=params))
134
+ except KiteAPIError as e:
135
+ print_error(e.message, code=e.code, details=e.details)
136
+
137
+
138
+ @app.command()
139
+ def status(
140
+ run_id: str = _RUN_ID,
141
+ wait: bool = typer.Option(False, "--wait", help="Poll until succeeded | failed | canceled"),
142
+ ):
143
+ """A run's live status, phase, progress and metrics; once packaged, its verdict and `output`."""
144
+ try:
145
+ client = KiteClient()
146
+ path = f"/v1/rl_runs/{run_id}"
147
+ if wait:
148
+ print_json(wait_for_completion(client, path, terminal_states=_TERMINAL, poll_interval=_POLL_S))
149
+ else:
150
+ print_json(client.get(path))
151
+ except KiteAPIError as e:
152
+ print_error(e.message, code=e.code, details=e.details)
153
+
154
+
155
+ @app.command()
156
+ def metrics(run_id: str = _RUN_ID):
157
+ """The training curve, every 5 iterations: mean reward per step, the same by reward term, episode length and
158
+ throughput."""
159
+ try:
160
+ print_json(KiteClient().get(f"/v1/rl_runs/{run_id}/metrics"))
161
+ except KiteAPIError as e:
162
+ print_error(e.message, code=e.code, details=e.details)
163
+
164
+
165
+ @app.command()
166
+ def report(run_id: str = _RUN_ID):
167
+ """The verdict and how it was reached: each check with its value and limit, the metrics, the judge's read of the
168
+ clip, and the checkpoint chosen. run_not_ready until the run is packaged."""
169
+ try:
170
+ print_json(KiteClient().get(f"/v1/rl_runs/{run_id}/report"))
171
+ except KiteAPIError as e:
172
+ print_error(e.message, code=e.code, details=e.details)
173
+
174
+
175
+ @app.command()
176
+ def files(run_id: str = _RUN_ID):
177
+ """Every file in a packaged run's bundle (path, bytes, sha256), sorted by path, following the cursor."""
178
+ path, params = f"/v1/rl_runs/{run_id}/files", {"limit": 100}
179
+ try:
180
+ client = KiteClient()
181
+ data: list = []
182
+ while True:
183
+ page = client.get(path, params=params)
184
+ data.extend(page.get("data") or [])
185
+ if not page.get("has_more") or not page.get("next_cursor"):
186
+ break
187
+ params = {"limit": 100, "after": page["next_cursor"]}
188
+ print_json({"object": "list", "data": data, "has_more": False, "next_cursor": None})
189
+ except KiteAPIError as e:
190
+ print_error(e.message, code=e.code, details=e.details)
191
+
192
+
193
+ @app.command()
194
+ def download(
195
+ run_id: str = _RUN_ID,
196
+ out: Path = typer.Option(Path("."), "-o", "--out", help="Directory; the bundle unpacks to <out>/kiteml_<run_id>/"),
197
+ ):
198
+ """Download a packaged run's bundle and unpack it: <out>/kiteml_<run_id>/ with media/, policy/, mujoco/ and
199
+ training/."""
200
+ try:
201
+ print_json({"id": run_id, "path": str(_unzip(KiteClient(timeout=120), run_id, out.expanduser().resolve()))})
202
+ except KiteAPIError as e:
203
+ print_error(e.message, code=e.code, details=e.details)
204
+
205
+
206
+ @app.command()
207
+ def checkpoints(run_id: str = _RUN_ID):
208
+ """The iterations the run saved (about every tenth of its budget, and the last)."""
209
+ try:
210
+ print_json(KiteClient().get(f"/v1/rl_runs/{run_id}/checkpoints"))
211
+ except KiteAPIError as e:
212
+ print_error(e.message, code=e.code, details=e.details)
213
+
214
+
215
+ @app.command()
216
+ def checkpoint(
217
+ run_id: str = _RUN_ID,
218
+ iteration: int = typer.Argument(..., help="A saved iteration (see `kite rl checkpoints`)"),
219
+ fmt: str = typer.Option("onnx", "--format", help="onnx (the deployable policy: raw observation in, action out) "
220
+ "or pt (the training checkpoint)"),
221
+ out: Optional[Path] = typer.Option(None, "-o", "--out", help="File to write (default ./<run_id>_<iteration>.<format>)"),
222
+ ):
223
+ """Download one saved iteration of a run's policy."""
224
+ dest = (out or Path(f"{run_id}_{iteration}.{fmt}")).expanduser().resolve()
225
+ try:
226
+ KiteClient(timeout=120).download(f"/v1/rl_runs/{run_id}/checkpoints/{iteration}/download?format={fmt}", str(dest))
227
+ print_json({"id": run_id, "iteration": iteration, "format": fmt, "path": str(dest)})
228
+ except KiteAPIError as e:
229
+ print_error(e.message, code=e.code, details=e.details)
230
+
231
+
232
+ @app.command()
233
+ def cancel(run_id: str = _RUN_ID):
234
+ """Stop a run. One that has started training keeps its policy so far: it reads canceling while that is packaged,
235
+ then canceled with its outputs, charged for the minutes it trained. One that never started, or left nothing to
236
+ package, is refunded in full."""
237
+ try:
238
+ print_json(KiteClient().post(f"/v1/rl_runs/{run_id}/cancel"))
239
+ except KiteAPIError as e:
240
+ print_error(e.message, code=e.code, details=e.details)
241
+
242
+
243
+ @app.command()
244
+ def fork(
245
+ run_id: str = _RUN_ID,
246
+ overrides: List[str] = typer.Option([], "--set", help="key=value: a reward or randomization parameter "
247
+ "(repeatable; `kite rl catalog` lists them)"),
248
+ seeds: Optional[int] = typer.Option(None, "--seeds", help="Runs to start, one per seed (1-5)"),
249
+ idempotency_key: Optional[str] = _KEY,
250
+ ):
251
+ """Train from a run's weights: its robot, task and budget, with the parameters you --set."""
252
+ params = _params(overrides)
253
+ try:
254
+ body = from_run(KiteClient().get(f"/v1/rl_runs/{run_id}"), "fork", params=params)
255
+ except KiteAPIError as e:
256
+ print_error(e.message, code=e.code, details=e.details)
257
+ return
258
+ if seeds is not None:
259
+ body["seeds"] = seeds
260
+ _start(body, idempotency_key)
261
+
262
+
263
+ @app.command("continue")
264
+ def continue_run(
265
+ run_id: str = _RUN_ID,
266
+ iterations: Optional[int] = typer.Option(None, "--iterations", help="Iterations to train this time "
267
+ "(default: the run's own budget)"),
268
+ idempotency_key: Optional[str] = _KEY,
269
+ ):
270
+ """Keep training a run: its weights, schedule and optimizer, with its robot, task and budget."""
271
+ try:
272
+ body = from_run(KiteClient().get(f"/v1/rl_runs/{run_id}"), "continue", iterations=iterations)
273
+ except KiteAPIError as e:
274
+ print_error(e.message, code=e.code, details=e.details)
275
+ return
276
+ _start(body, idempotency_key)
277
+
278
+
279
+ def from_run(run: dict, mode: str, params: Optional[dict] = None, iterations: Optional[int] = None) -> dict:
280
+ """A create body that starts from `run` (GET /v1/rl_runs/{id}): its robot, task, budget and description (what
281
+ the report's judge compares the clip against), with `params` overriding task.params and `iterations` the
282
+ budget's. Both resume its training schedule from its weights; `continue` also keeps its optimizer. Shared
283
+ with the MCP tool kite_rl_fork."""
284
+ task = {**run["task"], "params": {**(run["task"].get("params") or {}), **(params or {})}}
285
+ budget = {**(run.get("budget") or {}), **({"iterations": iterations} if iterations else {})}
286
+ body = {"robot": run["robot"], "task": task, "budget": budget, "from": {"run": run["id"], "mode": mode}}
287
+ if run.get("description"):
288
+ body["description"] = run["description"]
289
+ return body
290
+
291
+
292
+ def _start(body: dict, key: Optional[str], wait: bool = False) -> None:
293
+ """POST /v1/rl_runs and print the runs it started (each polled until terminal with `wait`)."""
294
+ key = key or str(uuid.uuid4())
295
+ try:
296
+ client = KiteClient(timeout=_SLOW_S)
297
+ try:
298
+ page = post_create(client, "/v1/rl_runs", body, key)
299
+ except (httpx.TimeoutException, httpx.NetworkError) as e:
300
+ print_error(f"network error ({e}); re-run with --idempotency-key {key} to retry without training twice",
301
+ code="network_error", details={"idempotency_key": key})
302
+ return
303
+ if wait:
304
+ page["data"] = [wait_for_completion(client, f"/v1/rl_runs/{r['id']}", terminal_states=_TERMINAL,
305
+ poll_interval=_POLL_S) for r in page.get("data") or []]
306
+ print_json(page)
307
+ except KiteAPIError as e:
308
+ print_error(e.message, code=e.code, details=e.details)
309
+
310
+
311
+ def _read(spec: Path) -> dict:
312
+ """SPEC.json: a create body."""
313
+ try:
314
+ return json.loads(spec.read_text())
315
+ except (OSError, ValueError) as e:
316
+ print_error(f"could not read {spec}: {e}", code="invalid_spec")
317
+
318
+
319
+ def _params(overrides: List[str]) -> dict:
320
+ """--set key=value, repeated -> task.params (every parameter is a number)."""
321
+ out = {}
322
+ for kv in overrides:
323
+ key, _, value = kv.partition("=")
324
+ try:
325
+ number = float(value)
326
+ except ValueError:
327
+ number = None
328
+ if not key or number is None:
329
+ raise typer.BadParameter(f"--set expects key=number, got '{kv}'")
330
+ out[key] = number
331
+ return out
332
+
333
+
334
+ def _unzip(client: KiteClient, run_id: str, out: Path) -> Path:
335
+ """Fetch the run's archive (one zip, unpacking to kiteml_<run_id>/), check it against the run's sha256 and
336
+ unpack it into `out`; keeps no zip. A mismatch, or a file outside kiteml_<run_id>/, refuses the whole archive."""
337
+ if not re.fullmatch(r"[A-Za-z0-9_-]+", run_id):
338
+ raise KiteAPIError(400, f"unexpected run id {run_id!r}", code="parameter_invalid")
339
+ expected = str((client.get(f"/v1/rl_runs/{run_id}").get("output") or {}).get("sha256") or "").lower()
340
+ out.mkdir(parents=True, exist_ok=True)
341
+ part = out / f"{run_id}.zip.part"
342
+ try:
343
+ client.download(f"/v1/rl_runs/{run_id}/archive", str(part))
344
+ if expected and hashlib.sha256(part.read_bytes()).hexdigest() != expected:
345
+ raise KiteAPIError(502, f"{run_id}.zip does not match its sha256; download it again",
346
+ code="archive_invalid")
347
+ with zipfile.ZipFile(part) as z:
348
+ top = {PurePosixPath(n).parts[0] for n in z.namelist()}
349
+ if top not in ({f"kiteml_{run_id}"}, {run_id}): # <run_id>/: runs packaged before the kiteml_ name
350
+ raise KiteAPIError(502, f"{run_id}.zip holds files outside kiteml_{run_id}/", code="archive_invalid")
351
+ z.extractall(out)
352
+ except zipfile.BadZipFile as e:
353
+ raise KiteAPIError(502, f"{run_id}.zip: {e}", code="archive_invalid") from e
354
+ finally:
355
+ part.unlink(missing_ok=True)
356
+ return out / top.pop()
@@ -0,0 +1,322 @@
1
+ """`kite twin` — digital twins via the public /v1/twins API: one robot episode in, an interactable
2
+ MuJoCo scene out (about 100 minutes per twin).
3
+
4
+ kite twin validate lerobot/svla_so101_pickplace [--camera side] free: cameras, episodes, time estimate
5
+ kite twin create lerobot/svla_so101_pickplace --wait --out ./twins -> ./twins/twin_.../scene.xml
6
+ kite twin status twin_01K... [--wait]
7
+ kite twin list [--limit 20] [--after twin_...]
8
+ kite twin files twin_01K... [--limit 20] [--after PATH] [--all] what the archive holds, once succeeded
9
+ kite twin cancel twin_01K... -> canceling, then canceled once the build has stopped
10
+ kite twin resume twin_01K...
11
+ kite twin download twin_01K... [--out DIR] [--no-extract]
12
+
13
+ DATASET is a Hugging Face repo id; the API takes it as {"source": {"type": "huggingface", "repo_id": DATASET}}.
14
+ Where twins go: --out DIR, else $KITE_TWINS_DIR, else ./twins (`~` works). Paths printed are absolute.
15
+
16
+ A signed storage URL is fetched with a plain HTTP client, never with the Kite key; the API's own archive
17
+ route (download.requires_auth) goes through KiteClient. Every archive is sha256-verified before it is kept,
18
+ and unpacked only inside <out>/<twin_id>/.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import os
25
+ import re
26
+ import tarfile
27
+ import uuid
28
+ from pathlib import Path, PurePosixPath
29
+ from typing import Optional
30
+
31
+ import httpx
32
+ import typer
33
+
34
+ from kite_cli.client import KiteAPIError, KiteClient, post_create
35
+ from kite_cli.output import print_error, print_json
36
+ from kite_cli.progress import wait_for_completion
37
+
38
+ app = typer.Typer(no_args_is_help=True, help="Digital twins: one robot episode -> an interactable MuJoCo scene")
39
+
40
+ _TERMINAL = {"succeeded", "failed", "canceled"} # not canceling: the cancel was accepted, the build has not stopped
41
+ _POLL_S = 30.0
42
+ _SLOW_S = 180.0 # validate/create check the dataset server-side (the API allows the builder 120 s)
43
+ _DATA_FILTER = hasattr(tarfile, "data_filter") # Python >= 3.10.12 / 3.11.4 / 3.12
44
+
45
+
46
+ @app.command()
47
+ def validate(
48
+ dataset: str = typer.Argument(..., help="Hugging Face dataset id or URL, e.g. lerobot/svla_so101_pickplace"),
49
+ camera: Optional[str] = typer.Option(None, "--camera", help="Substring of the camera that seeds the room"),
50
+ wrist_camera: Optional[str] = typer.Option(None, "--wrist-camera", help="Substring of the wrist camera"),
51
+ ):
52
+ """Free dry run of a create: cameras, episodes, fps, and a time estimate. Creates nothing."""
53
+ try:
54
+ client = KiteClient(timeout=_SLOW_S)
55
+ body = _body(source=_source(dataset), camera=camera, wrist_camera=wrist_camera)
56
+ print_json(client.post("/v1/twins/validate", json_data=body))
57
+ except KiteAPIError as e:
58
+ print_error(e.message, code=e.code, details=e.details)
59
+
60
+
61
+ @app.command()
62
+ def create(
63
+ dataset: str = typer.Argument(..., help="Hugging Face dataset id or URL, e.g. lerobot/svla_so101_pickplace"),
64
+ name: Optional[str] = typer.Option(None, "--name", help="A short name for the twin"),
65
+ task: Optional[str] = typer.Option(None, "--task", help="Which objects to author: the ones the robot manipulates"),
66
+ prompt: Optional[str] = typer.Option(None, "--prompt", help="How to describe the room to the reconstructor"),
67
+ camera: Optional[str] = typer.Option(None, "--camera", help="Substring of the camera that seeds the room"),
68
+ wrist_camera: Optional[str] = typer.Option(None, "--wrist-camera", help="Substring of the wrist camera"),
69
+ ceiling_m: Optional[float] = typer.Option(
70
+ None, "--ceiling-m", help="Floor-to-ceiling height in m, the scale anchor (default 2.90)"
71
+ ),
72
+ idempotency_key: Optional[str] = typer.Option(
73
+ None, "--idempotency-key", help="Safe retries: same key + body -> same twin (default: a generated key)"
74
+ ),
75
+ wait: bool = typer.Option(False, "--wait", help="Poll until succeeded | failed | canceled (about 100 min)"),
76
+ out: Optional[Path] = typer.Option(
77
+ None, "-o", "--out", help="When it succeeds, download, verify and unpack the scene into DIR (implies --wait); "
78
+ "default location for `kite twin download` is $KITE_TWINS_DIR, else ./twins"
79
+ ),
80
+ ):
81
+ """Build a twin from one episode of a LeRobot dataset (queued; about 100 minutes)."""
82
+ key = idempotency_key or str(uuid.uuid4())
83
+ body = _body(source=_source(dataset), name=name, task=task, prompt=prompt, camera=camera,
84
+ wrist_camera=wrist_camera, ceiling_m=ceiling_m)
85
+ try:
86
+ client = KiteClient(timeout=_SLOW_S)
87
+ try:
88
+ twin = post_create(client, "/v1/twins", body, key)
89
+ except (httpx.TimeoutException, httpx.NetworkError) as e:
90
+ print_error(f"network error ({e}); re-run with --idempotency-key {key} to retry without building twice",
91
+ code="network_error", details={"idempotency_key": key})
92
+ return
93
+ if (wait or out) and twin.get("id"):
94
+ twin = wait_for_completion(
95
+ client, f"/v1/twins/{twin['id']}", terminal_states=_TERMINAL, poll_interval=_POLL_S
96
+ )
97
+ if out is None:
98
+ print_json(twin)
99
+ elif twin.get("status") != "succeeded":
100
+ print_json(twin, success=False)
101
+ raise typer.Exit(1)
102
+ else:
103
+ saved = _save(client, twin, _out_dir(out), extract=True)
104
+ print_json({"ok": True, "data": twin, "scene": saved["scene"], "archive": saved["archive"]})
105
+ except KiteAPIError as e:
106
+ print_error(e.message, code=e.code, details=e.details)
107
+
108
+
109
+ @app.command()
110
+ def status(
111
+ twin_id: str = typer.Argument(..., help="Twin id (twin_...)"),
112
+ wait: bool = typer.Option(False, "--wait", help="Poll until succeeded | failed | canceled"),
113
+ ):
114
+ """A twin's live status, progress, stage and stage measurements; `download` once succeeded."""
115
+ try:
116
+ client = KiteClient()
117
+ path = f"/v1/twins/{twin_id}"
118
+ if wait:
119
+ print_json(wait_for_completion(client, path, terminal_states=_TERMINAL, poll_interval=_POLL_S))
120
+ else:
121
+ print_json(client.get(path))
122
+ except KiteAPIError as e:
123
+ print_error(e.message, code=e.code, details=e.details)
124
+
125
+
126
+ @app.command("list")
127
+ def list_twins(
128
+ limit: int = typer.Option(20, "--limit", help="Max rows (1-100)"),
129
+ after: Optional[str] = typer.Option(None, "--after", help="Cursor: the previous page's next_cursor"),
130
+ ):
131
+ """List your twins (newest first, last status seen; `status` has live progress)."""
132
+ params: dict = {"limit": limit}
133
+ if after:
134
+ params["after"] = after
135
+ try:
136
+ print_json(KiteClient().get("/v1/twins", params=params))
137
+ except KiteAPIError as e:
138
+ print_error(e.message, code=e.code, details=e.details)
139
+
140
+
141
+ @app.command("files")
142
+ def list_files(
143
+ twin_id: str = typer.Argument(..., help="Twin id (twin_...)"),
144
+ limit: Optional[int] = typer.Option(None, "--limit", help="Files per page, 1-100 (default 20; 100 with --all)"),
145
+ after: Optional[str] = typer.Option(None, "--after", help="Cursor: the previous page's next_cursor (a file path)"),
146
+ all_pages: bool = typer.Option(False, "--all", help="Follow next_cursor through every page; print one list"),
147
+ ):
148
+ """The files in a succeeded twin's archive (path, bytes, sha256), sorted by path; twin_not_ready before that."""
149
+ path = f"/v1/twins/{twin_id}/files"
150
+ size = limit if limit is not None else (100 if all_pages else 20)
151
+ try:
152
+ client = KiteClient()
153
+ if not all_pages:
154
+ print_json(client.get(path, params=_page(size, after)))
155
+ return
156
+ data: list = []
157
+ while True:
158
+ page = client.get(path, params=_page(size, after))
159
+ items = page.get("data") or []
160
+ data.extend(items)
161
+ after = page.get("next_cursor") or (items[-1].get("path") if items else None)
162
+ if not page.get("has_more") or not after:
163
+ break
164
+ print_json({"object": "list", "data": data, "has_more": False, "next_cursor": None})
165
+ except KiteAPIError as e:
166
+ print_error(e.message, code=e.code, details=e.details)
167
+
168
+
169
+ @app.command()
170
+ def cancel(twin_id: str = typer.Argument(..., help="Twin id (twin_...)")):
171
+ """Stop a queued or processing twin: it reads canceling until the build has stopped, then canceled. The
172
+ stages it finished stay cached for a resume."""
173
+ try:
174
+ print_json(KiteClient().post(f"/v1/twins/{twin_id}/cancel"))
175
+ except KiteAPIError as e:
176
+ print_error(e.message, code=e.code, details=e.details)
177
+
178
+
179
+ @app.command()
180
+ def resume(twin_id: str = typer.Argument(..., help="Twin id (twin_...)")):
181
+ """Re-queue a failed or canceled twin; only the stages that did not finish run again."""
182
+ try:
183
+ print_json(KiteClient().post(f"/v1/twins/{twin_id}/resume"))
184
+ except KiteAPIError as e:
185
+ print_error(e.message, code=e.code, details=e.details)
186
+
187
+
188
+ @app.command()
189
+ def download(
190
+ twin_id: str = typer.Argument(..., help="Twin id (twin_...)"),
191
+ out: Optional[Path] = typer.Option(None, "-o", "--out", help="Output directory (default: $KITE_TWINS_DIR, else ./twins)"),
192
+ extract: bool = typer.Option(
193
+ True, "--extract/--no-extract", help="Unpack into <out>/<twin_id>/, or keep only the .tar.gz"
194
+ ),
195
+ ):
196
+ """Download a succeeded twin: <out>/<twin_id>.tar.gz, sha256-verified, unpacked to <out>/<twin_id>/scene.xml."""
197
+ try:
198
+ client = KiteClient(timeout=120)
199
+ print_json(_save(client, client.get(f"/v1/twins/{twin_id}"), _out_dir(out), extract=extract))
200
+ except KiteAPIError as e:
201
+ print_error(e.message, code=e.code, details=e.details)
202
+
203
+
204
+ def _body(**fields) -> dict:
205
+ return {k: v for k, v in fields.items() if v not in (None, "")}
206
+
207
+
208
+ def _source(dataset: str) -> dict:
209
+ """The positional DATASET as the API takes it: the `source` object /v1/augmentations takes too."""
210
+ return {"type": "huggingface", "repo_id": dataset}
211
+
212
+
213
+ def _page(limit: int, after: Optional[str]) -> dict:
214
+ return {"limit": limit, "after": after} if after else {"limit": limit}
215
+
216
+
217
+ def _out_dir(out: Optional[Path]) -> Path:
218
+ """--out, else $KITE_TWINS_DIR, else ./twins; absolute, so what is printed says exactly where the twin is."""
219
+ return Path(out or os.environ.get("KITE_TWINS_DIR") or "./twins").expanduser().resolve()
220
+
221
+
222
+ def _save(client: KiteClient, twin: dict, out: Path, *, extract: bool) -> dict:
223
+ """Fetch a succeeded twin's archive to <out>/<twin_id>.tar.gz, verify its sha256, and (extract) unpack it
224
+ into <out>/<twin_id>/. An archive already there that still verifies is reused."""
225
+ tid, d = str(twin.get("id") or ""), twin.get("download")
226
+ if twin.get("status") != "succeeded" or not d:
227
+ raise KiteAPIError(409, f"Twin '{tid}' is {twin.get('status')}, not succeeded.", code="twin_not_ready")
228
+ if not re.fullmatch(r"[A-Za-z0-9_-]+", tid):
229
+ raise KiteAPIError(502, f"unexpected twin id {tid!r}", code="archive_invalid")
230
+ expected = str(d.get("sha256") or "").lower()
231
+ if not expected:
232
+ raise KiteAPIError(502, f"Twin '{tid}' has no download.sha256 to verify against.", code="checksum_missing")
233
+ out.mkdir(parents=True, exist_ok=True)
234
+ archive = out / f"{tid}.tar.gz"
235
+ if not (archive.is_file() and _sha256(archive) == expected):
236
+ part = out / f"{tid}.tar.gz.part"
237
+ try:
238
+ _fetch(client, d, part)
239
+ got = _sha256(part)
240
+ if got != expected:
241
+ raise KiteAPIError(502, f"{archive.name}: sha256 {got} != expected {expected}; the file was discarded, "
242
+ f"retry the download", code="checksum_mismatch",
243
+ details={"expected": expected, "actual": got, "bytes": part.stat().st_size})
244
+ part.replace(archive)
245
+ finally:
246
+ part.unlink(missing_ok=True)
247
+ saved = {"id": tid, "archive": str(archive), "bytes": archive.stat().st_size, "sha256": expected}
248
+ if extract:
249
+ scene = _relpath(d.get("scene") or f"{tid}/scene.xml")
250
+ _unpack(archive, out, top=tid)
251
+ if not (out / scene).is_file():
252
+ raise KiteAPIError(502, f"{archive.name} has no {scene.as_posix()}", code="archive_invalid")
253
+ saved["scene"] = str(out / scene)
254
+ return saved
255
+
256
+
257
+ def _fetch(client: KiteClient, d: dict, dest: Path) -> None:
258
+ """Stream the archive to `dest`. The Kite key goes to the Kite API only: the archive route through
259
+ KiteClient, a signed storage URL through a plain client that has no key to send."""
260
+ url = str(d.get("url") or "")
261
+ if not url:
262
+ raise KiteAPIError(502, "the twin has no download.url", code="download_failed")
263
+ u = httpx.URL(url)
264
+ if u.is_relative_url or _origin(u) == _origin(httpx.URL(client.base_url)):
265
+ client.download(url, str(dest))
266
+ return
267
+ if d.get("requires_auth"):
268
+ raise KiteAPIError(502, f"download.url is on {u.host}, not the Kite API; refusing to send it the Kite key",
269
+ code="download_failed")
270
+ with _storage_client() as http, http.stream("GET", url, headers={"Accept-Encoding": "identity"}) as r:
271
+ if not r.is_success:
272
+ r.read()
273
+ raise KiteAPIError(r.status_code, f"GET {u.host} answered HTTP {r.status_code}: {r.text[:200]}",
274
+ code="download_failed")
275
+ with open(dest, "wb") as f:
276
+ for chunk in r.iter_bytes(1 << 20):
277
+ f.write(chunk)
278
+
279
+
280
+ def _storage_client() -> httpx.Client:
281
+ """A plain client for signed storage URLs: it has no Kite credentials to send."""
282
+ return httpx.Client(timeout=httpx.Timeout(120.0, connect=30.0), follow_redirects=True)
283
+
284
+
285
+ def _origin(u: httpx.URL) -> tuple:
286
+ return u.scheme, u.host, u.port or {"https": 443, "http": 80}.get(u.scheme)
287
+
288
+
289
+ def _sha256(path: Path) -> str:
290
+ h = hashlib.sha256()
291
+ with open(path, "rb") as f:
292
+ for chunk in iter(lambda: f.read(1 << 20), b""):
293
+ h.update(chunk)
294
+ return h.hexdigest()
295
+
296
+
297
+ def _relpath(p: str) -> Path:
298
+ rel = PurePosixPath(p)
299
+ if rel.is_absolute() or ".." in rel.parts or not rel.parts:
300
+ raise KiteAPIError(502, f"unsafe path in download.scene: {p!r}", code="archive_invalid")
301
+ return Path(*rel.parts)
302
+
303
+
304
+ def _unpack(archive: Path, dest: Path, *, top: str) -> None:
305
+ """Extract into `dest`. Every member must sit under `<top>/` (the twin id), with no absolute path or `..`.
306
+ Python's tarfile "data" filter, where present, also refuses links out of `dest` and device files; without
307
+ it (Python < 3.10.12), links and special files are refused outright."""
308
+ with tarfile.open(archive, "r:gz") as tar:
309
+ members = tar.getmembers()
310
+ for m in members:
311
+ parts = PurePosixPath(m.name).parts
312
+ if (parts and parts[0] != top) or ".." in parts or not (_DATA_FILTER or m.isfile() or m.isdir()):
313
+ raise KiteAPIError(502, f"{archive.name}: refusing to extract {m.name!r}", code="archive_invalid")
314
+ try:
315
+ if _DATA_FILTER:
316
+ tar.extractall(dest, members=members, filter="data")
317
+ else:
318
+ for m in members:
319
+ m.mode = 0o755 if m.isdir() else (m.mode | 0o600) & 0o755
320
+ tar.extractall(dest, members=members)
321
+ except tarfile.TarError as e: # includes the data filter's refusals
322
+ raise KiteAPIError(502, f"{archive.name}: {e}", code="archive_invalid") from e