lucen-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.
Files changed (100) hide show
  1. lucen/__init__.py +72 -0
  2. lucen/errors.py +141 -0
  3. lucen/hub.py +889 -0
  4. lucen/progress.py +96 -0
  5. lucen/py.typed +0 -0
  6. lucen/results.py +885 -0
  7. lucen_cli/__init__.py +3 -0
  8. lucen_cli/checkpoint_dir.py +226 -0
  9. lucen_cli/checkpoint_publisher.py +269 -0
  10. lucen_cli/client.py +1730 -0
  11. lucen_cli/cmd_auth.py +256 -0
  12. lucen_cli/cmd_datasets.py +190 -0
  13. lucen_cli/cmd_holds.py +76 -0
  14. lucen_cli/cmd_hub.py +245 -0
  15. lucen_cli/cmd_model.py +175 -0
  16. lucen_cli/cmd_org.py +154 -0
  17. lucen_cli/cmd_quickstart.py +320 -0
  18. lucen_cli/cmd_repo.py +87 -0
  19. lucen_cli/cmd_robot.py +784 -0
  20. lucen_cli/cmd_rollout.py +576 -0
  21. lucen_cli/cmd_runs.py +615 -0
  22. lucen_cli/cmd_serve.py +438 -0
  23. lucen_cli/cmd_worker.py +135 -0
  24. lucen_cli/config.py +268 -0
  25. lucen_cli/context.py +43 -0
  26. lucen_cli/contract_derive.py +491 -0
  27. lucen_cli/embodiment.py +522 -0
  28. lucen_cli/errors.py +210 -0
  29. lucen_cli/gate_scorer.py +168 -0
  30. lucen_cli/group.py +104 -0
  31. lucen_cli/hf_dataset.py +696 -0
  32. lucen_cli/live.py +189 -0
  33. lucen_cli/local.py +160 -0
  34. lucen_cli/main.py +277 -0
  35. lucen_cli/model_card.py +523 -0
  36. lucen_cli/offscreen.py +52 -0
  37. lucen_cli/onnx_manifest.py +121 -0
  38. lucen_cli/onnx_writer.py +313 -0
  39. lucen_cli/ort_quiet.py +75 -0
  40. lucen_cli/output.py +868 -0
  41. lucen_cli/progress.py +133 -0
  42. lucen_cli/pull.py +158 -0
  43. lucen_cli/push.py +384 -0
  44. lucen_cli/rl/__init__.py +8 -0
  45. lucen_cli/rl/ppo.py +316 -0
  46. lucen_cli/rl/tasks.py +298 -0
  47. lucen_cli/rl/vecenv.py +246 -0
  48. lucen_cli/robot/__init__.py +66 -0
  49. lucen_cli/robot/checks.py +1416 -0
  50. lucen_cli/robot/confine.py +176 -0
  51. lucen_cli/robot/draft.py +523 -0
  52. lucen_cli/robot/hf.py +218 -0
  53. lucen_cli/robot/hub.py +227 -0
  54. lucen_cli/robot/intake.py +590 -0
  55. lucen_cli/robot/model.py +334 -0
  56. lucen_cli/robot/pose.py +132 -0
  57. lucen_cli/robot/publish.py +197 -0
  58. lucen_cli/robot/refs.py +212 -0
  59. lucen_cli/robot/thumbnail.py +586 -0
  60. lucen_cli/robot/urdf.py +435 -0
  61. lucen_cli/robot/worker.py +599 -0
  62. lucen_cli/robot_assets.py +127 -0
  63. lucen_cli/rollout/__init__.py +12 -0
  64. lucen_cli/rollout/assets.py +135 -0
  65. lucen_cli/rollout/conditions.py +486 -0
  66. lucen_cli/rollout/contract.py +314 -0
  67. lucen_cli/rollout/engine.py +1808 -0
  68. lucen_cli/rollout/harness.py +201 -0
  69. lucen_cli/rollout/obs.py +262 -0
  70. lucen_cli/rollout/plant.py +456 -0
  71. lucen_cli/rollout/posture.py +96 -0
  72. lucen_cli/rollout/video.py +548 -0
  73. lucen_cli/rollout/worker.py +505 -0
  74. lucen_cli/serve/__init__.py +22 -0
  75. lucen_cli/serve/lerobot_backend.py +337 -0
  76. lucen_cli/serve/msgpack_numpy.py +59 -0
  77. lucen_cli/serve/policy.py +276 -0
  78. lucen_cli/serve/server.py +474 -0
  79. lucen_cli/serve/session.py +142 -0
  80. lucen_cli/serve/stub_skill.py +264 -0
  81. lucen_cli/serve/vla.py +788 -0
  82. lucen_cli/serve/worker.py +578 -0
  83. lucen_cli/sim_words.py +55 -0
  84. lucen_cli/state.py +237 -0
  85. lucen_cli/templates/__init__.py +94 -0
  86. lucen_cli/templates/ppo_isaac.py +914 -0
  87. lucen_cli/templates/ppo_mujoco.py +664 -0
  88. lucen_cli/templates/smolvla_lora.py +986 -0
  89. lucen_cli/templates/stub_ppo.py +597 -0
  90. lucen_cli/templates/stub_skill.py +180 -0
  91. lucen_cli/tfevents.py +323 -0
  92. lucen_cli/training_curve.py +70 -0
  93. lucen_cli/transfer.py +209 -0
  94. lucen_cli/vla_manifest.py +322 -0
  95. lucen_cli/worker.py +1433 -0
  96. lucen_cli-0.1.0.dist-info/METADATA +113 -0
  97. lucen_cli-0.1.0.dist-info/RECORD +100 -0
  98. lucen_cli-0.1.0.dist-info/WHEEL +4 -0
  99. lucen_cli-0.1.0.dist-info/entry_points.txt +3 -0
  100. lucen_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
