openral-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.
openral_cli/dataset.py ADDED
@@ -0,0 +1,438 @@
1
+ """``openral dataset`` Typer app.
2
+
3
+ Subcommands:
4
+
5
+ * ``push <root>`` — upload a local LeRobotDataset v3 to the HF Hub.
6
+ Always ``private=True``; a typed consent prompt discloses the PII risks
7
+ (faces / room layouts / biometric joint trajectories) before any
8
+ network call. ``--yes`` or ``OPENRAL_DATASET_CONSENT=1`` skips the
9
+ prompt for CI / scripted invocations; ``--dry-run`` short-circuits
10
+ the consent gate entirely and just validates the dataset.
11
+
12
+ The PR4 ``from-bag`` subcommand will be added in the rosbag2 converter
13
+ PR. ``push`` ships first because it has no ROS dependency surface and
14
+ unblocks the dataset-flywheel demo regardless of rosbag2 readiness.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import Final
24
+
25
+ import structlog
26
+ import typer
27
+ from openral_core.exceptions import ROSConfigError, ROSError
28
+ from rich.console import Console
29
+ from rich.panel import Panel
30
+
31
+ from openral_cli._hf_publish import IGNORE_PATTERNS, ensure_private, resolve_token
32
+
33
+ __all__ = ["dataset_app"]
34
+
35
+ _log = structlog.get_logger(__name__)
36
+ _console = Console()
37
+
38
+ # Env-var override for the consent prompt. Lets CI runs / scripted
39
+ # uploads avoid the typer.confirm() interactive prompt.
40
+ _CONSENT_ENV_VAR: Final[str] = "OPENRAL_DATASET_CONSENT"
41
+
42
+
43
+ dataset_app = typer.Typer(
44
+ name="dataset",
45
+ help=(
46
+ "Publish or convert OpenRAL datasets.\n"
47
+ "\n"
48
+ "Commands:\n"
49
+ " push — upload a local LeRobotDataset v3 to the HF Hub (private; consent-gated).\n"
50
+ " from-bag — convert an mcap rosbag2 written by Rosbag2Sink into a LeRobotDataset v3."
51
+ ),
52
+ no_args_is_help=True,
53
+ add_completion=False,
54
+ )
55
+
56
+
57
+ # ── from-bag subcommand (PR4) ────────────────────────────────────────────────
58
+
59
+
60
+ @dataset_app.command("from-bag")
61
+ def from_bag_command(
62
+ bag_path: Path = typer.Argument(
63
+ ...,
64
+ help="Input .mcap bag written by openral_dataset.Rosbag2Sink.",
65
+ exists=True,
66
+ file_okay=True,
67
+ dir_okay=False,
68
+ resolve_path=True,
69
+ ),
70
+ robot_yaml: Path = typer.Option(
71
+ ...,
72
+ "--robot",
73
+ help=(
74
+ "Path to a RobotDescription robot.yaml — used to bind feature "
75
+ "shapes (state_shape from ObservationSpec, dim from ActionSpec, "
76
+ "camera keys from SensorSpec.vla_feature_key). Must match the "
77
+ "robot used at record time."
78
+ ),
79
+ exists=True,
80
+ file_okay=True,
81
+ dir_okay=False,
82
+ resolve_path=True,
83
+ ),
84
+ output_root: Path = typer.Option(
85
+ ...,
86
+ "--output",
87
+ "-o",
88
+ help=(
89
+ "Destination LeRobotDataset v3 root directory. Must NOT pre-exist "
90
+ "(lerobot v3 refuses to write into a populated root)."
91
+ ),
92
+ resolve_path=True,
93
+ ),
94
+ repo_id: str | None = typer.Option(
95
+ None,
96
+ "--repo-id",
97
+ help=(
98
+ "Override the resulting dataset's repo_id. Defaults to openral/dataset-<robot_name>."
99
+ ),
100
+ ),
101
+ license_str: str = typer.Option(
102
+ "CC-BY-4.0",
103
+ "--license",
104
+ help="SPDX license string for the produced dataset.",
105
+ ),
106
+ fps: float | None = typer.Option(
107
+ None,
108
+ "--fps",
109
+ help=(
110
+ "Frames-per-second for the produced dataset. Defaults to "
111
+ "robot.action_spec.control_freq_hz or 30.0."
112
+ ),
113
+ ),
114
+ ) -> None:
115
+ """Convert an mcap rosbag2 written by `Rosbag2Sink` into a LeRobotDataset v3.
116
+
117
+ Walks the bag's `/openral/episode` markers to segment episodes,
118
+ replays each tick through `RolloutRecorder` → `LeRobotDatasetSink`,
119
+ and produces an on-disk v3 dataset ready for `openral dataset push`.
120
+
121
+ Per CLAUDE.md §1.11 — this command uses real `mcap` and real
122
+ `lerobot.datasets.LeRobotDataset.create` end-to-end; no mocks.
123
+ """
124
+ from openral_core import RobotDescription
125
+ from openral_core.exceptions import ROSError
126
+ from openral_dataset.converter import Rosbag2ToLeRobotConverter
127
+
128
+ try:
129
+ robot = RobotDescription.from_yaml(str(robot_yaml))
130
+ except (OSError, ROSError) as exc:
131
+ _console.print(f"[red]robot.yaml error:[/red] {exc}")
132
+ raise typer.Exit(code=1) from exc
133
+
134
+ try:
135
+ summary = Rosbag2ToLeRobotConverter.from_bag(
136
+ bag_path=bag_path,
137
+ robot=robot,
138
+ output_root=output_root,
139
+ repo_id=repo_id,
140
+ license=license_str,
141
+ fps=fps,
142
+ )
143
+ except ROSError as exc:
144
+ _console.print(f"[red]conversion error:[/red] {exc}")
145
+ raise typer.Exit(code=1) from exc
146
+
147
+ _console.print(
148
+ f"[green]converted:[/green] {summary.output_root}\n"
149
+ f" episodes : {summary.n_episodes} "
150
+ f"({summary.n_success} success / {summary.n_episodes - summary.n_success} failure)\n"
151
+ f" frames : {summary.n_frames}\n"
152
+ f" repo_id : {summary.repo_id}\n"
153
+ f" publish : openral dataset push {summary.output_root} --yes"
154
+ )
155
+
156
+
157
+ # ── push subcommand ──────────────────────────────────────────────────────────
158
+
159
+
160
+ _CONSENT_PROMPT_BODY = """
161
+ You are about to upload a robot dataset to the Hugging Face Hub.
162
+
163
+ Dataset : {repo_id}
164
+ Local root : {root}
165
+ Visibility : PRIVATE (mandatory — OpenRAL never publishes public datasets automatically)
166
+ Episodes : {n_episodes}
167
+ Frames : {n_frames}
168
+ Cameras : {cameras}
169
+ License : {license}
170
+
171
+ This dataset may contain PERSONAL DATA:
172
+ - faces, bystanders, or other identifiable people in camera frames
173
+ - room layouts / posters / screens that disclose location or identity
174
+ - voice prints if audio sensors were recorded
175
+ - joint trajectories that may identify the operator (biometric)
176
+
177
+ You are responsible for:
178
+ - obtaining consent from anyone visible in camera frames
179
+ - the license you set ({license}) — PII-bearing data needs a stricter license
180
+ - regulatory compliance (GDPR / CCPA / equivalent) in your jurisdiction
181
+
182
+ The dataset's `meta/info.json` already records the license, episode count,
183
+ and success rate. The HF Hub repo will mirror them.
184
+ """.strip()
185
+
186
+
187
+ def _read_info_json(root: Path) -> dict[str, object]:
188
+ """Read ``meta/info.json`` from a local LeRobotDataset v3 root.
189
+
190
+ Raises:
191
+ ROSConfigError: When the file is missing or unparseable. A clean
192
+ ``openral dataset push <root>`` failure here means the user
193
+ pointed at a non-dataset path; we surface that loudly.
194
+ """
195
+ info_path = root / "meta" / "info.json"
196
+ if not info_path.is_file():
197
+ raise ROSConfigError(
198
+ f"{root} does not look like a LeRobotDataset v3 root "
199
+ f"(missing meta/info.json). "
200
+ "Did you mean to pass the directory written by "
201
+ "`openral sim run --dataset-out <path>`?"
202
+ )
203
+ try:
204
+ parsed = json.loads(info_path.read_text())
205
+ except json.JSONDecodeError as exc:
206
+ raise ROSConfigError(f"meta/info.json at {info_path} is not valid JSON: {exc!s}") from exc
207
+ if not isinstance(parsed, dict):
208
+ raise ROSConfigError(
209
+ f"meta/info.json at {info_path} is not a JSON object (got {type(parsed).__name__})"
210
+ )
211
+ return parsed
212
+
213
+
214
+ def _camera_keys_from_info(info: dict[str, object]) -> list[str]:
215
+ """Extract ``observation.images.*`` feature keys from a v3 info.json."""
216
+ features = info.get("features", {})
217
+ if not isinstance(features, dict):
218
+ return []
219
+ return sorted(k for k in features if k.startswith("observation.images."))
220
+
221
+
222
+ def _confirm_consent(repo_id: str, root: Path, info: dict[str, object], yes: bool) -> None:
223
+ """Display the PII / consent disclosure and require an explicit confirmation.
224
+
225
+ Acceptable confirmations:
226
+ - ``--yes`` flag passed to ``push``
227
+ - ``OPENRAL_DATASET_CONSENT=1`` env var set
228
+ - Interactive prompt where the user retypes the repo_id
229
+
230
+ Raises:
231
+ ROSConfigError: On any refusal / mismatch / non-interactive
232
+ invocation without the override.
233
+ """
234
+ if yes:
235
+ _log.info("dataset.push.consent.flag_override", repo_id=repo_id)
236
+ return
237
+ if os.environ.get(_CONSENT_ENV_VAR) == "1":
238
+ _log.info("dataset.push.consent.env_override", repo_id=repo_id)
239
+ return
240
+
241
+ metadata = info.get("metadata", {})
242
+ license_str = (
243
+ metadata.get("license", "CC-BY-4.0") if isinstance(metadata, dict) else "CC-BY-4.0"
244
+ )
245
+ cameras = _camera_keys_from_info(info)
246
+ body = _CONSENT_PROMPT_BODY.format(
247
+ repo_id=repo_id,
248
+ root=str(root),
249
+ n_episodes=info.get("total_episodes", "?"),
250
+ n_frames=info.get("total_frames", "?"),
251
+ cameras=", ".join(cameras) or "(none)",
252
+ license=license_str,
253
+ )
254
+ _console.print(Panel(body, title="Consent required", border_style="yellow"))
255
+
256
+ if not sys.stdin.isatty():
257
+ raise ROSConfigError(
258
+ "non-interactive stdin: pass --yes or set OPENRAL_DATASET_CONSENT=1 "
259
+ "to skip the consent prompt in CI / scripted runs"
260
+ )
261
+
262
+ prompt = f"Type the dataset repo_id ({repo_id!r}) to confirm:"
263
+ typed = typer.prompt(prompt, default="", show_default=False)
264
+ if typed.strip() != repo_id:
265
+ raise ROSConfigError(f"consent refused: typed {typed!r} did not match repo_id {repo_id!r}")
266
+
267
+
268
+ def _resolve_repo_id(root: Path, info: dict[str, object], cli_repo_id: str | None) -> str:
269
+ """Resolve the target repo_id, preferring CLI override → meta/info.json → default.
270
+
271
+ Returns:
272
+ A non-empty ``<org>/<name>`` string. Raises if the resolved id
273
+ is malformed (missing ``/`` separator).
274
+
275
+ Raises:
276
+ ROSConfigError: When no repo_id can be resolved or the format
277
+ is invalid.
278
+ """
279
+ if cli_repo_id is not None:
280
+ repo_id = cli_repo_id
281
+ else:
282
+ metadata = info.get("metadata", {})
283
+ if isinstance(metadata, dict) and metadata.get("repo_id"):
284
+ repo_id = str(metadata["repo_id"])
285
+ else:
286
+ raise ROSConfigError(
287
+ f"no repo_id found in {root}/meta/info.json[metadata][repo_id]; "
288
+ "pass --repo-id <org>/<name> on the command line"
289
+ )
290
+ if "/" not in repo_id or repo_id.startswith("/") or repo_id.endswith("/"):
291
+ raise ROSConfigError(f"invalid repo_id {repo_id!r}: must be of the form '<org>/<name>'")
292
+ return repo_id
293
+
294
+
295
+ @dataset_app.command("push")
296
+ def push_command(
297
+ root: Path = typer.Argument(
298
+ ...,
299
+ help=(
300
+ "Local LeRobotDataset v3 root directory. Must contain meta/info.json "
301
+ "(typically produced by `openral sim run --dataset-out <path>`)."
302
+ ),
303
+ exists=True,
304
+ file_okay=False,
305
+ dir_okay=True,
306
+ resolve_path=True,
307
+ ),
308
+ repo_id: str | None = typer.Option(
309
+ None,
310
+ "--repo-id",
311
+ help=(
312
+ "HF Hub repo id (e.g. openral/dataset-pick-cube). Overrides "
313
+ "meta/info.json[metadata][repo_id]; required when info.json "
314
+ "doesn't carry one."
315
+ ),
316
+ ),
317
+ yes: bool = typer.Option(
318
+ False,
319
+ "--yes",
320
+ "-y",
321
+ help=(
322
+ "Skip the interactive consent prompt. Required for non-TTY runs "
323
+ "(or set OPENRAL_DATASET_CONSENT=1 in the environment)."
324
+ ),
325
+ ),
326
+ dry_run: bool = typer.Option(
327
+ False,
328
+ "--dry-run",
329
+ help=(
330
+ "Validate the dataset and resolve the consent gate, but do "
331
+ "NOT contact the HF Hub. Useful for CI smoke checks."
332
+ ),
333
+ ),
334
+ token: str | None = typer.Option(
335
+ None,
336
+ "--token",
337
+ help=(
338
+ "HF token with 'repo.write' scope. Defaults to $HF_TOKEN / "
339
+ "$HUGGINGFACE_HUB_TOKEN env var."
340
+ ),
341
+ ),
342
+ commit_message: str | None = typer.Option(
343
+ None,
344
+ "--message",
345
+ "-m",
346
+ help="Commit message for the upload. Defaults to a structured auto-message.",
347
+ ),
348
+ ) -> None:
349
+ """Upload a local LeRobotDataset v3 to the HF Hub (private; consent-gated).
350
+
351
+ Reads ``meta/info.json`` to determine the default repo_id and the
352
+ fields shown in the consent prompt (episode count, frame count,
353
+ camera keys, license). The local file system is the source of
354
+ truth — the HF Hub repo created here is a mirror.
355
+
356
+ The repo is **always** created with ``private=True``. A safety gate
357
+ re-fetches the repo metadata after creation and aborts if the API
358
+ reports it as public. The "discard vs persist + tag" decision is
359
+ deliberate; this command is the one place where user consent gates
360
+ whether dataset persistence becomes dataset publication.
361
+ """
362
+ try:
363
+ info = _read_info_json(root)
364
+ resolved_repo_id = _resolve_repo_id(root, info, repo_id)
365
+ _confirm_consent(resolved_repo_id, root, info, yes=yes)
366
+ except ROSError as exc:
367
+ _console.print(f"[red]config error:[/red] {exc}")
368
+ raise typer.Exit(code=1) from exc
369
+
370
+ if dry_run:
371
+ _console.print(
372
+ f"[green]dry-run OK[/green] — would publish {resolved_repo_id!r} from {root}"
373
+ )
374
+ return
375
+
376
+ try:
377
+ resolved_token = resolve_token(token)
378
+ except ROSError as exc:
379
+ _console.print(f"[red]config error:[/red] {exc}")
380
+ raise typer.Exit(code=1) from exc
381
+
382
+ try:
383
+ from huggingface_hub import HfApi
384
+ except ImportError as exc:
385
+ _console.print(
386
+ "[red]config error:[/red] huggingface_hub is not installed. "
387
+ "Run `uv pip install huggingface_hub` and retry."
388
+ )
389
+ raise typer.Exit(code=1) from exc
390
+
391
+ api = HfApi(token=resolved_token)
392
+
393
+ try:
394
+ repo_url = api.create_repo(
395
+ repo_id=resolved_repo_id,
396
+ repo_type="dataset",
397
+ private=True,
398
+ exist_ok=True,
399
+ )
400
+ except Exception as exc: # HF SDK raises many specific types
401
+ err_str = str(exc)
402
+ if "403" in err_str or "rights" in err_str.lower():
403
+ _console.print(
404
+ "[red]token insufficient:[/red] HF token does not have "
405
+ "'repo.write' scope. Regenerate with Write permission at "
406
+ "https://huggingface.co/settings/tokens."
407
+ )
408
+ else:
409
+ _console.print(f"[red]create_repo failed:[/red] {err_str}")
410
+ raise typer.Exit(code=1) from exc
411
+
412
+ try:
413
+ ensure_private(api, resolved_repo_id, repo_type="dataset")
414
+ except ROSError as exc:
415
+ _console.print(f"[red]privacy gate:[/red] {exc}")
416
+ raise typer.Exit(code=1) from exc
417
+
418
+ message = commit_message or (
419
+ f"chore(dataset): publish {resolved_repo_id} "
420
+ f"({info.get('total_episodes', '?')} episodes, "
421
+ f"{info.get('total_frames', '?')} frames)"
422
+ )
423
+ try:
424
+ api.upload_folder(
425
+ folder_path=str(root),
426
+ repo_id=resolved_repo_id,
427
+ repo_type="dataset",
428
+ ignore_patterns=IGNORE_PATTERNS,
429
+ commit_message=message,
430
+ )
431
+ except Exception as exc:
432
+ _console.print(f"[red]upload failed:[/red] {exc}")
433
+ raise typer.Exit(code=1) from exc
434
+
435
+ _console.print(
436
+ f"[green]published:[/green] {repo_url} (private)\n"
437
+ f" load via: LeRobotDataset({resolved_repo_id!r})"
438
+ )