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/check.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""``openral check`` — static validation of the declarative robot/skill/scene set.
|
|
2
|
+
|
|
3
|
+
Loads every ``robots/*/robot.yaml``, ``rskills/*/rskill.yaml``, and
|
|
4
|
+
``scenes/{deploy,sim,benchmark}/*.yaml`` and cross-validates them in one pass:
|
|
5
|
+
every manifest parses, every ``file:`` / ``ros2://`` asset ref resolves, every
|
|
6
|
+
scene ``robot_id`` resolves to a real robot directory, every rSkill's embodiment
|
|
7
|
+
tags reach at least one in-repo robot, and every sensor ``parent_frame`` is a
|
|
8
|
+
declared tf2 frame. No schema change — pure reuse of the existing Pydantic
|
|
9
|
+
contracts (``RobotDescription.from_yaml`` / ``resolve_asset`` / the scene tiers).
|
|
10
|
+
|
|
11
|
+
A static, host-independent lint (no hardware detection), complementing the
|
|
12
|
+
host-specific ``openral rskill check`` compatibility report. JSON-Schema emission
|
|
13
|
+
for the manifests lives in ``tools/schema_export.py`` (CI-gated via
|
|
14
|
+
``quality.yml``), not here.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import datetime
|
|
20
|
+
import re
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Literal
|
|
23
|
+
|
|
24
|
+
import typer
|
|
25
|
+
import yaml
|
|
26
|
+
from openral_core.assets import AssetRefError, resolve_asset
|
|
27
|
+
from openral_core.exceptions import ROSError
|
|
28
|
+
from openral_core.schemas import (
|
|
29
|
+
BenchmarkScene,
|
|
30
|
+
DeployScene,
|
|
31
|
+
RobotDescription,
|
|
32
|
+
RSkillManifest,
|
|
33
|
+
SimScene,
|
|
34
|
+
)
|
|
35
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
36
|
+
from rich.console import Console
|
|
37
|
+
from rich.table import Table
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"CheckFinding",
|
|
41
|
+
"GraphCheckReport",
|
|
42
|
+
"check_command",
|
|
43
|
+
"check_description_graph",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
_console = Console()
|
|
47
|
+
|
|
48
|
+
# ── Graph validation ────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
CheckRule = Literal[
|
|
51
|
+
"robot_parse",
|
|
52
|
+
"rskill_parse",
|
|
53
|
+
"scene_parse",
|
|
54
|
+
"asset_ref",
|
|
55
|
+
"scene_robot_id",
|
|
56
|
+
"embodiment_reach",
|
|
57
|
+
"frames",
|
|
58
|
+
]
|
|
59
|
+
Severity = Literal["error", "warning"]
|
|
60
|
+
|
|
61
|
+
# Asset-ref prefixes that download a package or need a sim-only dep; skipped by
|
|
62
|
+
# default so the check runs offline in CI. ``file:`` / ``ros2://`` resolve
|
|
63
|
+
# locally and are always checked.
|
|
64
|
+
_REMOTE_ASSET_PREFIXES = ("rd:", "gym_aloha:", "openarm:", "menagerie:")
|
|
65
|
+
|
|
66
|
+
_SCENE_TIERS: dict[str, type[DeployScene]] = {
|
|
67
|
+
"deploy": DeployScene,
|
|
68
|
+
"sim": SimScene,
|
|
69
|
+
"benchmark": BenchmarkScene,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# Exceptions a ``from_yaml`` / ``model_validate`` may raise for a bad manifest.
|
|
73
|
+
_LOAD_ERRORS = (ValidationError, ROSError, OSError, ValueError, yaml.YAMLError)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class CheckFinding(BaseModel):
|
|
77
|
+
"""One problem surfaced by :func:`check_description_graph`."""
|
|
78
|
+
|
|
79
|
+
model_config = ConfigDict(extra="forbid")
|
|
80
|
+
|
|
81
|
+
rule: CheckRule
|
|
82
|
+
severity: Severity
|
|
83
|
+
target: str
|
|
84
|
+
message: str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class GraphCheckReport(BaseModel):
|
|
88
|
+
"""Typed result of ``openral check graph`` (also the ``--json`` payload)."""
|
|
89
|
+
|
|
90
|
+
model_config = ConfigDict(extra="forbid")
|
|
91
|
+
|
|
92
|
+
schema_version: Literal["0.1"] = "0.1"
|
|
93
|
+
generated_at: str
|
|
94
|
+
n_robots: int = 0
|
|
95
|
+
n_rskills: int = 0
|
|
96
|
+
n_scenes: int = 0
|
|
97
|
+
findings: list[CheckFinding] = Field(default_factory=list)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def errors(self) -> list[CheckFinding]:
|
|
101
|
+
"""Findings that fail the check."""
|
|
102
|
+
return [f for f in self.findings if f.severity == "error"]
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def warnings(self) -> list[CheckFinding]:
|
|
106
|
+
"""Findings that are advisory (do not fail unless ``--strict``)."""
|
|
107
|
+
return [f for f in self.findings if f.severity == "warning"]
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def ok(self) -> bool:
|
|
111
|
+
"""True when there are no error-severity findings."""
|
|
112
|
+
return not self.errors
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _now() -> str:
|
|
116
|
+
return datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _error(rule: CheckRule, target: str, message: str) -> CheckFinding:
|
|
120
|
+
return CheckFinding(rule=rule, severity="error", target=target, message=message)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _warning(rule: CheckRule, target: str, message: str) -> CheckFinding:
|
|
124
|
+
return CheckFinding(rule=rule, severity="warning", target=target, message=message)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _exc(exc: Exception) -> str:
|
|
128
|
+
return f"{type(exc).__name__}: {exc}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _asset_refs(robot: RobotDescription) -> list[tuple[Literal["urdf", "mjcf", "srdf"], str]]:
|
|
132
|
+
"""Collect the (kind, ref) asset references declared on a robot."""
|
|
133
|
+
refs: list[tuple[Literal["urdf", "mjcf", "srdf"], str]] = []
|
|
134
|
+
assets = robot.assets
|
|
135
|
+
if assets.urdf is not None:
|
|
136
|
+
refs.append(("urdf", assets.urdf.ref))
|
|
137
|
+
if assets.mjcf is not None:
|
|
138
|
+
refs.append(("mjcf", assets.mjcf))
|
|
139
|
+
if assets.srdf is not None:
|
|
140
|
+
refs.append(("srdf", assets.srdf))
|
|
141
|
+
return refs
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
_URDF_LINK_RE = re.compile(r'<link\s+name="([^"]+)"')
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _urdf_link_names(urdf_path: Path) -> set[str]:
|
|
148
|
+
"""Link names declared in a URDF — the tf frames robot_state_publisher emits."""
|
|
149
|
+
try:
|
|
150
|
+
text = urdf_path.read_text(encoding="utf-8")
|
|
151
|
+
except OSError:
|
|
152
|
+
return set()
|
|
153
|
+
return set(_URDF_LINK_RE.findall(text))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _declared_frames(robot: RobotDescription, manifest_dir: Path) -> set[str]:
|
|
157
|
+
"""tf2 frames a robot's manifest + local URDF declare.
|
|
158
|
+
|
|
159
|
+
``robot_state_publisher`` emits one tf frame per URDF link, so a local
|
|
160
|
+
``file:`` URDF is the authoritative frame source. The manifest's
|
|
161
|
+
base/odom/map frames, joint links, and sensor frames augment it (and are the
|
|
162
|
+
only source for sim-only robots whose URDF is a remote ``rd:`` ref we don't
|
|
163
|
+
resolve offline).
|
|
164
|
+
"""
|
|
165
|
+
frames = {robot.base_frame, robot.odom_frame, robot.map_frame}
|
|
166
|
+
for joint in robot.joints:
|
|
167
|
+
frames.add(joint.parent_link)
|
|
168
|
+
frames.add(joint.child_link)
|
|
169
|
+
for sensor in robot.sensors:
|
|
170
|
+
frames.add(sensor.frame_id)
|
|
171
|
+
urdf = robot.assets.urdf
|
|
172
|
+
if urdf is not None:
|
|
173
|
+
if urdf.root_frame is not None:
|
|
174
|
+
frames.add(urdf.root_frame)
|
|
175
|
+
if urdf.ref.startswith("file:"):
|
|
176
|
+
try:
|
|
177
|
+
urdf_path = resolve_asset(urdf.ref, "urdf", manifest_dir=manifest_dir)
|
|
178
|
+
except AssetRefError:
|
|
179
|
+
urdf_path = None
|
|
180
|
+
if urdf_path is not None:
|
|
181
|
+
frames |= _urdf_link_names(urdf_path)
|
|
182
|
+
return frames
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _check_robots(
|
|
186
|
+
repo_root: Path, *, resolve_remote_assets: bool
|
|
187
|
+
) -> tuple[dict[str, RobotDescription], set[str], list[CheckFinding]]:
|
|
188
|
+
"""Parse every robot manifest, resolve its asset refs, and check sensor frames."""
|
|
189
|
+
findings: list[CheckFinding] = []
|
|
190
|
+
robots: dict[str, RobotDescription] = {}
|
|
191
|
+
robot_tags: set[str] = set()
|
|
192
|
+
robots_dir = repo_root / "robots"
|
|
193
|
+
for path in sorted(robots_dir.glob("*/robot.yaml")) if robots_dir.is_dir() else []:
|
|
194
|
+
robot_id = path.parent.name
|
|
195
|
+
try:
|
|
196
|
+
robot = RobotDescription.from_yaml(str(path))
|
|
197
|
+
except _LOAD_ERRORS as exc:
|
|
198
|
+
findings.append(_error("robot_parse", f"robots/{robot_id}", _exc(exc)))
|
|
199
|
+
continue
|
|
200
|
+
robots[robot_id] = robot
|
|
201
|
+
robot_tags |= set(robot.capabilities.embodiment_tags)
|
|
202
|
+
findings.extend(_check_assets(robot_id, robot, path.parent, resolve_remote_assets))
|
|
203
|
+
findings.extend(_check_frames(robot_id, robot, path.parent))
|
|
204
|
+
return robots, robot_tags, findings
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _check_assets(
|
|
208
|
+
robot_id: str, robot: RobotDescription, manifest_dir: Path, resolve_remote_assets: bool
|
|
209
|
+
) -> list[CheckFinding]:
|
|
210
|
+
findings: list[CheckFinding] = []
|
|
211
|
+
for kind, ref in _asset_refs(robot):
|
|
212
|
+
if ref.startswith(_REMOTE_ASSET_PREFIXES) and not resolve_remote_assets:
|
|
213
|
+
continue
|
|
214
|
+
try:
|
|
215
|
+
resolve_asset(ref, kind, manifest_dir=manifest_dir)
|
|
216
|
+
except AssetRefError as exc:
|
|
217
|
+
findings.append(_error("asset_ref", f"robots/{robot_id} [{kind}]", str(exc)))
|
|
218
|
+
return findings
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _check_frames(robot_id: str, robot: RobotDescription, manifest_dir: Path) -> list[CheckFinding]:
|
|
222
|
+
declared = _declared_frames(robot, manifest_dir)
|
|
223
|
+
return [
|
|
224
|
+
_warning(
|
|
225
|
+
"frames",
|
|
226
|
+
f"robots/{robot_id} [sensor:{sensor.name}]",
|
|
227
|
+
f"parent_frame {sensor.parent_frame!r} is not a declared frame "
|
|
228
|
+
"(base/odom/map, a joint link, a sensor frame, or the URDF root); "
|
|
229
|
+
"confirm it is published by robot_state_publisher",
|
|
230
|
+
)
|
|
231
|
+
for sensor in robot.sensors
|
|
232
|
+
if sensor.parent_frame is not None and sensor.parent_frame not in declared
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _check_rskills(repo_root: Path, robot_tags: set[str]) -> tuple[int, list[CheckFinding]]:
|
|
237
|
+
"""Parse every rSkill manifest and check its embodiment tags reach a robot."""
|
|
238
|
+
findings: list[CheckFinding] = []
|
|
239
|
+
manifests: list[tuple[str, RSkillManifest]] = []
|
|
240
|
+
rskills_dir = repo_root / "rskills"
|
|
241
|
+
for path in sorted(rskills_dir.glob("*/rskill.yaml")) if rskills_dir.is_dir() else []:
|
|
242
|
+
rskill_id = path.parent.name
|
|
243
|
+
try:
|
|
244
|
+
manifest = RSkillManifest.from_yaml(str(path))
|
|
245
|
+
except _LOAD_ERRORS as exc:
|
|
246
|
+
findings.append(_error("rskill_parse", f"rskills/{rskill_id}", _exc(exc)))
|
|
247
|
+
continue
|
|
248
|
+
manifests.append((rskill_id, manifest))
|
|
249
|
+
|
|
250
|
+
for rskill_id, manifest in manifests:
|
|
251
|
+
tags = set(manifest.embodiment_tags)
|
|
252
|
+
# The explicit "any" wildcard is embodiment-agnostic by design
|
|
253
|
+
# and matches no specific robot tag — never flag it. Only flag a concrete
|
|
254
|
+
# tag set that intersects no robot in the repo (likely a typo).
|
|
255
|
+
if tags and "any" not in tags and not (tags & robot_tags):
|
|
256
|
+
findings.append(
|
|
257
|
+
_warning(
|
|
258
|
+
"embodiment_reach",
|
|
259
|
+
f"rskills/{rskill_id}",
|
|
260
|
+
f"embodiment_tags {sorted(tags)} intersect no in-repo robot "
|
|
261
|
+
"(intended for an external embodiment, or a typo)",
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
return len(manifests), findings
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _check_scenes(
|
|
268
|
+
repo_root: Path, robots: dict[str, RobotDescription]
|
|
269
|
+
) -> tuple[int, list[CheckFinding]]:
|
|
270
|
+
"""Parse every scene per tier and check its ``robot_id`` resolves to a robot dir."""
|
|
271
|
+
findings: list[CheckFinding] = []
|
|
272
|
+
n_scenes = 0
|
|
273
|
+
for tier, model in _SCENE_TIERS.items():
|
|
274
|
+
tier_dir = repo_root / "scenes" / tier
|
|
275
|
+
for path in sorted(tier_dir.glob("*.yaml")) if tier_dir.is_dir() else []:
|
|
276
|
+
n_scenes += 1
|
|
277
|
+
target = f"scenes/{tier}/{path.name}"
|
|
278
|
+
try:
|
|
279
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
280
|
+
except (OSError, yaml.YAMLError) as exc:
|
|
281
|
+
findings.append(_error("scene_parse", target, _exc(exc)))
|
|
282
|
+
continue
|
|
283
|
+
if not isinstance(data, dict):
|
|
284
|
+
continue # an eval suite (bare list), not a scene — skip
|
|
285
|
+
try:
|
|
286
|
+
scene = model.model_validate(data)
|
|
287
|
+
except _LOAD_ERRORS as exc:
|
|
288
|
+
findings.append(_error("scene_parse", target, _exc(exc)))
|
|
289
|
+
continue
|
|
290
|
+
if scene.robot_id is not None and scene.robot_id not in robots:
|
|
291
|
+
findings.append(
|
|
292
|
+
_error(
|
|
293
|
+
"scene_robot_id",
|
|
294
|
+
target,
|
|
295
|
+
f"robot_id={scene.robot_id!r} has no robots/{scene.robot_id}/robot.yaml",
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
return n_scenes, findings
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def check_description_graph(
|
|
302
|
+
repo_root: Path, *, resolve_remote_assets: bool = False
|
|
303
|
+
) -> GraphCheckReport:
|
|
304
|
+
"""Validate every robot / rSkill / scene manifest under ``repo_root``.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
repo_root: Directory holding ``robots/``, ``rskills/``, and ``scenes/``.
|
|
308
|
+
resolve_remote_assets: When ``True``, also resolve ``rd:`` /
|
|
309
|
+
``gym_aloha:`` / ``openarm:`` / ``menagerie:`` refs (downloads /
|
|
310
|
+
sim-only imports). Default ``False`` keeps the check offline.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
A :class:`GraphCheckReport`; ``report.ok`` is ``False`` when any
|
|
314
|
+
error-severity finding was raised.
|
|
315
|
+
|
|
316
|
+
Example:
|
|
317
|
+
>>> # report = check_description_graph(Path("."))
|
|
318
|
+
>>> # report.ok
|
|
319
|
+
"""
|
|
320
|
+
robots, robot_tags, findings = _check_robots(
|
|
321
|
+
repo_root, resolve_remote_assets=resolve_remote_assets
|
|
322
|
+
)
|
|
323
|
+
n_rskills, rskill_findings = _check_rskills(repo_root, robot_tags)
|
|
324
|
+
n_scenes, scene_findings = _check_scenes(repo_root, robots)
|
|
325
|
+
findings.extend(rskill_findings)
|
|
326
|
+
findings.extend(scene_findings)
|
|
327
|
+
return GraphCheckReport(
|
|
328
|
+
generated_at=_now(),
|
|
329
|
+
n_robots=len(robots),
|
|
330
|
+
n_rskills=n_rskills,
|
|
331
|
+
n_scenes=n_scenes,
|
|
332
|
+
findings=findings,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ── CLI ─────────────────────────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
_SEVERITY_STYLE = {"error": "red", "warning": "yellow"}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _render_graph_report(report: GraphCheckReport) -> None:
|
|
342
|
+
_console.print(
|
|
343
|
+
f"Checked [bold]{report.n_robots}[/bold] robots, "
|
|
344
|
+
f"[bold]{report.n_rskills}[/bold] rSkills, "
|
|
345
|
+
f"[bold]{report.n_scenes}[/bold] scenes."
|
|
346
|
+
)
|
|
347
|
+
if not report.findings:
|
|
348
|
+
_console.print("[green]All manifests valid and cross-references resolve.[/green]")
|
|
349
|
+
return
|
|
350
|
+
table = Table(show_header=True, header_style="bold")
|
|
351
|
+
table.add_column("severity")
|
|
352
|
+
table.add_column("rule")
|
|
353
|
+
table.add_column("target")
|
|
354
|
+
table.add_column("message")
|
|
355
|
+
for finding in report.findings:
|
|
356
|
+
style = _SEVERITY_STYLE[finding.severity]
|
|
357
|
+
table.add_row(
|
|
358
|
+
f"[{style}]{finding.severity}[/{style}]",
|
|
359
|
+
finding.rule,
|
|
360
|
+
finding.target,
|
|
361
|
+
finding.message,
|
|
362
|
+
)
|
|
363
|
+
_console.print(table)
|
|
364
|
+
_console.print(
|
|
365
|
+
f"[red]{len(report.errors)} error(s)[/red], "
|
|
366
|
+
f"[yellow]{len(report.warnings)} warning(s)[/yellow]."
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def check_command(
|
|
371
|
+
repo_root: Path = typer.Option(
|
|
372
|
+
Path("."), "--repo-root", help="Dir holding robots/, rskills/, scenes/."
|
|
373
|
+
),
|
|
374
|
+
strict: bool = typer.Option(False, "--strict", help="Treat warnings as failures too."),
|
|
375
|
+
resolve_remote_assets: bool = typer.Option(
|
|
376
|
+
False,
|
|
377
|
+
"--resolve-remote-assets",
|
|
378
|
+
help="Also resolve rd:/gym_aloha:/openarm: refs (downloads / sim deps).",
|
|
379
|
+
),
|
|
380
|
+
json_out: bool = typer.Option(False, "--json", help="Emit the GraphCheckReport as JSON."),
|
|
381
|
+
) -> None:
|
|
382
|
+
"""Cross-validate every robot, rSkill, and scene manifest in one pass.
|
|
383
|
+
|
|
384
|
+
Exits 1 on any error finding (a manifest that fails to parse, an unresolvable
|
|
385
|
+
asset ref, or a scene whose ``robot_id`` names no robot). Warnings (an
|
|
386
|
+
unreachable embodiment tag, an undeclared sensor frame) only fail under
|
|
387
|
+
``--strict``.
|
|
388
|
+
"""
|
|
389
|
+
report = check_description_graph(
|
|
390
|
+
repo_root.resolve(), resolve_remote_assets=resolve_remote_assets
|
|
391
|
+
)
|
|
392
|
+
if json_out:
|
|
393
|
+
_console.print_json(report.model_dump_json())
|
|
394
|
+
else:
|
|
395
|
+
_render_graph_report(report)
|
|
396
|
+
if not report.ok or (strict and report.warnings):
|
|
397
|
+
raise typer.Exit(code=1)
|