linkforge-core 1.4.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.
Files changed (53) hide show
  1. linkforge/core/__init__.py +301 -0
  2. linkforge/core/_utils/__init__.py +1 -0
  3. linkforge/core/_utils/dict_utils.py +122 -0
  4. linkforge/core/_utils/math_utils.py +75 -0
  5. linkforge/core/_utils/path_utils.py +169 -0
  6. linkforge/core/_utils/string_utils.py +96 -0
  7. linkforge/core/_utils/xml_utils.py +396 -0
  8. linkforge/core/base.py +218 -0
  9. linkforge/core/composer/__init__.py +11 -0
  10. linkforge/core/composer/helpers.py +25 -0
  11. linkforge/core/composer/interfaces.py +37 -0
  12. linkforge/core/composer/link_builder.py +1098 -0
  13. linkforge/core/composer/robot_builder.py +271 -0
  14. linkforge/core/composer/semantic_builder.py +253 -0
  15. linkforge/core/constants.py +293 -0
  16. linkforge/core/exceptions.py +217 -0
  17. linkforge/core/generators/__init__.py +8 -0
  18. linkforge/core/generators/srdf_generator.py +217 -0
  19. linkforge/core/generators/urdf_generator.py +1128 -0
  20. linkforge/core/generators/xacro_generator.py +895 -0
  21. linkforge/core/generators/xml_base.py +159 -0
  22. linkforge/core/io.py +125 -0
  23. linkforge/core/logging_config.py +66 -0
  24. linkforge/core/models/__init__.py +135 -0
  25. linkforge/core/models/gazebo.py +137 -0
  26. linkforge/core/models/geometry.py +193 -0
  27. linkforge/core/models/graph.py +250 -0
  28. linkforge/core/models/joint.py +294 -0
  29. linkforge/core/models/link.py +228 -0
  30. linkforge/core/models/material.py +85 -0
  31. linkforge/core/models/robot.py +1147 -0
  32. linkforge/core/models/ros2_control.py +195 -0
  33. linkforge/core/models/sensor.py +388 -0
  34. linkforge/core/models/srdf.py +555 -0
  35. linkforge/core/models/transmission.py +353 -0
  36. linkforge/core/parsers/__init__.py +21 -0
  37. linkforge/core/parsers/srdf_parser.py +570 -0
  38. linkforge/core/parsers/urdf_parser.py +1253 -0
  39. linkforge/core/parsers/xacro_parser.py +1091 -0
  40. linkforge/core/parsers/xml_base.py +324 -0
  41. linkforge/core/physics/__init__.py +29 -0
  42. linkforge/core/physics/inertia.py +399 -0
  43. linkforge/core/physics/mesh_validation.py +324 -0
  44. linkforge/core/py.typed +0 -0
  45. linkforge/core/validation/__init__.py +58 -0
  46. linkforge/core/validation/checks.py +728 -0
  47. linkforge/core/validation/result.py +150 -0
  48. linkforge/core/validation/security.py +175 -0
  49. linkforge/core/validation/validator.py +113 -0
  50. linkforge_core-1.4.0.dist-info/METADATA +236 -0
  51. linkforge_core-1.4.0.dist-info/RECORD +53 -0
  52. linkforge_core-1.4.0.dist-info/WHEEL +4 -0
  53. linkforge_core-1.4.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,96 @@
