Spaces:
Paused
Paused
File size: 8,994 Bytes
bd0a5c7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 | """Static validation of generated ROS 2 packages, grounded in the indexed interface definitions.
No ROS installation is needed. Checks:
- file paths are relative and stay inside the package tree
- every package has a parseable package.xml with a name and build type, plus
setup.py (ament_python) or CMakeLists.txt with ament_package() (ament_cmake)
- Python files and Python launch files parse; launch files define generate_launch_description()
- XML launch files parse and have a <launch> root
- every message/service type used (Python imports, C++ includes and qualified names)
exists in the ROS 2 distro, according to data/ros2_interfaces.json
- interface packages that are used are declared as dependencies in package.xml (warning)
"""
from __future__ import annotations
import ast
import json
import re
import xml.etree.ElementTree as ET
from dataclasses import asdict, dataclass, field
from functools import lru_cache
from pathlib import Path, PurePosixPath
@dataclass
class CodeValidation:
valid: bool
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
stats: dict = field(default_factory=dict)
def to_dict(self) -> dict:
return asdict(self)
def feedback(self) -> str:
lines = ["The generated package failed validation:"] + [f"- ERROR: {e}" for e in self.errors]
lines += [f"- warning: {w}" for w in self.warnings]
return "\n".join(lines)
@lru_cache
def load_interfaces(path: str) -> dict[str, dict[str, str]]:
p = Path(path)
return json.loads(p.read_text()) if p.is_file() else {}
def _camel(snake: str) -> str:
return "".join(part.capitalize() for part in snake.split("_"))
_CPP_INCLUDE = re.compile(r'#include\s*[<"](\w+)/(msg|srv|action)/(\w+)\.hpp[>"]')
_CPP_QUALIFIED = re.compile(r"\b(\w+)::(msg|srv|action)::(\w+)\b")
_DEPENDS = ("depend", "exec_depend", "build_depend", "build_export_depend")
def _python_interfaces(tree: ast.AST) -> set[str]:
used = set()
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module:
parts = node.module.split(".")
if len(parts) == 2 and parts[1] in ("msg", "srv", "action"):
for alias in node.names:
if alias.name != "*":
used.add(f"{parts[0]}/{parts[1]}/{alias.name}")
elif isinstance(node, ast.Attribute) and isinstance(node.value, ast.Attribute):
# std_msgs.msg.String style
inner = node.value
if isinstance(inner.value, ast.Name) and inner.attr in ("msg", "srv", "action") and node.attr[:1].isupper():
used.add(f"{inner.value.id}/{inner.attr}/{node.attr}")
return used
def _cpp_interfaces(text: str) -> set[str]:
used = {f"{p}/{k}/{_camel(n)}" for p, k, n in _CPP_INCLUDE.findall(text)}
used |= {f"{p}/{k}/{n}" for p, k, n in _CPP_QUALIFIED.findall(text) if n[:1].isupper() and p != "std"}
return used
def _package_roots(files: dict[str, str]) -> dict[str, str]:
"""Directory -> package name for every package.xml."""
roots = {}
for path, content in files.items():
if PurePosixPath(path).name == "package.xml":
try:
name = (ET.fromstring(content).findtext("name") or "").strip()
except ET.ParseError:
name = ""
roots[str(PurePosixPath(path).parent)] = name
return roots
def _owning_root(path: str, roots: dict[str, str]) -> str | None:
best = None
for root in roots:
prefix = "" if root == "." else root + "/"
if path.startswith(prefix) and (best is None or len(root) > len(best)):
best = root
return best
def validate_package(files: dict[str, str], distro: str, interfaces: dict[str, dict[str, str]]) -> CodeValidation:
errors: list[str] = []
warnings: list[str] = []
if not files:
return CodeValidation(False, ["no files were generated"])
for path in files:
p = PurePosixPath(path)
if p.is_absolute() or ".." in p.parts or path.startswith("~"):
errors.append(f"{path}: file paths must be relative and inside the package")
if errors:
return CodeValidation(False, errors)
known = interfaces.get(distro, {})
known_packages = {name.split("/")[0] for name in known}
if not known:
warnings.append(f"no interface catalogue for {distro}; message types were not checked (run rag.ingest)")
# Custom interfaces defined in the generated packages themselves.
defined = set()
for path in files:
p = PurePosixPath(path)
if p.suffix in (".msg", ".srv", ".action") and p.parent.name in ("msg", "srv", "action"):
roots = _package_roots(files)
root = _owning_root(path, roots)
pkg = roots.get(root, "") if root is not None else ""
defined.add(f"{pkg}/{p.parent.name}/{p.stem}")
roots = _package_roots(files)
if not roots:
errors.append("no package.xml found")
deps_by_root: dict[str, set[str]] = {}
for root, name in roots.items():
pkg_xml = files[f"{root}/package.xml" if root != "." else "package.xml"]
try:
tree = ET.fromstring(pkg_xml)
except ET.ParseError as e:
errors.append(f"{root}/package.xml: XML parse error: {e}")
continue
if not name:
errors.append(f"{root}/package.xml: missing <name>")
build_type = (tree.findtext("export/build_type") or "").strip()
deps_by_root[root] = {(d.text or "").strip() for tag in _DEPENDS for d in tree.iter(tag)}
prefix = "" if root == "." else root + "/"
if build_type == "ament_python":
if f"{prefix}setup.py" not in files:
errors.append(f"{root}: ament_python package without setup.py")
if name and f"{prefix}{name}/__init__.py" not in files:
warnings.append(f"{root}: no {name}/__init__.py (the Python module directory)")
if f"{prefix}resource/{name}" not in files:
warnings.append(f"{root}: no resource/{name} marker file (ament index)")
elif build_type == "ament_cmake":
cmake = files.get(f"{prefix}CMakeLists.txt")
if cmake is None:
errors.append(f"{root}: ament_cmake package without CMakeLists.txt")
elif "ament_package()" not in cmake:
errors.append(f"{root}/CMakeLists.txt: missing ament_package()")
else:
errors.append(f"{root}/package.xml: <export><build_type> must be ament_python or ament_cmake")
used_by_root: dict[str, set[str]] = {}
for path, content in files.items():
p = PurePosixPath(path)
used: set[str] = set()
if p.suffix == ".py":
try:
tree = ast.parse(content, filename=path)
except SyntaxError as e:
errors.append(f"{path}:{e.lineno}: Python syntax error: {e.msg}")
continue
used = _python_interfaces(tree)
if path.endswith("launch.py"):
funcs = {n.name for n in tree.body if isinstance(n, ast.FunctionDef)}
if "generate_launch_description" not in funcs:
errors.append(f"{path}: launch file does not define generate_launch_description()")
elif p.suffix in (".cpp", ".hpp", ".h", ".cc"):
used = _cpp_interfaces(content)
elif path.endswith(("launch.xml", ".launch")) or (p.suffix == ".xml" and "launch" in p.parts):
try:
root_el = ET.fromstring(content)
if root_el.tag != "launch":
errors.append(f"{path}: XML launch file root must be <launch>, got <{root_el.tag}>")
except ET.ParseError as e:
errors.append(f"{path}: XML parse error: {e}")
for name in sorted(used):
if name in known or name in defined:
continue
pkg = name.split("/")[0]
if pkg in known_packages:
errors.append(f"{path}: {name} does not exist in ROS 2 {distro}")
else:
warnings.append(f"{path}: {name} is from a package that is not indexed; not verified")
owner = _owning_root(path, roots)
if owner is not None:
used_by_root.setdefault(owner, set()).update(used)
for root, used in used_by_root.items():
own = roots.get(root)
for pkg in sorted({u.split("/")[0] for u in used} - {own}):
if pkg not in deps_by_root.get(root, set()):
warnings.append(f"{root}/package.xml: uses {pkg} but does not declare it as a dependency")
stats = {
"files": len(files),
"packages": sorted(n for n in roots.values() if n),
"interfaces_used": sorted(set().union(*used_by_root.values())) if used_by_root else [],
}
return CodeValidation(not errors, errors, warnings, stats)
|