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/__init__.py +6 -0
- openral_cli/_hf_publish.py +125 -0
- openral_cli/_rskill_doc_validator.py +562 -0
- openral_cli/_rskill_intel.py +334 -0
- openral_cli/_rskill_readme.py +217 -0
- openral_cli/_rskill_scaffolder.py +267 -0
- openral_cli/autodetect.py +464 -0
- openral_cli/check.py +397 -0
- openral_cli/collision.py +369 -0
- openral_cli/dataset.py +438 -0
- openral_cli/deploy_sim.py +2664 -0
- openral_cli/install.py +422 -0
- openral_cli/main.py +4108 -0
- openral_cli/prompt.py +128 -0
- openral_cli/py.typed +0 -0
- openral_cli/robot.py +301 -0
- openral_cli-0.1.0.dist-info/METADATA +33 -0
- openral_cli-0.1.0.dist-info/RECORD +20 -0
- openral_cli-0.1.0.dist-info/WHEEL +4 -0
- openral_cli-0.1.0.dist-info/entry_points.txt +2 -0
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"]
|