"""Base XML parser shared across URDF, XACRO, etc. Support for MJCF and SDF is planned."""
from __future__ import annotations
from ..constants import (
DEFAULT_GEOMETRY_LENGTH,
DEFAULT_GEOMETRY_RADIUS,
DEFAULT_MATERIAL_RGBA_STR,
MAX_FILE_SIZE,
)
__all__ = ["RobotXMLParser"]
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any, Generic, TypeVar
from .._utils.path_utils import normalize_uri_to_path
from .._utils.xml_utils import (
parse_float,
parse_vector3,
)
from ..base import IResourceResolver, RobotParser
from ..exceptions import (
RobotModelError,
RobotParserError,
RobotParserIOError,
RobotValidationError,
ValidationErrorCode,
)
from ..logging_config import get_logger
from ..models import (
Box,
Color,
Cylinder,
Inertial,
InertiaTensor,
Material,
Mesh,
Sphere,
Transform,
)
from ..validation import validate_mesh_path, validate_package_uri
logger = get_logger(__name__)
T = TypeVar("T")
[docs]
class RobotXMLParser(RobotParser[T], Generic[T]):
"""Abstract base class for XML-based robotics format parsers."""
[docs]
def __init__(
self,
max_file_size: int = MAX_FILE_SIZE,
sandbox_root: Path | None = None,
resource_resolver: IResourceResolver | None = None,
) -> None:
"""Initialize base XML parser.
Args:
max_file_size: Maximum allowed file size in bytes
sandbox_root: Optional root directory for security sandbox
resource_resolver: Optional resolver for URIs
"""
self.max_file_size = max_file_size
self.sandbox_root = sandbox_root
self.resource_resolver = resource_resolver
def _validate_file(self, filepath: Path) -> None:
"""Validate file existence, type, and size.
Args:
filepath: Path to the file to validate.
Raises:
RobotParserIOError: If file is missing, is a directory, or exceeds max_file_size.
"""
if not filepath.exists():
raise RobotParserIOError(filepath=filepath, reason="File not found")
if filepath.is_dir():
raise RobotParserIOError(filepath=filepath, reason="Target path is a directory")
file_size = filepath.stat().st_size
if file_size > self.max_file_size:
raise RobotParserIOError(filepath=filepath, reason="File too large")
def _validate_content(self, content: str | bytes) -> None:
"""Validate content size for string or byte buffers.
Args:
content: The content string or bytes to validate.
Raises:
RobotParserIOError: If content size exceeds max_file_size.
"""
size = len(content.encode("utf-8")) if isinstance(content, str) else len(content)
if size > self.max_file_size:
raise RobotParserIOError(filepath="buffer", reason="Content too large")
[docs]
def parse_xacro(self, filepath: Path, **kwargs: Any) -> T:
"""Resolve XACRO then parse the resulting XML string.
This is a convenience wrapper around XACROParser.resolve() + parse_string().
Args:
filepath: Path to the XACRO file to resolve.
**kwargs: Arguments passed to both the resolver and the format parser.
Returns:
The parsed robot model (T).
Raises:
RobotXacroError: If XACRO resolution fails.
RobotParserError: If XML parsing fails.
"""
from .xacro_parser import XACROParser
self._validate_file(filepath)
xml_string = XACROParser().resolve(filepath, **kwargs)
return self.parse_string(xml_string, source_directory=filepath.parent, **kwargs)
def _parse_origin_element(self, elem: ET.Element | None) -> Transform:
"""Parse origin-style element into a Transform object.
Args:
elem: XML element with xyz/rpy attributes (e.g. <origin> or <pose>).
Returns:
A Transform object.
"""
if elem is None:
return Transform.identity()
xyz_text = elem.get("xyz", elem.get("pos", "0 0 0"))
rpy_text = elem.get("rpy", elem.get("euler", "0 0 0"))
xyz = parse_vector3(xyz_text)
rpy = parse_vector3(rpy_text)
return Transform(xyz=xyz, rpy=rpy)
def _parse_geometry_element(
self,
geom_elem: ET.Element,
base_directory: Path | None = None,
) -> Box | Cylinder | Sphere | Mesh | None:
"""Parse geometry element (box, cylinder, sphere, mesh)."""
try:
if geom_elem.find("{*}box") is not None:
return self._parse_box(geom_elem.find("{*}box"))
if geom_elem.find("{*}cylinder") is not None:
return self._parse_cylinder(geom_elem.find("{*}cylinder"))
if geom_elem.find("{*}sphere") is not None:
return self._parse_sphere(geom_elem.find("{*}sphere"))
if geom_elem.find("{*}mesh") is not None:
return self._parse_mesh(geom_elem.find("{*}mesh"), base_directory)
except (RobotModelError, ValueError, RobotParserError) as e:
logger.warning(f"Invalid geometry ignored: {e}")
return None
return None
def _parse_box(self, box: ET.Element | None) -> Box | None:
"""Parse box geometry."""
if box is None:
return None
size_text = box.get("size")
if size_text is None:
logger.warning("Invalid box geometry ignored: missing size")
return None
return Box(size=parse_vector3(size_text))
def _parse_cylinder(self, cylinder: ET.Element | None) -> Cylinder | None:
"""Parse cylinder geometry."""
if cylinder is None:
return None
radius = parse_float(
cylinder.get("radius"), "cylinder radius", default=DEFAULT_GEOMETRY_RADIUS
)
length = parse_float(
cylinder.get("length"), "cylinder length", default=DEFAULT_GEOMETRY_LENGTH
)
return Cylinder(radius=radius, length=length)
def _parse_sphere(self, sphere: ET.Element | None) -> Sphere | None:
"""Parse sphere geometry."""
if sphere is None:
return None
radius = parse_float(sphere.get("radius"), "sphere radius", default=DEFAULT_GEOMETRY_RADIUS)
return Sphere(radius=radius)
def _parse_mesh(self, mesh: ET.Element | None, base_dir: Path | None) -> Mesh | None:
"""Parse mesh geometry with security validation."""
if mesh is None:
return None
filename = mesh.get("filename", mesh.get("file", ""))
if not filename:
raise RobotValidationError(
ValidationErrorCode.VALUE_EMPTY,
"Mesh filename is missing",
target="Mesh",
value=filename,
)
# Path Security Validation
validation_path: Path | None = None
if filename.startswith("file://"):
validation_path = normalize_uri_to_path(filename)
elif not filename.startswith("package://"):
validation_path = Path(filename)
if validation_path is not None and base_dir is not None:
validate_mesh_path(
validation_path,
base_dir,
sandbox_root=self.sandbox_root,
allow_absolute=validation_path.is_absolute(),
)
elif filename.startswith("package://"):
validate_package_uri(filename)
scale_text = mesh.get("scale", "1 1 1")
resource = str(validation_path) if validation_path else filename
return Mesh(resource=resource, scale=parse_vector3(scale_text))
def _parse_material_element(
self, mat_elem: ET.Element | None, materials: dict[str, Material]
) -> Material | None:
"""Parse material definition or reference.
Args:
mat_elem: Material element.
materials: Cache of defined materials.
Returns:
Material object or None.
"""
if mat_elem is None:
return None
mat_name = mat_elem.get("name", "")
if mat_name and mat_name in materials:
return materials[mat_name]
color = None
color_elem = mat_elem.find("{*}color")
num_rgb = 3
num_rgba = 4
if color_elem is not None:
rgba_text = color_elem.get("rgba", DEFAULT_MATERIAL_RGBA_STR)
parts = rgba_text.strip().split()
try:
num_parts = len(parts)
if num_parts == num_rgb:
color = Color(r=float(parts[0]), g=float(parts[1]), b=float(parts[2]), a=1.0)
elif num_parts == num_rgba:
color = Color(
r=float(parts[0]), g=float(parts[1]), b=float(parts[2]), a=float(parts[3])
)
except (ValueError, IndexError):
logger.warning(f"Invalid material color format: {rgba_text}")
return None
texture = None
texture_elem = mat_elem.find("{*}texture")
if texture_elem is not None:
texture = texture_elem.get("filename")
# Create material even if color/texture are missing if we have a name
if mat_name or color or texture:
try:
return Material(
name=mat_name if mat_name else "default", color=color, texture=texture
)
except RobotModelError as e:
logger.warning(f"Failed to create material '{mat_name}': {e}")
return None
return None
def _parse_inertial_element(self, inertial_elem: ET.Element | None) -> Inertial | None:
"""Parse inertial properties.
Args:
inertial_elem: Inertial XML element.
Returns:
Inertial object or None.
"""
if inertial_elem is None:
return None
origin = self._parse_origin_element(inertial_elem.find("{*}origin"))
mass_elem = inertial_elem.find("{*}mass")
mass = parse_float(mass_elem.get("value") if mass_elem is not None else None, default=0.0)
inertia_elem = inertial_elem.find("{*}inertia")
if inertia_elem is not None:
# Delegate physical validity entirely to the InertiaTensor model
ixx = parse_float(inertia_elem.get("ixx"), default=0.0)
iyy = parse_float(inertia_elem.get("iyy"), default=0.0)
izz = parse_float(inertia_elem.get("izz"), default=0.0)
ixy = parse_float(inertia_elem.get("ixy"), default=0.0)
ixz = parse_float(inertia_elem.get("ixz"), default=0.0)
iyz = parse_float(inertia_elem.get("iyz"), default=0.0)
try:
inertia = InertiaTensor(ixx=ixx, iyy=iyy, izz=izz, ixy=ixy, ixz=ixz, iyz=iyz)
except RobotModelError:
# If triangle inequality is still violated, fall back to minimal valid
inertia = InertiaTensor.stability_floor()
else:
inertia = InertiaTensor.stability_floor()
return Inertial(mass=mass, origin=origin, inertia=inertia)