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.
- lucen/__init__.py +72 -0
- lucen/errors.py +141 -0
- lucen/hub.py +889 -0
- lucen/progress.py +96 -0
- lucen/py.typed +0 -0
- lucen/results.py +885 -0
- lucen_cli/__init__.py +3 -0
- lucen_cli/checkpoint_dir.py +226 -0
- lucen_cli/checkpoint_publisher.py +269 -0
- lucen_cli/client.py +1730 -0
- lucen_cli/cmd_auth.py +256 -0
- lucen_cli/cmd_datasets.py +190 -0
- lucen_cli/cmd_holds.py +76 -0
- lucen_cli/cmd_hub.py +245 -0
- lucen_cli/cmd_model.py +175 -0
- lucen_cli/cmd_org.py +154 -0
- lucen_cli/cmd_quickstart.py +320 -0
- lucen_cli/cmd_repo.py +87 -0
- lucen_cli/cmd_robot.py +784 -0
- lucen_cli/cmd_rollout.py +576 -0
- lucen_cli/cmd_runs.py +615 -0
- lucen_cli/cmd_serve.py +438 -0
- lucen_cli/cmd_worker.py +135 -0
- lucen_cli/config.py +268 -0
- lucen_cli/context.py +43 -0
- lucen_cli/contract_derive.py +491 -0
- lucen_cli/embodiment.py +522 -0
- lucen_cli/errors.py +210 -0
- lucen_cli/gate_scorer.py +168 -0
- lucen_cli/group.py +104 -0
- lucen_cli/hf_dataset.py +696 -0
- lucen_cli/live.py +189 -0
- lucen_cli/local.py +160 -0
- lucen_cli/main.py +277 -0
- lucen_cli/model_card.py +523 -0
- lucen_cli/offscreen.py +52 -0
- lucen_cli/onnx_manifest.py +121 -0
- lucen_cli/onnx_writer.py +313 -0
- lucen_cli/ort_quiet.py +75 -0
- lucen_cli/output.py +868 -0
- lucen_cli/progress.py +133 -0
- lucen_cli/pull.py +158 -0
- lucen_cli/push.py +384 -0
- lucen_cli/rl/__init__.py +8 -0
- lucen_cli/rl/ppo.py +316 -0
- lucen_cli/rl/tasks.py +298 -0
- lucen_cli/rl/vecenv.py +246 -0
- lucen_cli/robot/__init__.py +66 -0
- lucen_cli/robot/checks.py +1416 -0
- lucen_cli/robot/confine.py +176 -0
- lucen_cli/robot/draft.py +523 -0
- lucen_cli/robot/hf.py +218 -0
- lucen_cli/robot/hub.py +227 -0
- lucen_cli/robot/intake.py +590 -0
- lucen_cli/robot/model.py +334 -0
- lucen_cli/robot/pose.py +132 -0
- lucen_cli/robot/publish.py +197 -0
- lucen_cli/robot/refs.py +212 -0
- lucen_cli/robot/thumbnail.py +586 -0
- lucen_cli/robot/urdf.py +435 -0
- lucen_cli/robot/worker.py +599 -0
- lucen_cli/robot_assets.py +127 -0
- lucen_cli/rollout/__init__.py +12 -0
- lucen_cli/rollout/assets.py +135 -0
- lucen_cli/rollout/conditions.py +486 -0
- lucen_cli/rollout/contract.py +314 -0
- lucen_cli/rollout/engine.py +1808 -0
- lucen_cli/rollout/harness.py +201 -0
- lucen_cli/rollout/obs.py +262 -0
- lucen_cli/rollout/plant.py +456 -0
- lucen_cli/rollout/posture.py +96 -0
- lucen_cli/rollout/video.py +548 -0
- lucen_cli/rollout/worker.py +505 -0
- lucen_cli/serve/__init__.py +22 -0
- lucen_cli/serve/lerobot_backend.py +337 -0
- lucen_cli/serve/msgpack_numpy.py +59 -0
- lucen_cli/serve/policy.py +276 -0
- lucen_cli/serve/server.py +474 -0
- lucen_cli/serve/session.py +142 -0
- lucen_cli/serve/stub_skill.py +264 -0
- lucen_cli/serve/vla.py +788 -0
- lucen_cli/serve/worker.py +578 -0
- lucen_cli/sim_words.py +55 -0
- lucen_cli/state.py +237 -0
- lucen_cli/templates/__init__.py +94 -0
- lucen_cli/templates/ppo_isaac.py +914 -0
- lucen_cli/templates/ppo_mujoco.py +664 -0
- lucen_cli/templates/smolvla_lora.py +986 -0
- lucen_cli/templates/stub_ppo.py +597 -0
- lucen_cli/templates/stub_skill.py +180 -0
- lucen_cli/tfevents.py +323 -0
- lucen_cli/training_curve.py +70 -0
- lucen_cli/transfer.py +209 -0
- lucen_cli/vla_manifest.py +322 -0
- lucen_cli/worker.py +1433 -0
- lucen_cli-0.1.0.dist-info/METADATA +113 -0
- lucen_cli-0.1.0.dist-info/RECORD +100 -0
- lucen_cli-0.1.0.dist-info/WHEEL +4 -0
- lucen_cli-0.1.0.dist-info/entry_points.txt +3 -0
- 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"]
|