1
+ """String utility functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..exceptions import RobotValidationError, ValidationErrorCode
6
+
7
+
8
+ def sanitize_name(name: str | None, allow_hyphen: bool = True) -> str:
9
+ """Sanitize a name for robot model and Python identifier compatibility.
10
+
11
+ Replaces invalid characters with underscores and ensures it doesn't
12
+ start with a digit.
13
+
14
+ Args:
15
+ name: Original name
16
+ allow_hyphen: Whether to allow hyphens (valid in many formats like URDF/SDF, invalid in Python)
17
+
18
+ Returns:
19
+ Sanitized name
20
+ """
21
+ if not name:
22
+ return ""
23
+
24
+ # Prevent ReDoS: limit input length before processing
25
+ if len(name) > 1000:
26
+ raise RobotValidationError(
27
+ ValidationErrorCode.OUT_OF_RANGE,
28
+ f"Name length {len(name)} exceeds 1000 characters",
29
+ target="NameLength",
30
+ value=len(name),
31
+ )
32
+
33
+ # Replace spaces with underscores
34
+ name = name.replace(" ", "_")
35
+
36
+ # Character iteration (safer than regex)
37
+ allowed_special = ("_", "-") if allow_hyphen else ("_",)
38
+ sanitized = "".join(c if c.isalnum() or c in allowed_special else "_" for c in name)
39
+
40
+ # Ensure it doesn't start with a digit
41
+ if sanitized and sanitized[0].isdigit():
42
+ sanitized = f"_{sanitized}"
43
+
44
+ return sanitized
45
+
46
+
47
+ def is_valid_name(name: str, allow_hyphen: bool = True) -> bool:
48
+ """Check if a name is valid for robot components.
49
+
50
+ A valid name:
51
+ - Is not empty
52
+ - Does not start with a digit
53
+ - Contains only alphanumeric characters, underscores, and optionally hyphens
54
+
55
+ Args:
56
+ name: Name to validate
57
+ allow_hyphen: Whether to allow hyphens (valid in URDF/SDF, invalid in Python)
58
+
59
+ Returns:
60
+ True if name is valid, False otherwise
61
+
62
+ Examples:
63
+ >>> is_valid_name("base_link")
64
+ True
65
+ >>> is_valid_name("base-link")
66
+ True
67
+ >>> is_valid_name("base-link", allow_hyphen=False)
68
+ False
69
+ >>> is_valid_name("2nd_link")
70
+ False
71
+ >>> is_valid_name("base link")
72
+ False
73
+ """
74
+ if not name:
75
+ return False
76
+
77
+ # Must not start with a digit
78
+ if name[0].isdigit():
79
+ return False
80
+
81
+ # All characters must be alphanumeric or allowed special chars
82
+ allowed_special = ("_", "-") if allow_hyphen else ("_",)
83
+ return all(c.isalnum() or c in allowed_special for c in name)
84
+
85
+
86
+ def format_scientific(value: float) -> str:
87
+ """Format a float to scientific notation for UI display."""
88
+ return f"{value:.2e}"
89
+
90
+
91
+ def parse_scientific(value: str, fallback: float) -> float:
92
+ """Parse a scientific notation string back to float."""
93
+ try:
94
+ return float(value)
95
+ except ValueError:
96
+ return fallback
@@ -0,0 +1,396 @@
1
+ """XML utility functions for LinkForge."""
2
+
3
+ import math
4
+ import xml.etree.ElementTree as ET
5
+ from collections.abc import Callable
6
+ from datetime import datetime
7
+ from typing import Any
8
+
9
+ from ..constants import (
10
+ MAX_REASONABLE_FLOAT,
11
+ MAX_REASONABLE_INT,
12
+ MAX_XML_DEPTH,
13
+ XACRO_URIS,
14
+ )
15
+ from ..exceptions import RobotMathError, RobotValidationError, ValidationErrorCode
16
+ from ..models import Vector3
17
+
18
+ # Register XACRO namespace to ensure standard 'xacro:' prefix in exports
19
+ ET.register_namespace("xacro", next(iter(XACRO_URIS)))
20
+
21
+
22
+ def strip_xml_namespace(tag: str) -> str:
23
+ """Strip XML namespace (Clark notation) from a tag string.
24
+
25
+ Example:
26
+ '{http://www.ros.org/wiki/xacro}macro' -> 'macro'
27
+ 'robot' -> 'robot'
28
+
29
+ Args:
30
+ tag: The XML tag string to process.
31
+
32
+ Returns:
33
+ The local tag name without the namespace.
34
+ """
35
+ if tag.startswith("{"):
36
+ return tag.split("}", 1)[-1]
37
+ return tag
38
+
39
+
40
+ def get_xml_namespace(tag: str) -> str | None:
41
+ """Extract XML namespace URI from a tag string in Clark notation.
42
+
43
+ Example:
44
+ '{http://www.ros.org/wiki/xacro}macro' -> 'http://www.ros.org/wiki/xacro'
45
+ 'robot' -> None
46
+
47
+ Args:
48
+ tag: The XML tag string to process.
49
+
50
+ Returns:
51
+ The namespace URI if present, otherwise None.
52
+ """
53
+ if tag.startswith("{"):
54
+ return tag[1:].split("}", 1)[0]
55
+ return None
56
+
57
+
58
+ def validate_xml_depth(element: ET.Element, depth: int = 0) -> None:
59
+ """Validate XML depth to prevent billion laughs attack.
60
+
61
+ Args:
62
+ element: XML element to validate
63
+ depth: Current nesting depth
64
+
65
+ Raises:
66
+ RobotValidationError: If depth exceeds MAX_XML_DEPTH
67
+ """
68
+ if depth > MAX_XML_DEPTH:
69
+ raise RobotValidationError(
70
+ ValidationErrorCode.OUT_OF_RANGE,
71
+ f"XML nesting depth {depth} exceeds limit {MAX_XML_DEPTH}",
72
+ target="XMLDepth",
73
+ value=depth,
74
+ )
75
+
76
+ for child in element:
77
+ validate_xml_depth(child, depth + 1)
78
+
79
+
80
+ def get_xml_header(element: ET.Element, version: str) -> str:
81
+ """Standardized header comment for XML files.
82
+
83
+ Args:
84
+ element: Root XML element (to extract robot name)
85
+ version: LinkForge version
86
+
87
+ Returns:
88
+ Header string containing XML declaration and meta comment
89
+ """
90
+ return f"""<?xml version="1.0"?>
91
+ <!--
92
+ Robot: {element.get("name", "robot")}
93
+ Generated by: LinkForge v{version}
94
+ https://github.com/arounamounchili/linkforge
95
+ Date: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
96
+ -->
97
+ """
98
+
99
+
100
+ def indent_xml(element: ET.Element, space: str = " ", level: int = 0) -> None:
101
+ """Format XML elements with indentation.
102
+
103
+ Args:
104
+ element: XML element to indent
105
+ space: Indentation string
106
+ level: Starting level
107
+ """
108
+ ET.indent(element, space=space, level=level)
109
+
110
+
111
+ def serialize_xml(
112
+ element: ET.Element,
113
+ pretty_print: bool = True,
114
+ version: str = "unknown",
115
+ namespaces: dict[str, str] | None = None,
116
+ ) -> str:
117
+ """Convert XML element to standardized LinkForge string.
118
+
119
+ Args:
120
+ element: XML element to serialize
121
+ pretty_print: Whether to indent
122
+ version: LinkForge version for header
123
+ namespaces: Optional dict of {prefix: uri} to register
124
+
125
+ Returns:
126
+ XML string
127
+ """
128
+ if namespaces:
129
+ for prefix, uri in namespaces.items():
130
+ ET.register_namespace(prefix, uri)
131
+
132
+ if pretty_print:
133
+ indent_xml(element)
134
+
135
+ xml_str = ET.tostring(element, encoding="unicode")
136
+
137
+ # Ensure namespaces are explicitly present on root if ElementTree dropped them
138
+ if namespaces:
139
+ for prefix, uri in namespaces.items():
140
+ ns_attr = f'xmlns:{prefix}="{uri}"'
141
+ # Using a more robust check for root tag insertion
142
+ if ns_attr not in xml_str and "<robot" in xml_str:
143
+ xml_str = xml_str.replace("<robot", f"<robot {ns_attr}", 1)
144
+
145
+ return get_xml_header(element, version) + xml_str
146
+
147
+
148
+ def parse_float(
149
+ text: str | None,
150
+ attribute_name: str = "value",
151
+ default: float | None = None,
152
+ check_name: str | None = None,
153
+ ) -> float:
154
+ """Parse float value from XML with comprehensive validation.
155
+
156
+ Args:
157
+ text: String to parse
158
+ attribute_name: Context for error messages
159
+ default: Default value if text is None
160
+ check_name: Alias for attribute_name used in some parsers
161
+
162
+ Returns:
163
+ Parsed float
164
+
165
+ Raises:
166
+ RobotMathError: If input is invalid
167
+ RobotValidationError: If attribute is missing
168
+ """
169
+ # Alias check_name if provided
170
+ report_name = check_name or attribute_name
171
+
172
+ if text is not None and not text.strip():
173
+ text = None
174
+
175
+ if text is None:
176
+ if default is not None:
177
+ return default
178
+ raise RobotValidationError(
179
+ ValidationErrorCode.VALUE_EMPTY,
180
+ f"Missing required attribute: {report_name}",
181
+ target=report_name,
182
+ )
183
+
184
+ try:
185
+ value = float(text)
186
+ if math.isnan(value) or math.isinf(value):
187
+ raise RobotMathError(
188
+ ValidationErrorCode.INVALID_VALUE,
189
+ f"Non-finite float value '{value}' in {report_name}",
190
+ target=report_name,
191
+ value=value,
192
+ )
193
+
194
+ # Sanity check for reasonable values (Standard LinkForge limit)
195
+ # Physics constants like kp (stiffness) can be very large (e.g. 1e12)
196
+ if not (-MAX_REASONABLE_FLOAT < value < MAX_REASONABLE_FLOAT):
197
+ raise RobotMathError(
198
+ ValidationErrorCode.OUT_OF_RANGE,
199
+ f"Float value '{value}' in {report_name} is outside reasonable range",
200
+ target=report_name,
201
+ value=value,
202
+ )
203
+
204
+ return value
205
+ except ValueError:
206
+ raise RobotMathError(
207
+ ValidationErrorCode.INVALID_VALUE,
208
+ f"Invalid float format '{text}' in {report_name}",
209
+ target=report_name,
210
+ value=text,
211
+ ) from None
212
+
213
+
214
+ def parse_int(
215
+ text: str | None,
216
+ attribute_name: str = "value",
217
+ default: int | None = None,
218
+ check_name: str | None = None,
219
+ ) -> int:
220
+ """Parse integer value from XML with comprehensive validation.
221
+
222
+ Args:
223
+ text: String to parse
224
+ attribute_name: Context for error messages
225
+ default: Default value if text is None
226
+ check_name: Alias for attribute_name for consistent reporting
227
+
228
+ Returns:
229
+ Parsed integer
230
+
231
+ Raises:
232
+ RobotMathError: If input format is invalid or value is out of range
233
+ RobotValidationError: If attribute is missing and no default is provided
234
+ """
235
+ report_name = check_name or attribute_name
236
+
237
+ if text is not None and not text.strip():
238
+ text = None
239
+
240
+ if text is None:
241
+ if default is not None:
242
+ return default
243
+ raise RobotValidationError(
244
+ ValidationErrorCode.VALUE_EMPTY,
245
+ f"Missing required attribute: {report_name}",
246
+ target=report_name,
247
+ )
248
+
249
+ try:
250
+ value = int(text)
251
+ # Sanity check for reasonable values (standard LinkForge limit)
252
+ if not (-MAX_REASONABLE_INT < value < MAX_REASONABLE_INT):
253
+ raise RobotMathError(
254
+ ValidationErrorCode.OUT_OF_RANGE,
255
+ f"Integer value '{value}' in {report_name} is outside reasonable range",
256
+ target=report_name,
257
+ value=value,
258
+ )
259
+ return value
260
+ except ValueError:
261
+ raise RobotMathError(
262
+ ValidationErrorCode.INVALID_VALUE,
263
+ f"Invalid integer format '{text}' in {report_name}",
264
+ target=report_name,
265
+ value=text,
266
+ ) from None
267
+
268
+
269
+ def parse_vector3(text: str) -> Vector3:
270
+ """Parse space-separated vector3 string.
271
+
272
+ Args:
273
+ text: Space-separated string (e.g. "1.0 2.0 3.0")
274
+
275
+ Returns:
276
+ Vector3 model
277
+
278
+ Raises:
279
+ RobotValidationError: If vector format is invalid or components are missing
280
+ RobotMathError: If component values are invalid
281
+ """
282
+ parts = text.strip().split()
283
+ if len(parts) != 3:
284
+ raise RobotValidationError(
285
+ ValidationErrorCode.INVALID_VALUE,
286
+ f"Expected 3 values for Vector3, got {len(parts)}",
287
+ target="Vector3",
288
+ value=text,
289
+ )
290
+ try:
291
+ x = parse_float(parts[0], "x")
292
+ y = parse_float(parts[1], "y")
293
+ z = parse_float(parts[2], "z")
294
+ return Vector3(x, y, z)
295
+ except (RobotMathError, RobotValidationError):
296
+ raise
297
+
298
+
299
+ def parse_optional_bool(elem: ET.Element, tag: str, default: str = "false") -> bool | None:
300
+ """Parse optional boolean element.
301
+
302
+ Args:
303
+ elem: Parent XML element
304
+ tag: Sub-element tag to find
305
+ default: Default string value if tag is found but content is empty
306
+
307
+ Returns:
308
+ Boolean value if tag exists, else None
309
+ """
310
+ if elem.find(f"{{*}}{tag}") is not None:
311
+ return elem.findtext(f"{{*}}{tag}", default).lower() == "true"
312
+ return None
313
+
314
+
315
+ def parse_optional_float(elem: ET.Element, tag: str, default: float | None = 0.0) -> float | None:
316
+ """Parse optional float element.
317
+
318
+ Args:
319
+ elem: Parent XML element
320
+ tag: Sub-element tag to find
321
+ default: Default float value if tag content is missing
322
+
323
+ Returns:
324
+ Parsed float if tag exists, else None
325
+
326
+ Raises:
327
+ RobotMathError: If input value is invalid or out of range
328
+ RobotValidationError: If parsing logic fails
329
+ """
330
+ if elem.find(f"{{*}}{tag}") is not None:
331
+ text = elem.findtext(f"{{*}}{tag}")
332
+ return parse_float(text, tag, default=default)
333
+ return None
334
+
335
+
336
+ def xml_add_text(parent: ET.Element, tag: str, value: Any) -> ET.Element:
337
+ """Create a sub-element with text content.
338
+
339
+ Args:
340
+ parent: The parent XML element.
341
+ tag: The tag name for the new element.
342
+ value: The text content to set. Will be converted to string if not None.
343
+
344
+ Returns:
345
+ The newly created XML element.
346
+ """
347
+ elem = ET.SubElement(parent, tag)
348
+ if value is not None:
349
+ elem.text = str(value)
350
+ return elem
351
+
352
+
353
+ def xml_add_vector(
354
+ parent: ET.Element,
355
+ tag: str,
356
+ vector: Vector3,
357
+ formatter: Callable[[float], str],
358
+ ) -> ET.Element:
359
+ """Create a sub-element for a vector relying on a formatter for the values.
360
+
361
+ Args:
362
+ parent: The parent XML element.
363
+ tag: The tag name for the new element.
364
+ vector: The Vector3 object containing the values.
365
+ formatter: A callable that takes a float and returns a string (e.g., format_float).
366
+
367
+ Returns:
368
+ The newly created XML element with the formatted text string.
369
+ """
370
+ # Create text from formatted components
371
+ text_val = f"{formatter(vector.x)} {formatter(vector.y)} {formatter(vector.z)}"
372
+ return xml_add_text(parent, tag, text_val)
373
+
374
+
375
+ def create_xml_element(
376
+ parent: ET.Element,
377
+ tag: str,
378
+ formatter: Callable[[Any], str] | None = None,
379
+ **kwargs: Any,
380
+ ) -> ET.Element:
381
+ """Create an XML element, stripping None values and converting types to str.
382
+
383
+ Args:
384
+ parent: Parent XML element
385
+ tag: Tag name for the new element
386
+ formatter: Optional callable to format values before string conversion
387
+ **kwargs: Attributes for the new element
388
+
389
+ Returns:
390
+ The newly created XML element
391
+ """
392
+ if formatter:
393
+ attrib = {k: formatter(v) for k, v in kwargs.items() if v is not None}
394
+ else:
395
+ attrib = {k: str(v) for k, v in kwargs.items() if v is not None}
396
+ return ET.SubElement(parent, tag, attrib)
linkforge/core/base.py ADDED
@@ -0,0 +1,218 @@
1
+ """Base classes for Robot Generators and Parsers.
2
+
3
+ This module defines the abstract base classes that facilitate translation
4
+ between the LinkForge Intermediate Representation (IR) and various external
5
+ robot description formats.
6
+
7
+ Core Components:
8
+ - RobotGenerator: Abstract base for format-specific exporters (URDF, SRDF).
9
+ - RobotParser: Abstract base for format-specific importers.
10
+ - IResourceResolver: Protocol for resolving package:// and file:// URIs.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from abc import ABC, abstractmethod
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar, runtime_checkable
18
+
19
+ from ._utils.path_utils import normalize_uri_to_path, resolve_package_path
20
+ from .exceptions import (
21
+ LinkForgeError,
22
+ RobotGeneratorError,
23
+ RobotModelError,
24
+ RobotParserError,
25
+ XacroDetectedError,
26
+ )
27
+
28
+ if TYPE_CHECKING:
29
+ from .models.robot import Robot
30
+
31
+ # Generic type for the output format (e.g., str for XML, dict for JSON)
32
+ T = TypeVar("T")
33
+
34
+ __all__ = [
35
+ "RobotGenerator",
36
+ "RobotParser",
37
+ "IResourceResolver",
38
+ "FileSystemResolver",
39
+ "NetworkResolver",
40
+ "LinkForgeError",
41
+ "RobotGeneratorError",
42
+ "RobotModelError",
43
+ "RobotParserError",
44
+ "XacroDetectedError",
45
+ ]
46
+
47
+
48
+ class RobotGenerator(ABC, Generic[T]):
49
+ """Abstract base class for all Robot Generators."""
50
+
51
+ @abstractmethod
52
+ def generate(self, robot: Robot, **kwargs: Any) -> T:
53
+ """Generate the output representation from the Robot model.
54
+
55
+ Args:
56
+ robot: The generic Robot model (Intermediate Representation)
57
+ **kwargs: Format-specific generation options
58
+
59
+ Returns:
60
+ The generated output (e.g. XML string, JSON dict)
61
+ """
62
+ pass
63
+
64
+ def write(self, robot: Robot, filepath: Path, **kwargs: Any) -> None:
65
+ """Write the generated output to a file.
66
+
67
+ This is a template method that handles directory creation and
68
+ delegates the actual writing to the _save_to_file hook.
69
+
70
+ Args:
71
+ robot: Robot model to export
72
+ filepath: Destination file path
73
+ **kwargs: Options passed to generate() and _save_to_file()
74
+ """
75
+ try:
76
+ # Ensure parent directory exists
77
+ filepath.parent.mkdir(parents=True, exist_ok=True)
78
+
79
+ content = self.generate(robot, **kwargs)
80
+ self._save_to_file(content, filepath, **kwargs)
81
+ except Exception as e:
82
+ if isinstance(e, LinkForgeError):
83
+ raise
84
+ raise RobotGeneratorError(str(filepath), str(e)) from e
85
+
86
+ def _save_to_file(self, content: T, filepath: Path, **_kwargs: Any) -> None:
87
+ """Default I/O hook for saving content.
88
+
89
+ Supports both string (text) and binary (bytes) content by default.
90
+ Formats requiring specialized serialization should override this.
91
+
92
+ Args:
93
+ content: Generated content from generate()
94
+ filepath: Target file path
95
+ **kwargs: Additional options
96
+ """
97
+ if isinstance(content, str):
98
+ filepath.write_text(content, encoding="utf-8")
99
+ elif isinstance(content, bytes):
100
+ filepath.write_bytes(content)
101
+ else:
102
+ raise RobotGeneratorError(self.__class__.__name__, type(content))
103
+
104
+
105
+ class RobotParser(ABC, Generic[T]):
106
+ """Abstract base class for all Robot Parsers."""
107
+
108
+ @abstractmethod
109
+ def parse(self, filepath: Path, **kwargs: Any) -> T:
110
+ """Parse a file into a model.
111
+
112
+ Args:
113
+ filepath: Path to the input file
114
+ **kwargs: Format-specific parsing options
115
+
116
+ Returns:
117
+ The parsed model (e.g. Robot, SemanticRobotDescription)
118
+ """
119
+ pass
120
+
121
+ @abstractmethod
122
+ def parse_string(self, content: str, **kwargs: Any) -> T:
123
+ """Parse a string representation into a model.
124
+
125
+ Args:
126
+ content: The string content to parse
127
+ **kwargs: Format-specific parsing options
128
+
129
+ Returns:
130
+ The parsed model (e.g. Robot, SemanticRobotDescription)
131
+ """
132
+ pass
133
+
134
+
135
+ @runtime_checkable
136
+ class IResourceResolver(Protocol):
137
+ """Protocol for resolving resource URIs (e.g. package://, file://, https://)."""
138
+
139
+ def resolve(self, uri: str, relative_to: Path | None = None) -> Path:
140
+ """Resolve a URI to a local filesystem Path.
141
+
142
+ Args:
143
+ uri: The resource URI to resolve.
144
+ relative_to: Optional base directory for relative path resolution.
145
+
146
+ Returns:
147
+ The resolved absolute Path.
148
+
149
+ Raises:
150
+ FileNotFoundError: If the resource cannot be located.
151
+ """
152
+ ...
153
+
154
+
155
+ class FileSystemResolver:
156
+ """Default resolver for local file paths, file://, and package:// URIs."""
157
+
158
+ def __init__(self, additional_search_paths: list[Path] | None = None) -> None:
159
+ """Initialize the resolver.
160
+
161
+ Args:
162
+ additional_search_paths: Optional list of paths to check before ROS_PACKAGE_PATH.
163
+ """
164
+ self.additional_search_paths = additional_search_paths
165
+
166
+ def resolve(self, uri: str, relative_to: Path | None = None) -> Path:
167
+ """Resolve standard file paths, file:// URIs, and package:// URIs."""
168
+ # Handle package:// URIs
169
+ if "package://" in uri or "package:/" in uri:
170
+ # We use an empty Path if relative_to is not provided,
171
+ # though package resolution usually doesn't strictly need it if ROS_PACKAGE_PATH is set.
172
+ resolved = resolve_package_path(
173
+ uri, relative_to or Path.cwd(), additional_search_paths=self.additional_search_paths
174
+ )
175
+ if resolved and resolved.exists():
176
+ return resolved.absolute()
177
+ raise FileNotFoundError(uri)
178
+
179
+ # Handle file:// URIs
180
+ if uri.startswith("file://"):
181
+ path = normalize_uri_to_path(uri)
182
+ if path.exists():
183
+ return path.absolute()
184
+ raise FileNotFoundError(uri)
185
+
186
+ # Handle standard paths (absolute or relative)
187
+ path = Path(uri)
188
+ if path.is_absolute():
189
+ if path.exists():
190
+ return path.absolute()
191
+ elif relative_to is not None:
192
+ # Try relative to the provided directory
193
+ rel_path = (relative_to / path).resolve()
194
+ if rel_path.exists():
195
+ return rel_path
196
+
197
+ # Final fallback: current working directory if it exists there
198
+ if path.exists():
199
+ return path.absolute()
200
+
201
+ raise FileNotFoundError(uri)
202
+
203
+
204
+ class NetworkResolver:
205
+ """Mock network resolver for URL-based meshes.
206
+
207
+ This is a placeholder for future cloud integrations (e.g. AWS S3, HTTP).
208
+ Currently raises a NotImplementedError if a network URI is detected.
209
+ """
210
+
211
+ def resolve(self, uri: str, relative_to: Path | None = None) -> Path:
212
+ """Simulate network resolution."""
213
+ if any(uri.startswith(p) for p in ("http://", "https://", "s3://")):
214
+ # In a real implementation, this would download to a /tmp cache
215
+ raise NotImplementedError(uri)
216
+
217
+ # Fallback to standard filesystem if it's a local path
218
+ return FileSystemResolver().resolve(uri, relative_to=relative_to)