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/install.py ADDED
@@ -0,0 +1,422 @@
1
+ """``openral install`` — install opt-in dependency groups into the managed venv.
2
+
3
+ This subcommand is the post-install escape hatch for the Tier-0 curl-bash
4
+ installer (``scripts/install.sh``). The base install gives the user
5
+ ``openral`` on their ``$PATH`` with the CLI's own thin runtime; heavy / opt-in
6
+ extras (sim physics, LIBERO / MetaWorld / RoboCasa task suites, ROS 2 system
7
+ deps) ship separately and are layered in on demand.
8
+
9
+ Group taxonomy mirrors the workspace root ``pyproject.toml``
10
+ ``[dependency-groups]`` table — keep the two in sync. ``ros`` is special-cased
11
+ because it re-exec's ``scripts/bootstrap_ubuntu.sh`` (sudo + apt) rather than
12
+ calling ``uv pip install``; everything else is a pure-Python group resolved by
13
+ the workspace lockfile.
14
+
15
+ The libero ↔ robocasa exclusion declared in the root ``[tool.uv].conflicts``
16
+ table is enforced here as a typed ``ROSConfigError`` so users see the failure
17
+ at the CLI instead of as a solver-conflict from ``uv pip install``.
18
+
19
+ Examples:
20
+ Install the lightweight sim group (CPU-only physics)::
21
+
22
+ openral install sim
23
+
24
+ Install LIBERO (mutually exclusive with robocasa)::
25
+
26
+ openral install libero
27
+
28
+ Re-run the apt + ROS 2 + udev system bootstrap (needs sudo)::
29
+
30
+ openral install ros
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json as _json
36
+ import os
37
+ import shutil
38
+ import subprocess
39
+ import sys
40
+ from pathlib import Path
41
+ from typing import Final
42
+
43
+ import typer
44
+ from openral_core.exceptions import ROSConfigError
45
+ from rich.console import Console
46
+ from rich.table import Table
47
+
48
+ # ── Group → dependency-list mirror of root pyproject.toml ────────────────────
49
+ #
50
+ # These lists are duplicated from the workspace root ``pyproject.toml``
51
+ # ``[dependency-groups]`` table on purpose: the installer must work *before*
52
+ # the workspace is cloned (the curl-bash one-liner runs `uv tool install
53
+ # openral-cli` against PyPI, which does not see the root pyproject). When a
54
+ # group is added or a pin is bumped in the root file, mirror the change here
55
+ # in the same commit — the ``tests/unit/test_install_command.py`` real-
56
+ # components check loads the workspace pyproject when present and asserts the
57
+ # two are in lockstep.
58
+ #
59
+ # Each entry is exactly what would appear in ``[dependency-groups].<group>``;
60
+ # the installer hands them straight to ``uv pip install`` so PEP 508 markers
61
+ # are honoured.
62
+ _GROUPS: Final[dict[str, list[str]]] = {
63
+ "sim": [
64
+ "gym-aloha>=0.1.3",
65
+ "accelerate>=1.13.0",
66
+ "bitsandbytes>=0.45",
67
+ "gym-pusht>=0.1.6",
68
+ "gymnasium[mujoco]>=1.3.0",
69
+ "mujoco>=3.8.0",
70
+ "num2words>=0.5.14",
71
+ "pymunk<8",
72
+ "robot-descriptions>=1.12.0",
73
+ "transformers>=5.4.0,<5.14.0",
74
+ "lerobot[diffusion]",
75
+ ],
76
+ "libero": [
77
+ "lerobot[libero]",
78
+ "transformers>=5.4.0,<5.14.0",
79
+ "num2words>=0.5.14",
80
+ "bitsandbytes>=0.45",
81
+ "accelerate>=1.13.0",
82
+ ],
83
+ "metaworld": [
84
+ "lerobot",
85
+ "gymnasium[mujoco]>=1.3.0",
86
+ "mujoco>=3.8.0",
87
+ "transformers>=5.4.0,<5.14.0",
88
+ "accelerate>=1.13.0",
89
+ "num2words>=0.5.14",
90
+ ],
91
+ "maniskill3": [
92
+ "mani-skill>=3.0.0b9",
93
+ "gymnasium>=1.0.0",
94
+ "lerobot",
95
+ "transformers>=5.4.0,<5.14.0",
96
+ "accelerate>=1.13.0",
97
+ "num2words>=0.5.14",
98
+ ],
99
+ "simpler-env": [
100
+ "mani-skill>=3.0.0b9",
101
+ "gymnasium>=1.0.0",
102
+ ],
103
+ "robocasa": [
104
+ "robosuite>=1.5.2",
105
+ "h5py>=3.10",
106
+ "robosuite-models>=1.0.0",
107
+ "lxml>=5",
108
+ "lerobot",
109
+ "transformers>=5.4.0,<5.14.0",
110
+ "accelerate>=1.13.0",
111
+ "bitsandbytes>=0.45",
112
+ "num2words>=0.5.14",
113
+ "imageio[ffmpeg]>=2.34",
114
+ ],
115
+ "rldx": [
116
+ "pyzmq>=25",
117
+ "msgpack>=1",
118
+ ],
119
+ }
120
+
121
+ # Mutually exclusive groups — mirrors ``[tool.uv].conflicts`` in the workspace
122
+ # root pyproject.toml. Each entry is a frozenset of group names
123
+ # that cannot coexist in a single resolved environment.
124
+ _CONFLICTS: Final[tuple[frozenset[str], ...]] = (frozenset({"libero", "robocasa"}),)
125
+
126
+
127
+ install_app = typer.Typer(
128
+ name="install",
129
+ help=(
130
+ "Install opt-in dependency groups (sim, libero, metaworld, robocasa, "
131
+ "rldx, …) or re-run the ROS 2 system bootstrap."
132
+ ),
133
+ no_args_is_help=True,
134
+ )
135
+ console = Console()
136
+
137
+
138
+ def _detect_target_python() -> str:
139
+ """Return the absolute path of the Python interpreter ``uv pip`` should target.
140
+
141
+ Resolution order:
142
+ 1. ``OPENRAL_INSTALL_PYTHON`` env var — explicit override.
143
+ 2. ``sys.executable`` — the interpreter currently running the CLI.
144
+
145
+ The Tier-0 installer (``scripts/install.sh``) calls
146
+ ``uv tool install openral-cli``; that places the CLI in a uv-managed
147
+ tool venv whose ``sys.executable`` is exactly the interpreter we want
148
+ to install into. No PATH-sniffing heuristics needed.
149
+
150
+ Returns:
151
+ Absolute path to the target ``python3`` binary.
152
+ """
153
+ override = os.environ.get("OPENRAL_INSTALL_PYTHON")
154
+ if override:
155
+ return override
156
+ return sys.executable
157
+
158
+
159
+ def _ensure_uv() -> str:
160
+ """Return the path to a ``uv`` binary, raising ``ROSConfigError`` if absent.
161
+
162
+ ``uv`` is the only supported installer for this command — CLAUDE.md §4
163
+ forbids invoking ``pip`` directly inside the workspace.
164
+
165
+ Raises:
166
+ ROSConfigError: when ``uv`` is not on ``$PATH``.
167
+ """
168
+ uv = shutil.which("uv")
169
+ if uv is None:
170
+ raise ROSConfigError(
171
+ "`uv` not found on $PATH. Install it with "
172
+ "`curl -LsSf https://astral.sh/uv/install.sh | sh` "
173
+ "(the scripts/install.sh one-liner does this for you)."
174
+ )
175
+ return uv
176
+
177
+
178
+ def _check_conflicts(group: str, already_installed: frozenset[str]) -> None:
179
+ """Raise ``ROSConfigError`` if ``group`` conflicts with anything already in the venv.
180
+
181
+ Args:
182
+ group: Name of the group about to be installed.
183
+ already_installed: Names of groups previously installed into the
184
+ current target venv. Detected via importlib.metadata best-effort
185
+ in :func:`_detect_installed_groups`.
186
+
187
+ Raises:
188
+ ROSConfigError: when installing ``group`` would violate an entry in
189
+ :data:`_CONFLICTS`.
190
+ """
191
+ for conflict_set in _CONFLICTS:
192
+ if group in conflict_set:
193
+ collision = (conflict_set - {group}) & already_installed
194
+ if collision:
195
+ others = ", ".join(sorted(collision))
196
+ raise ROSConfigError(
197
+ f"Cannot install `{group}` — mutually exclusive with already-"
198
+ f"installed group(s): {others}. "
199
+ f"Use a separate venv (`uv venv .venv-{group}`) for the other "
200
+ f"group, or `openral install --force {group}` to override."
201
+ )
202
+
203
+
204
+ def _detect_installed_groups(python: str) -> frozenset[str]:
205
+ """Best-effort: probe the target venv for sentinel packages of each group.
206
+
207
+ Uses a one-shot subprocess call to ``importlib.metadata.distributions``
208
+ in the target interpreter rather than ``pkg_resources`` (deprecated) or
209
+ a hand-rolled site-packages walk. Returns the set of group names whose
210
+ sentinel package is present.
211
+
212
+ Sentinels are chosen to be the cheapest unambiguous proof a group was
213
+ installed — typically the package that drags the rest of the group in.
214
+
215
+ Args:
216
+ python: Absolute path to the target python interpreter.
217
+
218
+ Returns:
219
+ Frozen set of group names detected as installed.
220
+ """
221
+ sentinels = {
222
+ "sim": "gym-aloha",
223
+ "libero": "lerobot",
224
+ "metaworld": "lerobot",
225
+ "maniskill3": "mani-skill",
226
+ "simpler-env": "mani-skill",
227
+ "robocasa": "robosuite",
228
+ "rldx": "pyzmq",
229
+ }
230
+ probe = (
231
+ "import importlib.metadata as m, json, sys; "
232
+ "names = {d.metadata['Name'].lower() for d in m.distributions() "
233
+ "if d.metadata and d.metadata['Name']}; "
234
+ f"print(json.dumps([k for k,v in {sentinels!r}.items() if v.lower() in names]))"
235
+ )
236
+ try:
237
+ out = subprocess.run(
238
+ [python, "-c", probe],
239
+ check=True,
240
+ capture_output=True,
241
+ text=True,
242
+ timeout=15,
243
+ )
244
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
245
+ return frozenset()
246
+
247
+ try:
248
+ return frozenset(_json.loads(out.stdout.strip() or "[]"))
249
+ except _json.JSONDecodeError:
250
+ return frozenset()
251
+
252
+
253
+ def _run_uv_pip_install(group: str, python: str) -> int:
254
+ """Invoke ``uv pip install --python <python> <packages…>`` and stream output.
255
+
256
+ Args:
257
+ group: Dependency-group name; must be a key in :data:`_GROUPS`.
258
+ python: Absolute path to the target interpreter.
259
+
260
+ Returns:
261
+ The subprocess exit code (0 on success).
262
+ """
263
+ uv = _ensure_uv()
264
+ pkgs = _GROUPS[group]
265
+ cmd = [uv, "pip", "install", "--python", python, *pkgs]
266
+ console.print(f"[cyan]$ {' '.join(cmd)}[/cyan]")
267
+ proc = subprocess.run(cmd, check=False)
268
+ return proc.returncode
269
+
270
+
271
+ def _install_group(group: str, *, force: bool) -> None:
272
+ """Install one dependency group into the active managed venv.
273
+
274
+ Args:
275
+ group: Group name. Must be a key in :data:`_GROUPS`.
276
+ force: When ``True``, bypass the libero ↔ robocasa conflict check.
277
+
278
+ Raises:
279
+ ROSConfigError: when the group is unknown, when ``uv`` is missing, or
280
+ when a conflict is detected and ``force=False``.
281
+ """
282
+ if group not in _GROUPS:
283
+ known = ", ".join(sorted(_GROUPS))
284
+ raise ROSConfigError(f"Unknown group `{group}`. Known: {known}.")
285
+
286
+ python = _detect_target_python()
287
+ if not force:
288
+ installed = _detect_installed_groups(python)
289
+ _check_conflicts(group, installed)
290
+
291
+ rc = _run_uv_pip_install(group, python)
292
+ if rc != 0:
293
+ raise ROSConfigError(
294
+ f"`uv pip install` for group `{group}` exited {rc}. "
295
+ f"See the output above for the resolver error."
296
+ )
297
+ console.print(f"[green]✓ installed group `{group}` into {python}[/green]")
298
+
299
+
300
+ # ── Typer subcommands — one per supported group ──────────────────────────────
301
+
302
+
303
+ @install_app.command("sim")
304
+ def install_sim(
305
+ force: bool = typer.Option(False, "--force", help="Bypass conflict checks."),
306
+ ) -> None:
307
+ """Install the ``sim`` group (gym-aloha, gym-pusht, MuJoCo, bitsandbytes)."""
308
+ _install_group("sim", force=force)
309
+
310
+
311
+ @install_app.command("libero")
312
+ def install_libero(
313
+ force: bool = typer.Option(False, "--force", help="Bypass conflict checks."),
314
+ ) -> None:
315
+ """Install LIBERO (mutually exclusive with ``robocasa``)."""
316
+ _install_group("libero", force=force)
317
+
318
+
319
+ @install_app.command("metaworld")
320
+ def install_metaworld(
321
+ force: bool = typer.Option(False, "--force", help="Bypass conflict checks."),
322
+ ) -> None:
323
+ """Install the MetaWorld MT50 task suite (Sawyer scenes)."""
324
+ _install_group("metaworld", force=force)
325
+
326
+
327
+ @install_app.command("maniskill3")
328
+ def install_maniskill3(
329
+ force: bool = typer.Option(False, "--force", help="Bypass conflict checks."),
330
+ ) -> None:
331
+ """Install ManiSkill3 (SAPIEN GPU physics)."""
332
+ _install_group("maniskill3", force=force)
333
+
334
+
335
+ @install_app.command("simpler-env")
336
+ def install_simpler_env(
337
+ force: bool = typer.Option(False, "--force", help="Bypass conflict checks."),
338
+ ) -> None:
339
+ """Install the SimplerEnv real-to-sim correlator backend."""
340
+ _install_group("simpler-env", force=force)
341
+
342
+
343
+ @install_app.command("robocasa")
344
+ def install_robocasa(
345
+ force: bool = typer.Option(False, "--force", help="Bypass conflict checks."),
346
+ ) -> None:
347
+ """Install RoboCasa (mutually exclusive with ``libero``)."""
348
+ _install_group("robocasa", force=force)
349
+
350
+
351
+ @install_app.command("rldx")
352
+ def install_rldx(
353
+ force: bool = typer.Option(False, "--force", help="Bypass conflict checks."),
354
+ ) -> None:
355
+ """Install the RLDX-1 sidecar client (pyzmq + msgpack)."""
356
+ _install_group("rldx", force=force)
357
+
358
+
359
+ @install_app.command("ros")
360
+ def install_ros() -> None:
361
+ """Re-run the ROS 2 + system-package bootstrap (sudo + apt; Linux only).
362
+
363
+ Delegates to ``scripts/bootstrap_ubuntu.sh`` or ``scripts/bootstrap_macos.sh``
364
+ from a cloned workspace. Resolution order for the script path:
365
+
366
+ 1. ``OPENRAL_REPO_ROOT/scripts/bootstrap_<os>.sh`` if the env var is set.
367
+ 2. ``<cwd>/scripts/bootstrap_<os>.sh`` if a workspace is present.
368
+ 3. Print a clone hint and exit non-zero.
369
+
370
+ The Tier-0 curl-bash installer cannot ship apt packages without sudo,
371
+ so this subcommand is the only supported escalation path.
372
+
373
+ Raises:
374
+ ROSConfigError: when the bootstrap script cannot be located.
375
+ """
376
+ if sys.platform == "darwin":
377
+ script_name = "bootstrap_macos.sh"
378
+ elif sys.platform.startswith("linux"):
379
+ script_name = "bootstrap_ubuntu.sh"
380
+ else:
381
+ raise ROSConfigError(
382
+ f"`openral install ros` is only supported on Linux and macOS (detected {sys.platform})."
383
+ )
384
+
385
+ candidates: list[Path] = []
386
+ env_root = os.environ.get("OPENRAL_REPO_ROOT")
387
+ if env_root:
388
+ candidates.append(Path(env_root) / "scripts" / script_name)
389
+ candidates.append(Path.cwd() / "scripts" / script_name)
390
+
391
+ for path in candidates:
392
+ if path.is_file():
393
+ console.print(f"[yellow]about to run {path} (will prompt for sudo)[/yellow]")
394
+ proc = subprocess.run(["bash", str(path)], check=False)
395
+ if proc.returncode != 0:
396
+ raise ROSConfigError(f"{script_name} exited {proc.returncode}; see output above.")
397
+ console.print("[green]✓ system bootstrap complete[/green]")
398
+ return
399
+
400
+ raise ROSConfigError(
401
+ f"Could not locate {script_name}. Clone the openral repo and set "
402
+ f"OPENRAL_REPO_ROOT, or run from a workspace checkout:\n"
403
+ f" git clone https://github.com/OpenRAL/openral.git\n"
404
+ f" cd openral && openral install ros"
405
+ )
406
+
407
+
408
+ @install_app.command("list")
409
+ def install_list() -> None:
410
+ """List every group the installer knows about, with package counts."""
411
+ table = Table(title="openral install — known groups")
412
+ table.add_column("group", style="cyan")
413
+ table.add_column("packages", justify="right")
414
+ table.add_column("conflicts-with", style="yellow")
415
+ for group, pkgs in sorted(_GROUPS.items()):
416
+ conflicts = sorted(other for cs in _CONFLICTS if group in cs for other in (cs - {group}))
417
+ table.add_row(group, str(len(pkgs)), ", ".join(conflicts) or "—")
418
+ table.add_row("ros", "(sudo + apt)", "—")
419
+ console.print(table)
420
+
421
+
422
+ __all__ = ["install_app"]