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/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)