lucen/hub.py ADDED
@@ -0,0 +1,889 @@
1
+ """`Hub` — the MouseMouse hub from a Python script, on the CLI's own engine.
2
+
3
+ Nothing here talks to the hub on its own: every method is one of the CLI's
4
+ functions (`lucen_cli.client.HubClient`, `push.run_push`, `pull.run_pull`,
5
+ the runner's `follow_run` stream, the rollout engine) called with the same
6
+ arguments the command line would pass, so a script and a terminal cannot
7
+ disagree about what a push uploads or what a 404 means. The module adds three
8
+ things a script wants and a terminal does not:
9
+
10
+ - **credentials resolved the CLI's way, or passed explicitly** —
11
+ ``Hub()`` reads `LUCEN_API_URL` / `LUCEN_API_KEY`, then
12
+ `~/.config/lucen/credentials` (the file `lucen auth login` writes, refused
13
+ when another user can read it); ``Hub(url=…, key=…)`` overrides either. No
14
+ key is ever read from a file a script wrote or an argument it did not pass;
15
+ - **results as records** (:mod:`lucen.results`): frozen dataclasses with the
16
+ ids, URLs and counts a script asserts on, `to_dict()` for what
17
+ `lucen … --json` would have printed, and `raw` for the whole SDK model;
18
+ - **refusals as one exception family** (:mod:`lucen.errors`): the CLI's three
19
+ lines — what happened, what the hub said, the fix — as attributes, with the
20
+ subclass chosen by status.
21
+
22
+ Progress is silent unless asked for: ``Hub(progress=True)`` prints exactly the
23
+ CLI's stderr lines; ``Hub(on_progress=callback)`` hands every milestone, byte
24
+ count and followed log line to a function of yours (:mod:`lucen.progress`).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import builtins
30
+ import dataclasses
31
+ import os
32
+ from collections.abc import Callable, Iterator, Mapping, Sequence
33
+ from contextlib import contextmanager
34
+ from pathlib import Path
35
+ from types import TracebackType
36
+ from typing import Any
37
+
38
+ import httpx
39
+ from lucen_sdk.models import OrgRole, RepoKind, Visibility
40
+
41
+ from lucen.errors import LOGIN_FIX_PYTHON, HubError, HubUnreachable, NotFound, NotSignedIn
42
+ from lucen.errors import from_cli_error as _from_cli_error
43
+ from lucen.progress import CallbackReporter, ProgressCallback, make_reporter
44
+ from lucen.results import (
45
+ Episode,
46
+ LogEvent,
47
+ Me,
48
+ Member,
49
+ Org,
50
+ PullResult,
51
+ PushResult,
52
+ Repo,
53
+ RobotDraft,
54
+ RobotPublishResult,
55
+ RobotReport,
56
+ Rollout,
57
+ Run,
58
+ RunEstimate,
59
+ RunRecommendation,
60
+ )
61
+ from lucen_cli.client import HubClient, RepoTarget, unset_to_none
62
+ from lucen_cli.cmd_hub import page_path
63
+ from lucen_cli.config import (
64
+ ENV_WEB_URL,
65
+ NO_HUB_URL_MESSAGE,
66
+ Credentials,
67
+ load_cached_web_url,
68
+ load_credentials,
69
+ page_url,
70
+ save_cached_web_url,
71
+ web_url_for,
72
+ )
73
+ from lucen_cli.errors import CLIError
74
+ from lucen_cli.errors import HubUnreachable as _CLIHubUnreachable
75
+ from lucen_cli.progress import Reporter
76
+ from lucen_cli.pull import run_pull
77
+ from lucen_cli.push import run_push
78
+ from lucen_cli.sim_words import created_line
79
+
80
+ #: How a script configures the hub's address when nothing else does.
81
+ NO_HUB_URL_FIX_PYTHON = (
82
+ "Pass Hub(url='https://…'), set LUCEN_API_URL, or store the address once with "
83
+ "`lucen auth login --url <API URL>`."
84
+ )
85
+
86
+ #: The sentence `hub.robots` and a local rollout refuse with when MuJoCo is absent.
87
+ ENGINE_FIX = (
88
+ "Install the rollout extra into this environment: "
89
+ 'uv pip install --index lucen=<hub>/install/simple/ "lucen-cli[rollout]" '
90
+ '(or, from a checkout: uv pip install ./packages/sdk/py "./packages/cli[rollout]").'
91
+ )
92
+
93
+ #: `recipe` as a script hands it over: a mapping, or the path of a YAML/JSON file.
94
+ RecipeLike = Mapping[str, Any] | str | os.PathLike[str]
95
+ LogCallback = Callable[[LogEvent], None]
96
+
97
+
98
+ def _target(repo: str) -> RepoTarget:
99
+ try:
100
+ return RepoTarget.parse(repo)
101
+ except CLIError as exc:
102
+ raise _from_cli_error(exc) from exc
103
+
104
+
105
+ def _engine_available() -> bool:
106
+ for module in ("mujoco", "numpy", "defusedxml", "onnxruntime"):
107
+ try:
108
+ __import__(module)
109
+ except ImportError:
110
+ return False
111
+ return True
112
+
113
+
114
+ def _require_engine(what: str) -> None:
115
+ """An `ImportError` with the install line, before anything is created on the hub."""
116
+ if not _engine_available():
117
+ raise ImportError(
118
+ f"{what} runs real MuJoCo on this machine, and it is not installed. {ENGINE_FIX}"
119
+ )
120
+
121
+
122
+ def _recipe(recipe: RecipeLike) -> tuple[dict[str, Any], str | None, str | None]:
123
+ """A mapping, or a file -> `(recipe, owner, parent_run_id)` as `lucen train -f` reads it."""
124
+ if isinstance(recipe, Mapping):
125
+ body = dict(recipe)
126
+ owner = body.pop("owner", None)
127
+ parent = body.pop("parent_run_id", None)
128
+ if "template" not in body:
129
+ raise HubError(
130
+ "The recipe has no `template`.",
131
+ fix="Give it one: {'template': 'ppo_mujoco', 'robot': 'you/robot'} "
132
+ "(or smolvla_lora, ppo_isaac).",
133
+ )
134
+ body.setdefault("dataset", [])
135
+ return body, (str(owner) if owner else None), (str(parent) if parent else None)
136
+ from lucen_cli.cmd_runs import load_recipe_file
137
+
138
+ path = Path(recipe).expanduser()
139
+ if not path.is_file():
140
+ raise HubError(
141
+ f"{path} is not a recipe file.",
142
+ fix="Pass the recipe as a dict, or the path of a YAML/JSON file that exists.",
143
+ )
144
+ try:
145
+ return load_recipe_file(path)
146
+ except CLIError as exc:
147
+ raise _from_cli_error(exc) from exc
148
+
149
+
150
+ class Hub:
151
+ """One hub, one credential, one connection pool. See the module docstring."""
152
+
153
+ def __init__(
154
+ self,
155
+ url: str | None = None,
156
+ key: str | None = None,
157
+ *,
158
+ progress: bool = False,
159
+ on_progress: ProgressCallback | None = None,
160
+ timeout: float = 60.0,
161
+ ) -> None:
162
+ try:
163
+ credentials = load_credentials(url.strip() if url else None)
164
+ except CLIError as exc:
165
+ if exc.message == NO_HUB_URL_MESSAGE:
166
+ raise HubError(NO_HUB_URL_MESSAGE, fix=NO_HUB_URL_FIX_PYTHON) from exc
167
+ raise _from_cli_error(exc) from exc
168
+ if key:
169
+ credentials = dataclasses.replace(
170
+ credentials, api_key=key.strip(), key_source="argument"
171
+ )
172
+ self._credentials: Credentials = credentials
173
+ self._client = HubClient(credentials, timeout=timeout)
174
+ self._progress = progress
175
+ self._on_progress = on_progress
176
+ self.repos = Repos(self)
177
+ self.runs = Runs(self)
178
+ self.rollouts = Rollouts(self)
179
+ self.robots = Robots(self)
180
+ self.orgs = Orgs(self)
181
+ self.datasets = Datasets(self)
182
+
183
+ # -- plumbing ----------------------------------------------------------
184
+
185
+ @property
186
+ def api_url(self) -> str:
187
+ return self._credentials.api_url
188
+
189
+ @property
190
+ def signed_in(self) -> bool:
191
+ """Whether a key is configured (not whether the hub accepts it: that is `me()`)."""
192
+ return self._credentials.is_authenticated
193
+
194
+ @property
195
+ def web_url(self) -> str | None:
196
+ """The hub's web app, or None when the hub names none (one `/healthz`, cached)."""
197
+ return self._client.web_url
198
+
199
+ def close(self) -> None:
200
+ self._client.close()
201
+
202
+ def __enter__(self) -> Hub:
203
+ return self
204
+
205
+ def __exit__(
206
+ self,
207
+ exc_type: type[BaseException] | None,
208
+ exc: BaseException | None,
209
+ tb: TracebackType | None,
210
+ ) -> None:
211
+ self.close()
212
+
213
+ def __repr__(self) -> str:
214
+ key = self._credentials.key_display if self.signed_in else "none"
215
+ return f"Hub({self.api_url!r}, key={key})"
216
+
217
+ @contextmanager
218
+ def _calls(self) -> Iterator[None]:
219
+ """The CLI's exceptions become this module's; a transport error is `HubUnreachable`."""
220
+ try:
221
+ yield
222
+ except CLIError as exc:
223
+ raise _from_cli_error(exc) from exc
224
+ except httpx.TransportError as exc:
225
+ error = _CLIHubUnreachable(self.api_url, exc)
226
+ raise HubUnreachable(error.message, fix=error.hint) from exc
227
+
228
+ def _auth(self) -> HubClient:
229
+ """The client, for a call that needs a key; `NotSignedIn` when there is none."""
230
+ if not self.signed_in:
231
+ raise NotSignedIn(
232
+ f"Not signed in: this call needs an API key, and none is configured for "
233
+ f"{self.api_url}.",
234
+ fix=LOGIN_FIX_PYTHON,
235
+ )
236
+ return self._client
237
+
238
+ def _reporter(self, on_progress: ProgressCallback | None = None) -> Reporter:
239
+ return make_reporter(progress=self._progress, on_progress=on_progress or self._on_progress)
240
+
241
+ def _web(self) -> str | None:
242
+ return self._client.web_url
243
+
244
+ # -- the five things a script does -------------------------------------
245
+
246
+ def me(self) -> Me:
247
+ """Who the credential is, and the scopes it grants (`GET /v1/auth/me`)."""
248
+ client = self._auth()
249
+ with self._calls():
250
+ me = client.me()
251
+ return Me.from_sdk(
252
+ me,
253
+ key_prefix=self._credentials.key_display,
254
+ key_source=self._credentials.key_source,
255
+ api_url=self.api_url,
256
+ )
257
+
258
+ def push(
259
+ self,
260
+ path: str | os.PathLike[str],
261
+ repo: str,
262
+ *,
263
+ kind: str = "dataset",
264
+ public: bool = False,
265
+ rehash: bool = False,
266
+ description: str | None = None,
267
+ jobs: int = 8,
268
+ dry_run: bool = False,
269
+ on_progress: ProgressCallback | None = None,
270
+ ) -> PushResult:
271
+ """Upload a directory to `repo` (`owner/slug`), creating it as `kind` if it is new.
272
+
273
+ Exactly `lucen push`: a file the hub already holds at that path with
274
+ that sha256 is skipped, bytes the hub already stores are linked and
275
+ not sent, a killed push resumes by the parts that never landed.
276
+ `public` applies only when the repo is created; `rehash` ignores the
277
+ local digest cache; `dry_run` reports what would upload and changes
278
+ nothing (and needs no key).
279
+ """
280
+ try:
281
+ repo_kind = RepoKind(kind)
282
+ except ValueError as exc:
283
+ raise HubError(
284
+ f"{kind!r} is not a repo kind.", fix="Use 'dataset', 'model' or 'robot'."
285
+ ) from exc
286
+ root = Path(path).expanduser()
287
+ if not root.is_dir():
288
+ raise HubError(f"{root} is not a directory.", fix="Pass the directory to push.")
289
+ client = self._client if dry_run else self._auth()
290
+ with self._calls():
291
+ result = run_push(
292
+ client,
293
+ _target(repo),
294
+ root,
295
+ kind=repo_kind,
296
+ private=False if public else None,
297
+ description=description,
298
+ jobs=jobs,
299
+ rehash=rehash,
300
+ reporter=self._reporter(on_progress),
301
+ dry_run=dry_run,
302
+ )
303
+ return PushResult.from_cli(result)
304
+
305
+ def pull(
306
+ self,
307
+ repo: str,
308
+ path: str | os.PathLike[str] | None = None,
309
+ *,
310
+ prefix: str | None = None,
311
+ force: bool = False,
312
+ jobs: int = 8,
313
+ on_progress: ProgressCallback | None = None,
314
+ ) -> PullResult:
315
+ """Download a repo's files into `path` (default `./<slug>`), verifying every sha256.
316
+
317
+ Exactly `lucen pull`: files stream in parallel, are hashed while they
318
+ are written and take their name only when the digest matches the
319
+ tree's; one already on disk with the right size and digest is skipped
320
+ (`force` re-downloads it). A public repo needs no key.
321
+ """
322
+ target = _target(repo)
323
+ dest = Path(path).expanduser() if path is not None else Path(target.slug)
324
+ with self._calls():
325
+ result = run_pull(
326
+ self._client,
327
+ target,
328
+ dest,
329
+ prefix=prefix,
330
+ jobs=jobs,
331
+ force=force,
332
+ reporter=self._reporter(on_progress),
333
+ )
334
+ return PullResult.from_cli(result)
335
+
336
+ def open(self, target: str | None = None) -> str:
337
+ """The web address of a repo (`owner/slug`), a run, a rollout, an endpoint or a plan.
338
+
339
+ The origin is `LUCEN_WEB_URL`, else what the hub answered last time
340
+ (`~/.config/lucen/hub.json`, the file `lucen open` keeps), else
341
+ `/healthz` now — then cached. Nothing is opened; the string is returned.
342
+ """
343
+ with self._calls():
344
+ path = page_path(target)
345
+ web = self._web_origin()
346
+ url = page_url(web, path)
347
+ assert url is not None
348
+ return url
349
+
350
+ def _web_origin(self) -> str:
351
+ if os.environ.get(ENV_WEB_URL):
352
+ origin = web_url_for(None)
353
+ assert origin is not None
354
+ return origin
355
+ cached = load_cached_web_url(self.api_url)
356
+ if cached:
357
+ return cached
358
+ with self._calls():
359
+ health = self._client.health()
360
+ origin = web_url_for(unset_to_none(health.web_url))
361
+ save_cached_web_url(self.api_url, origin)
362
+ if origin is None:
363
+ raise HubError(
364
+ f"The hub at {self.api_url} names no web address.",
365
+ fix=f"Set {ENV_WEB_URL} to the web app's origin (https://…), or ask the hub's "
366
+ "operator to set API_CORS_ORIGINS.",
367
+ )
368
+ return origin
369
+
370
+
371
+ class Repos:
372
+ """`hub.repos`: list, read, create and delete dataset / model / robot repos."""
373
+
374
+ def __init__(self, hub: Hub) -> None:
375
+ self._hub = hub
376
+
377
+ def list(
378
+ self,
379
+ *,
380
+ kind: str | None = None,
381
+ owner: str | None = None,
382
+ q: str | None = None,
383
+ limit: int = 20,
384
+ ) -> builtins.list[Repo]:
385
+ """The repos this credential can see: public ones, yours, your organizations'."""
386
+ repo_kind = RepoKind(kind) if kind else None
387
+ with self._hub._calls():
388
+ repos = self._hub._client.list_repos(query=q, kind=repo_kind, owner=owner, limit=limit)
389
+ web = self._hub._web() if repos else None
390
+ return [Repo.from_sdk(repo, web=web) for repo in repos]
391
+
392
+ def get(self, repo: str) -> Repo:
393
+ """One repo; `NotFound` when it does not exist or is private and not yours."""
394
+ target = _target(repo)
395
+ with self._hub._calls():
396
+ found = self._hub._client.get_repo(target)
397
+ if found is None:
398
+ raise NotFound(
399
+ f"Reading {target.full_name}: no such repo, or not yours.",
400
+ status=404,
401
+ fix="`hub.repos.list()` shows the ones this credential can see; a private "
402
+ "one you are not a member of looks the same as none.",
403
+ )
404
+ return Repo.from_sdk(found, web=self._hub._web())
405
+
406
+ def create(
407
+ self,
408
+ repo: str,
409
+ *,
410
+ kind: str = "dataset",
411
+ public: bool = False,
412
+ description: str | None = None,
413
+ tags: Sequence[str] = (),
414
+ ) -> Repo:
415
+ """An empty repo under your handle or an organization you belong to."""
416
+ client = self._hub._auth()
417
+ with self._hub._calls():
418
+ created = client.create_repo(
419
+ _target(repo),
420
+ kind=RepoKind(kind),
421
+ visibility=Visibility.PUBLIC if public else Visibility.PRIVATE,
422
+ description=description,
423
+ tags=tags,
424
+ )
425
+ return Repo.from_sdk(created, web=self._hub._web())
426
+
427
+ def delete(self, repo: str) -> None:
428
+ """Delete a repo; `Conflict` names the run or rollout whose history still needs it."""
429
+ client = self._hub._auth()
430
+ with self._hub._calls():
431
+ client.delete_repo(_target(repo))
432
+
433
+
434
+ class Runs:
435
+ """`hub.runs`: submit, price, plan, read, follow and cancel training runs."""
436
+
437
+ def __init__(self, hub: Hub) -> None:
438
+ self._hub = hub
439
+
440
+ def create(
441
+ self,
442
+ recipe: RecipeLike,
443
+ *,
444
+ executor: str | None = None,
445
+ robot: str | None = None,
446
+ parent: str | None = None,
447
+ owner: str | None = None,
448
+ idempotency_key: str | None = None,
449
+ follow: bool = False,
450
+ on_log: LogCallback | None = None,
451
+ ) -> Run:
452
+ """Submit a run (`POST /v1/runs`); with `follow=True`, stay until it ends.
453
+
454
+ `recipe` is a mapping (`template`, `dataset`, `hyperparams`, `gpu`,
455
+ `max_hours`, `executor`, `budget_usd`, `robot`) or the path of the
456
+ YAML/JSON file `lucen train -f` reads. `executor` is `worker` (the
457
+ default: it runs when a `lucen worker run` you start claims it) or
458
+ `modal`. With `follow=True` every log line goes to `on_log` as a
459
+ :class:`~lucen.results.LogEvent` (or to the hub's progress reporter),
460
+ and the run returned is its final state — `status` is `succeeded`,
461
+ `failed` or `canceled`. Ctrl-C detaches; the run keeps going.
462
+ """
463
+ body, file_owner, file_parent = _recipe(recipe)
464
+ if executor is not None:
465
+ body["executor"] = executor
466
+ if robot is not None:
467
+ body["robot"] = robot
468
+ client = self._hub._auth()
469
+ with self._hub._calls():
470
+ run, created = client.create_run(
471
+ body,
472
+ owner=owner or file_owner,
473
+ parent_run_id=parent or file_parent,
474
+ idempotency_key=idempotency_key,
475
+ )
476
+ if not follow:
477
+ return Run.from_sdk(run, web=self._hub._web(), created=created)
478
+ for event in self._stream(run.id, follow=True, tail=None):
479
+ self._emit(event, on_log)
480
+ final = client.get_run(run.id)
481
+ return Run.from_sdk(final, web=self._hub._web(), created=created)
482
+
483
+ def _stream(self, run_id: str, *, follow: bool, tail: int | None) -> Iterator[LogEvent]:
484
+ client = self._hub._auth()
485
+ with self._hub._calls():
486
+ for event in client.stream_logs(run_id, follow=follow, tail=tail):
487
+ yield LogEvent.from_sse(event)
488
+
489
+ def _emit(self, event: LogEvent, on_log: LogCallback | None) -> None:
490
+ if on_log is not None:
491
+ on_log(event)
492
+ return
493
+ if event.kind != "log":
494
+ return
495
+ reporter = self._hub._reporter()
496
+ if isinstance(reporter, CallbackReporter):
497
+ reporter.log(event.line or "", stream=event.stream or "stdout")
498
+ else:
499
+ reporter.step(event.text)
500
+
501
+ def list(
502
+ self,
503
+ *,
504
+ status: str | None = None,
505
+ executor: str | None = None,
506
+ owner: str | None = None,
507
+ limit: int = 20,
508
+ ) -> builtins.list[Run]:
509
+ """The runs this credential can see, newest first (no run is public)."""
510
+ client = self._hub._auth()
511
+ with self._hub._calls():
512
+ runs = client.list_runs(owner=owner, status=status, executor=executor, limit=limit)
513
+ web = self._hub._web() if runs else None
514
+ return [Run.from_sdk(run, web=web) for run in runs]
515
+
516
+ def get(self, run_id: str) -> Run:
517
+ client = self._hub._auth()
518
+ with self._hub._calls():
519
+ return Run.from_sdk(client.get_run(run_id), web=self._hub._web())
520
+
521
+ def logs(
522
+ self, run_id: str, *, follow: bool = False, tail: int | None = None
523
+ ) -> Iterator[LogEvent]:
524
+ """The run's log as it was written (`follow=True`: and as it is written, until the end).
525
+
526
+ Yields :class:`~lucen.results.LogEvent`s — `log` lines, every `metric`
527
+ line, and one `end` carrying the run's status.
528
+ """
529
+ return self._stream(run_id, follow=follow, tail=tail)
530
+
531
+ def cancel(self, run_id: str) -> Run:
532
+ """Cancel: a queued run ends now; a running one is stopped by its runner."""
533
+ client = self._hub._auth()
534
+ with self._hub._calls():
535
+ return Run.from_sdk(client.cancel_run(run_id), web=self._hub._web())
536
+
537
+ def estimate(
538
+ self, recipe: RecipeLike, *, owner: str | None = None, parent: str | None = None
539
+ ) -> RunEstimate:
540
+ """The price of a recipe before anything is created (`POST /v1/runs/estimate`)."""
541
+ body, file_owner, file_parent = _recipe(recipe)
542
+ client = self._hub._auth()
543
+ with self._hub._calls():
544
+ estimate = client.estimate_run(
545
+ body, owner=owner or file_owner, parent_run_id=parent or file_parent
546
+ )
547
+ return RunEstimate.from_sdk(estimate)
548
+
549
+ def recommend(
550
+ self, recipe: RecipeLike, *, owner: str | None = None, parent: str | None = None
551
+ ) -> RunRecommendation:
552
+ """Which GPU tier a recipe needs, with the arithmetic (`POST /v1/runs/recommend`)."""
553
+ body, file_owner, file_parent = _recipe(recipe)
554
+ client = self._hub._auth()
555
+ with self._hub._calls():
556
+ rec = client.recommend_run(
557
+ body, owner=owner or file_owner, parent_run_id=parent or file_parent
558
+ )
559
+ return RunRecommendation.from_sdk(rec)
560
+
561
+
562
+ class Rollouts:
563
+ """`hub.rollouts`: the sim gate — a policy in MuJoCo on a robot, scored by the hub."""
564
+
565
+ def __init__(self, hub: Hub) -> None:
566
+ self._hub = hub
567
+
568
+ def create(
569
+ self,
570
+ model: str,
571
+ robot: str,
572
+ *,
573
+ battery: str | Mapping[str, Any] = "smoke",
574
+ seed: int = 0,
575
+ seeds: int | None = None,
576
+ horizon_s: float | None = None,
577
+ run: str | None = None,
578
+ conditions: Mapping[str, Any] | None = None,
579
+ checkpoint: int | None = None,
580
+ executor: str = "worker",
581
+ local: bool = False,
582
+ name: str | None = None,
583
+ work_dir: str | os.PathLike[str] | None = None,
584
+ video: bool = True,
585
+ keep: bool = False,
586
+ on_progress: ProgressCallback | None = None,
587
+ ) -> Rollout:
588
+ """Queue a rollout of `model` on `robot`; with `local=True`, run it here and report.
589
+
590
+ `battery` is a name (`smoke`, `full`, `c_matrix`, `robust`, `recovery`,
591
+ `watch`, `stand`) or a `BatterySpec` mapping. `run` attaches the
592
+ scorecard to that training run. Without `local` the rollout waits
593
+ `queued` for a `lucen rollout worker`; with it, this machine claims
594
+ and executes it (the `rollout` extra), the hub scores it, and the
595
+ rollout returned is its final state. A local rollout that did not
596
+ succeed raises `HubError` with the reason, as `lucen rollout` exits 1.
597
+ """
598
+ if executor not in ("worker", "modal"):
599
+ raise HubError(f"executor={executor!r}: expected 'worker' or 'modal'.")
600
+ if local and executor == "modal":
601
+ raise HubError(
602
+ "local=True runs the rollout here; executor='modal' asks the hub to run it on "
603
+ "Modal.",
604
+ fix="Pick one.",
605
+ )
606
+ if local:
607
+ _require_engine("A local rollout")
608
+ client = self._hub._auth()
609
+ body: dict[str, Any] = {
610
+ "model_repo": _target(model).full_name,
611
+ "robot_repo": _target(robot).full_name,
612
+ "seed": seed,
613
+ "battery": dict(battery) if isinstance(battery, Mapping) else battery,
614
+ "executor": executor,
615
+ }
616
+ if horizon_s is not None:
617
+ body["horizon_s"] = horizon_s
618
+ if run:
619
+ body["run_id"] = run
620
+ if seeds is not None:
621
+ body["seeds"] = seeds
622
+ if checkpoint is not None:
623
+ body["checkpoint_step"] = checkpoint
624
+ if conditions:
625
+ body["conditions"] = dict(conditions)
626
+ reporter = self._hub._reporter(on_progress)
627
+ with self._hub._calls():
628
+ rollout = client.create_rollout(body)
629
+ spec = unset_to_none(rollout.battery_spec)
630
+ if spec is not None:
631
+ reporter.step(
632
+ created_line(
633
+ rollout.id,
634
+ unset_to_none(rollout.battery),
635
+ len(spec.cells),
636
+ unset_to_none(spec.seeds) or 0,
637
+ unset_to_none(spec.horizon_s) or 0.0,
638
+ )
639
+ )
640
+ if not local:
641
+ return Rollout.from_sdk(rollout, web=self._hub._web())
642
+ import socket
643
+ import tempfile
644
+
645
+ from lucen_cli.rollout.worker import RolloutWorkerConfig, execute_rollout
646
+
647
+ config = RolloutWorkerConfig(
648
+ name=(name or socket.gethostname() or "lucen-rollout")[:128],
649
+ work_dir=(
650
+ Path(work_dir).expanduser()
651
+ if work_dir is not None
652
+ else Path(tempfile.gettempdir()) / "lucen-rollout"
653
+ ),
654
+ keep_work=keep,
655
+ render=video,
656
+ )
657
+ claimed = client.claim_rollout(rollout.id, config.name)
658
+ reporter.step(f"Running the test suite on this machine, as {config.name}")
659
+ outcome = execute_rollout(client, claimed, config, reporter)
660
+ final = Rollout.from_sdk(client.get_rollout(rollout.id), web=self._hub._web())
661
+ if outcome.status != "succeeded":
662
+ reason = (outcome.error or "no reason recorded").splitlines()[0]
663
+ raise HubError(
664
+ f"Sim test {rollout.id} failed: {reason}",
665
+ detail=outcome.error,
666
+ fix=f"`hub.rollouts.get({rollout.id!r})` has the full reason; fix it and run "
667
+ "the same call again (the hub keeps the failed one).",
668
+ )
669
+ return final
670
+
671
+ def get(self, rollout_id: str) -> Rollout:
672
+ client = self._hub._auth()
673
+ with self._hub._calls():
674
+ return Rollout.from_sdk(client.get_rollout(rollout_id), web=self._hub._web())
675
+
676
+ def list(
677
+ self,
678
+ *,
679
+ status: str | None = None,
680
+ model: str | None = None,
681
+ run: str | None = None,
682
+ limit: int = 20,
683
+ ) -> builtins.list[Rollout]:
684
+ client = self._hub._auth()
685
+ with self._hub._calls():
686
+ rollouts = client.list_rollouts(
687
+ status=status, model_repo=model, run_id=run, limit=limit
688
+ )
689
+ web = self._hub._web() if rollouts else None
690
+ return [Rollout.from_sdk(rollout, web=web) for rollout in rollouts]
691
+
692
+ def cancel(self, rollout_id: str) -> Rollout:
693
+ client = self._hub._auth()
694
+ with self._hub._calls():
695
+ return Rollout.from_sdk(client.cancel_rollout(rollout_id), web=self._hub._web())
696
+
697
+
698
+ class Robots:
699
+ """`hub.robots`: `lucen robot init | validate | publish`, and a robot's card.
700
+
701
+ The three local commands compile the model with real MuJoCo, so they need
702
+ the `rollout` extra and raise `ImportError` naming the install line
703
+ without it; `card()` needs only the hub.
704
+ """
705
+
706
+ def __init__(self, hub: Hub) -> None:
707
+ self._hub = hub
708
+
709
+ def init(
710
+ self,
711
+ path: str | os.PathLike[str],
712
+ *,
713
+ control_hz: float,
714
+ action: str,
715
+ base: str | None = None,
716
+ action_scale: float | None = None,
717
+ action_clip: float | None = None,
718
+ kp: float | None = None,
719
+ kd: float | None = None,
720
+ armature: float | None = None,
721
+ friction_loss: float | None = None,
722
+ proprioception: Sequence[str] | None = None,
723
+ command: str | None = None,
724
+ keyframe: str | None = None,
725
+ default_pose: Mapping[str, float] | None = None,
726
+ discard_visual: bool = False,
727
+ root: str | os.PathLike[str] | None = None,
728
+ force: bool = False,
729
+ ) -> RobotDraft:
730
+ """Compile an MJCF or URDF and draft `robot_card.json` beside it (`lucen robot init`)."""
731
+ _require_engine("hub.robots.init")
732
+ from lucen_cli.cmd_robot import run_init
733
+
734
+ with self._hub._calls():
735
+ result = run_init(
736
+ Path(path).expanduser(),
737
+ control_hz=control_hz,
738
+ action=action,
739
+ base=base,
740
+ action_scale=action_scale,
741
+ action_clip=action_clip,
742
+ kp=kp,
743
+ kd=kd,
744
+ armature=armature,
745
+ friction_loss=friction_loss,
746
+ proprioception=",".join(proprioception) if proprioception else None,
747
+ command=command,
748
+ keyframe=keyframe,
749
+ default_pose=dict(default_pose) if default_pose else None,
750
+ discard_visual=discard_visual,
751
+ root=Path(root).expanduser() if root is not None else None,
752
+ force=force,
753
+ )
754
+ return RobotDraft.from_cli(result)
755
+
756
+ def validate(
757
+ self,
758
+ directory: str | os.PathLike[str] = ".",
759
+ *,
760
+ card: str | os.PathLike[str] | None = None,
761
+ settle_s: float = 3.0,
762
+ dynamics: bool = True,
763
+ ) -> RobotReport:
764
+ """The compile report on a directory's card (`lucen robot validate`); a fail is a value."""
765
+ _require_engine("hub.robots.validate")
766
+ from lucen_cli.robot.checks import validate as run_validate
767
+
768
+ with self._hub._calls():
769
+ report = run_validate(
770
+ Path(directory).expanduser().resolve(),
771
+ card_path=Path(card).expanduser() if card is not None else None,
772
+ settle_s=settle_s,
773
+ dynamics=dynamics,
774
+ )
775
+ return RobotReport.from_cli(report.to_dict())
776
+
777
+ def publish(
778
+ self,
779
+ repo: str,
780
+ directory: str | os.PathLike[str] = ".",
781
+ *,
782
+ public: bool = False,
783
+ description: str | None = None,
784
+ force: bool = False,
785
+ thumbnails: bool = True,
786
+ settle_s: float = 3.0,
787
+ jobs: int = 8,
788
+ on_progress: ProgressCallback | None = None,
789
+ ) -> RobotPublishResult:
790
+ """Validate, create the robot repo if missing, push the files, set the card. Idempotent."""
791
+ _require_engine("hub.robots.publish")
792
+ from lucen_cli.robot.checks import load_card, validate_card
793
+ from lucen_cli.robot.publish import publish as run_publish
794
+
795
+ client = self._hub._auth()
796
+ root = Path(directory).expanduser().resolve()
797
+ with self._hub._calls():
798
+ card, needs = load_card(root)
799
+ report = validate_card(root, card, needs, settle_s=settle_s)
800
+ result = run_publish(
801
+ client,
802
+ _target(repo),
803
+ root,
804
+ card,
805
+ report,
806
+ private=False if public else None,
807
+ description=description,
808
+ jobs=jobs,
809
+ reporter=self._hub._reporter(on_progress),
810
+ force=force,
811
+ thumbnails=thumbnails,
812
+ )
813
+ return RobotPublishResult.from_cli({"ok": True, **result.to_dict()})
814
+
815
+ def card(self, repo: str) -> dict[str, Any]:
816
+ """A robot repo's card as the hub stores it (`GET /v1/robots/{owner}/{repo}/card`)."""
817
+ with self._hub._calls():
818
+ return self._hub._client.get_robot_card(_target(repo)).to_dict()
819
+
820
+
821
+ class Orgs:
822
+ """`hub.orgs`: the organizations a lab shares (`lucen org`)."""
823
+
824
+ def __init__(self, hub: Hub) -> None:
825
+ self._hub = hub
826
+
827
+ def list(self) -> builtins.list[Org]:
828
+ """Every organization you belong to, with your role in each."""
829
+ client = self._hub._auth()
830
+ with self._hub._calls():
831
+ memberships = client.list_my_orgs()
832
+ web = self._hub._web() if memberships else None
833
+ return [Org.from_membership(membership, web=web) for membership in memberships]
834
+
835
+ def create(self, slug: str, *, name: str | None = None, description: str | None = None) -> Org:
836
+ """Create an organization; you become its first owner."""
837
+ client = self._hub._auth()
838
+ with self._hub._calls():
839
+ org = client.create_org(slug, display_name=name, description=description)
840
+ return Org.from_sdk(org, web=self._hub._web(), role="owner")
841
+
842
+ def members(self, org: str) -> builtins.list[Member]:
843
+ """An organization's members, owners first (members only)."""
844
+ client = self._hub._auth()
845
+ with self._hub._calls():
846
+ rows = client.list_org_members(org)
847
+ return sorted(
848
+ (Member.from_sdk(row) for row in rows),
849
+ key=lambda member: (member.role != "owner", member.username),
850
+ )
851
+
852
+ def add(self, org: str, username: str, *, role: str = "member") -> Member:
853
+ """Add a person by hub handle (they must have signed up first), or change their role."""
854
+ try:
855
+ org_role = OrgRole(role)
856
+ except ValueError as exc:
857
+ raise HubError(f"role={role!r}: expected 'member' or 'owner'.") from exc
858
+ client = self._hub._auth()
859
+ with self._hub._calls():
860
+ return Member.from_sdk(client.put_org_member(org, username, org_role))
861
+
862
+ def remove(self, org: str, username: str) -> None:
863
+ """Remove a member (owners only); `leave()` is how you remove yourself."""
864
+ client = self._hub._auth()
865
+ with self._hub._calls():
866
+ client.remove_org_member(org, username)
867
+
868
+ def leave(self, org: str) -> None:
869
+ """Leave an organization; the last owner must make someone else an owner first."""
870
+ client = self._hub._auth()
871
+ with self._hub._calls():
872
+ me = client.me().user.username
873
+ client.remove_org_member(org, me, leaving=True)
874
+
875
+
876
+ class Datasets:
877
+ """`hub.datasets`: what the hub reads out of a LeRobot-format dataset repo."""
878
+
879
+ def __init__(self, hub: Hub) -> None:
880
+ self._hub = hub
881
+
882
+ def episodes(self, repo: str) -> list[Episode]:
883
+ """Every episode of a dataset repo (index, frames, tasks); a public one needs no key."""
884
+ with self._hub._calls():
885
+ rows = self._hub._client.list_episodes(_target(repo))
886
+ return [Episode.from_sdk(row) for row in rows]
887
+
888
+
889
+ __all__ = ["ENGINE_FIX", "Datasets", "Hub", "Orgs", "Repos", "Robots", "Rollouts", "Runs"]