linkforge-core 1.5.2__tar.gz → 1.5.4__tar.gz

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 (54) hide show
  1. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/PKG-INFO +36 -5
  2. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/README.md +35 -4
  3. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/pyproject.toml +1 -1
  4. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/__init__.py +21 -1
  5. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/_utils/math_utils.py +12 -1
  6. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/_utils/path_utils.py +10 -26
  7. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/_utils/xml_utils.py +20 -30
  8. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/base.py +4 -11
  9. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/composer/__init__.py +2 -1
  10. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/composer/interfaces.py +14 -3
  11. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/composer/link_builder.py +5 -8
  12. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/composer/robot_builder.py +18 -0
  13. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/constants.py +48 -33
  14. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/exceptions.py +26 -44
  15. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/generators/srdf_generator.py +5 -4
  16. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/generators/urdf_generator.py +13 -12
  17. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/generators/xacro_generator.py +19 -18
  18. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/logging_config.py +3 -1
  19. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/link.py +2 -2
  20. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/parsers/urdf_parser.py +60 -29
  21. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/parsers/xacro_parser.py +9 -9
  22. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/physics/inertia.py +5 -11
  23. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/validation/checks.py +100 -97
  24. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/.gitignore +0 -0
  25. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/LICENSE +0 -0
  26. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/_utils/__init__.py +0 -0
  27. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/_utils/dict_utils.py +0 -0
  28. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/_utils/string_utils.py +0 -0
  29. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/composer/helpers.py +0 -0
  30. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/composer/semantic_builder.py +0 -0
  31. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/generators/__init__.py +0 -0
  32. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/generators/xml_base.py +0 -0
  33. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/io.py +0 -0
  34. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/__init__.py +0 -0
  35. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/gazebo.py +0 -0
  36. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/geometry.py +0 -0
  37. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/graph.py +0 -0
  38. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/joint.py +0 -0
  39. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/material.py +0 -0
  40. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/robot.py +0 -0
  41. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/ros2_control.py +0 -0
  42. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/sensor.py +0 -0
  43. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/srdf.py +0 -0
  44. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/models/transmission.py +0 -0
  45. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/parsers/__init__.py +0 -0
  46. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/parsers/srdf_parser.py +0 -0
  47. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/parsers/xml_base.py +0 -0
  48. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/physics/__init__.py +0 -0
  49. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/physics/mesh_validation.py +0 -0
  50. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/py.typed +0 -0
  51. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/validation/__init__.py +0 -0
  52. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/validation/result.py +0 -0
  53. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/validation/security.py +0 -0
  54. {linkforge_core-1.5.2 → linkforge_core-1.5.4}/src/linkforge/core/validation/validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: linkforge-core
3
- Version: 1.5.2
3
+ Version: 1.5.4
4
4
  Summary: LinkForge Core: Programmable IR and Physical Validation Engine for Robotics
5
5
  Project-URL: Homepage, https://github.com/arounamounchili/linkforge
6
6
  Project-URL: Documentation, https://linkforge.readthedocs.io/
@@ -41,7 +41,7 @@ Description-Content-Type: text/markdown
41
41
 
42
42
  Writing and maintaining URDF or SRDF by hand is fragile: inertia values are guessed, collision geometries drift, and physics bugs surface only after a simulator crash (or worse, on hardware). LinkForge Core solves this by treating your robot as **source code with physical constraints**, not a static XML document.
43
43
 
44
- It provides a mathematically rigorous, zero-dependency Intermediate Representation (IR) engine with hardened physical validation, scientific inertia solvers (Mirtich / Sylvester), and lossless round-trip translation between **URDF**, **XACRO**, and **SRDF**.
44
+ It provides a mathematically rigorous, lightweight Intermediate Representation (IR) engine with hardened physical validation, scientific inertia solvers (Mirtich / Sylvester), and lossless round-trip translation between **URDF**, **XACRO**, and **SRDF**.
45
45
 
46
46
  ## Installation
47
47
 
@@ -49,14 +49,14 @@ It provides a mathematically rigorous, zero-dependency Intermediate Representati
49
49
  pip install linkforge-core
50
50
  ```
51
51
 
52
- Zero external dependencies. No Blender, no ROS installation, no C++ compilation required.
52
+ Minimal pure-Python dependencies (only PyYAML). No Blender, no ROS installation, no C++ compilation required.
53
53
 
54
54
  ## Why LinkForge Core?
55
55
 
56
56
  - **Physically Guaranteed Sim Stability**: Zero-mass links or unphysical inertia tensors cause simulators like Gazebo or Isaac Sim to crash. LinkForge Core uses the **Mirtich algorithm** (Divergence Theorem) to calculate exact inertia properties from geometries, validated against **Sylvester's Criterion** to ensure physical validity.
57
57
  - **Standardized & Namespaced Assembly**: Easily compile complex robots, merge multiple sub-assemblies (e.g. attaching a gripper to an arm), and apply joint prefixing and limits programmatically using the fluent **Composer API**.
58
58
  - **Hardened Sandboxed Security**: Safely parse untrusted third-party robot descriptions. LinkForge Core blocks path-traversal attacks and restrains file reading to designated package boundaries.
59
- - **Light & Portable**: Zero external dependencies. No C++ compilation required, making it highly portable across standard Python environments, CI/CD pipelines, and HPC clusters.
59
+ - **Light & Portable**: Minimal pure-Python footprint with no C++ extensions. Highly portable across standard Python environments, CI/CD pipelines, and HPC clusters.
60
60
 
61
61
  ## Quickstart
62
62
 
@@ -116,6 +116,36 @@ else:
116
116
  print(f" [{issue.code.name}] {issue.message} on {issue.affected_objects}")
117
117
  ```
118
118
 
119
+ ### Modular Assembly & Sub-Assemblies
120
+
121
+ Compose validated sub-assemblies from reusable robot components with automated naming prefixing and transform offsets:
122
+
123
+ ```python
124
+ from linkforge.core import RobotBuilder, read_urdf
125
+
126
+ base_builder = RobotBuilder(robot=read_urdf("mobile_base.urdf"))
127
+ arm_builder = RobotBuilder(robot=read_urdf("manipulator.urdf"))
128
+
129
+ # Attach arm onto the base, auto-resolving naming conflicts
130
+ base_builder.attach(
131
+ arm_builder,
132
+ at_link="top_plate",
133
+ prefix="left_arm_",
134
+ xyz=(0, 0, 0.1),
135
+ )
136
+
137
+ # Add a MoveIt 2 planning group for the combined assembly
138
+ base_builder.semantic.group(
139
+ "arm_chain",
140
+ base_link="top_plate",
141
+ tip_link="left_arm_end_effector",
142
+ )
143
+
144
+ robot = base_builder.build(validate=True)
145
+ urdf_xml = base_builder.export_urdf()
146
+ srdf_xml = base_builder.export_srdf()
147
+ ```
148
+
119
149
  ### MoveIt 2 & SRDF Semantic Composition
120
150
 
121
151
  Programmatically compose MoveIt 2 planning groups, end-effectors, and self-collision matrices for SRDF without manually editing XML:
@@ -133,7 +163,8 @@ builder.semantic.group(
133
163
  tip_link="wrist_link",
134
164
  )
135
165
 
136
- # Define an end-effector
166
+ # Define an end-effector group and register the end-effector
167
+ builder.semantic.group("gripper_group", links=["gripper_link"])
137
168
  builder.semantic.end_effector(
138
169
  name="gripper",
139
170
  group="gripper_group",
@@ -12,7 +12,7 @@
12
12
 
13
13
  Writing and maintaining URDF or SRDF by hand is fragile: inertia values are guessed, collision geometries drift, and physics bugs surface only after a simulator crash (or worse, on hardware). LinkForge Core solves this by treating your robot as **source code with physical constraints**, not a static XML document.
14
14
 
15
- It provides a mathematically rigorous, zero-dependency Intermediate Representation (IR) engine with hardened physical validation, scientific inertia solvers (Mirtich / Sylvester), and lossless round-trip translation between **URDF**, **XACRO**, and **SRDF**.
15
+ It provides a mathematically rigorous, lightweight Intermediate Representation (IR) engine with hardened physical validation, scientific inertia solvers (Mirtich / Sylvester), and lossless round-trip translation between **URDF**, **XACRO**, and **SRDF**.
16
16
 
17
17
  ## Installation
18
18
 
@@ -20,14 +20,14 @@ It provides a mathematically rigorous, zero-dependency Intermediate Representati
20
20
  pip install linkforge-core
21
21
  ```
22
22
 
23
- Zero external dependencies. No Blender, no ROS installation, no C++ compilation required.
23
+ Minimal pure-Python dependencies (only PyYAML). No Blender, no ROS installation, no C++ compilation required.
24
24
 
25
25
  ## Why LinkForge Core?
26
26
 
27
27
  - **Physically Guaranteed Sim Stability**: Zero-mass links or unphysical inertia tensors cause simulators like Gazebo or Isaac Sim to crash. LinkForge Core uses the **Mirtich algorithm** (Divergence Theorem) to calculate exact inertia properties from geometries, validated against **Sylvester's Criterion** to ensure physical validity.
28
28
  - **Standardized & Namespaced Assembly**: Easily compile complex robots, merge multiple sub-assemblies (e.g. attaching a gripper to an arm), and apply joint prefixing and limits programmatically using the fluent **Composer API**.
29
29
  - **Hardened Sandboxed Security**: Safely parse untrusted third-party robot descriptions. LinkForge Core blocks path-traversal attacks and restrains file reading to designated package boundaries.
30
- - **Light & Portable**: Zero external dependencies. No C++ compilation required, making it highly portable across standard Python environments, CI/CD pipelines, and HPC clusters.
30
+ - **Light & Portable**: Minimal pure-Python footprint with no C++ extensions. Highly portable across standard Python environments, CI/CD pipelines, and HPC clusters.
31
31
 
32
32
  ## Quickstart
33
33
 
@@ -87,6 +87,36 @@ else:
87
87
  print(f" [{issue.code.name}] {issue.message} on {issue.affected_objects}")
88
88
  ```
89
89
 
90
+ ### Modular Assembly & Sub-Assemblies
91
+
92
+ Compose validated sub-assemblies from reusable robot components with automated naming prefixing and transform offsets:
93
+
94
+ ```python
95
+ from linkforge.core import RobotBuilder, read_urdf
96
+
97
+ base_builder = RobotBuilder(robot=read_urdf("mobile_base.urdf"))
98
+ arm_builder = RobotBuilder(robot=read_urdf("manipulator.urdf"))
99
+
100
+ # Attach arm onto the base, auto-resolving naming conflicts
101
+ base_builder.attach(
102
+ arm_builder,
103
+ at_link="top_plate",
104
+ prefix="left_arm_",
105
+ xyz=(0, 0, 0.1),
106
+ )
107
+
108
+ # Add a MoveIt 2 planning group for the combined assembly
109
+ base_builder.semantic.group(
110
+ "arm_chain",
111
+ base_link="top_plate",
112
+ tip_link="left_arm_end_effector",
113
+ )
114
+
115
+ robot = base_builder.build(validate=True)
116
+ urdf_xml = base_builder.export_urdf()
117
+ srdf_xml = base_builder.export_srdf()
118
+ ```
119
+
90
120
  ### MoveIt 2 & SRDF Semantic Composition
91
121
 
92
122
  Programmatically compose MoveIt 2 planning groups, end-effectors, and self-collision matrices for SRDF without manually editing XML:
@@ -104,7 +134,8 @@ builder.semantic.group(
104
134
  tip_link="wrist_link",
105
135
  )
106
136
 
107
- # Define an end-effector
137
+ # Define an end-effector group and register the end-effector
138
+ builder.semantic.group("gripper_group", links=["gripper_link"])
108
139
  builder.semantic.end_effector(
109
140
  name="gripper",
110
141
  group="gripper_group",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "linkforge-core"
3
- version = "1.5.2" # x-release-please-version
3
+ version = "1.5.4" # x-release-please-version
4
4
  description = "LinkForge Core: Programmable IR and Physical Validation Engine for Robotics"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -11,7 +11,7 @@ for parsing, generating, and validating robot descriptions across formats.
11
11
  from __future__ import annotations
12
12
 
13
13
  # Versioning
14
- __version__ = "1.5.2" # x-release-please-version
14
+ __version__ = "1.5.4" # x-release-please-version
15
15
 
16
16
  # Sub-Package Exposure
17
17
  # (Ensures lf.models, lf.parsers, etc. are accessible via dot-notation)
@@ -26,6 +26,15 @@ from . import (
26
26
  validation,
27
27
  )
28
28
 
29
+ # Domain Utilities
30
+ from ._utils.math_utils import clean_float, format_float
31
+ from ._utils.string_utils import (
32
+ format_scientific,
33
+ is_valid_name,
34
+ parse_scientific,
35
+ sanitize_name,
36
+ )
37
+
29
38
  # Base Architecture: Interfaces and Resolvers
30
39
  from .base import (
31
40
  FileSystemResolver,
@@ -36,6 +45,7 @@ from .base import (
36
45
 
37
46
  # Composer API: The LinkForge way to build robots
38
47
  from .composer import (
48
+ IComposer,
39
49
  LinkBuilder,
40
50
  RobotBuilder,
41
51
  box,
@@ -54,6 +64,7 @@ from .constants import (
54
64
  from .exceptions import (
55
65
  LinkForgeError,
56
66
  RobotGeneratorError,
67
+ RobotGeneratorUnsupportedTypeError,
57
68
  RobotMathError,
58
69
  RobotModelError,
59
70
  RobotParserError,
@@ -259,12 +270,20 @@ __all__ = [
259
270
  "IResourceResolver",
260
271
  "FileSystemResolver",
261
272
  # Composer API
273
+ "IComposer",
262
274
  "RobotBuilder",
263
275
  "LinkBuilder",
264
276
  "box",
265
277
  "cylinder",
266
278
  "sphere",
267
279
  "mesh",
280
+ # Domain Utilities
281
+ "clean_float",
282
+ "format_float",
283
+ "sanitize_name",
284
+ "is_valid_name",
285
+ "format_scientific",
286
+ "parse_scientific",
268
287
  # Validation & Physics
269
288
  "RobotValidator",
270
289
  "ValidationResult",
@@ -280,6 +299,7 @@ __all__ = [
280
299
  "RobotParserError",
281
300
  "RobotParserIOError",
282
301
  "RobotGeneratorError",
302
+ "RobotGeneratorUnsupportedTypeError",
283
303
  "RobotValidationError",
284
304
  "RobotPhysicsError",
285
305
  "RobotMathError",
@@ -6,11 +6,13 @@ import math
6
6
 
7
7
  from ..constants import (
8
8
  EPSILON,
9
+ FLOAT_CLEAN_EPSILON,
10
+ SCIENTIFIC_NOTATION_THRESHOLD,
9
11
  SYLVESTER_TOLERANCE_EPSILON,
10
12
  )
11
13
 
12
14
 
13
- def clean_float(value: float, epsilon: float = EPSILON) -> float:
15
+ def clean_float(value: float, epsilon: float = FLOAT_CLEAN_EPSILON) -> float:
14
16
  """Clean up floating point values to avoid -0.0 and very small numbers.
15
17
 
16
18
  Args:
@@ -37,6 +39,15 @@ def format_float(value: float, precision: int = 6) -> str:
37
39
  """
38
40
  # Clean up small values and -0.0 first
39
41
  cleaned = clean_float(value)
42
+ if cleaned == 0.0:
43
+ return "0"
44
+
45
+ # Use scientific notation for very small numbers to avoid zeroing them out
46
+ if abs(cleaned) < SCIENTIFIC_NOTATION_THRESHOLD:
47
+ formatted = f"{cleaned:.{precision}e}"
48
+ mantissa, exp = formatted.split("e")
49
+ mantissa = mantissa.rstrip("0").rstrip(".")
50
+ return f"{mantissa}e{exp}"
40
51
 
41
52
  # Format with specified precision
42
53
  formatted = f"{cleaned:.{precision}f}"
@@ -1,5 +1,6 @@
1
1
  """Path and resource resolution utilities for LinkForge."""
2
2
 
3
+ import contextlib
3
4
  import os
4
5
  import re
5
6
  from pathlib import Path
@@ -91,13 +92,11 @@ def resolve_package_path(
91
92
 
92
93
 
93
94
  def _extract_package_name(xml_path: Path) -> str | None:
94
- """Extract <name> from package.xml using regex for performance."""
95
+ """Extract <name> from package.xml."""
95
96
  try:
96
- # Lightweight scan of the beginning of the file
97
- with open(xml_path, encoding="utf-8") as f:
98
- content = f.read(1024)
99
- match = re.search(r"<name>(.*?)</name>", content)
100
- return match.group(1).strip() if match else None
97
+ content = xml_path.read_text(encoding="utf-8")
98
+ match = re.search(r"<name>(.*?)</name>", content)
99
+ return match.group(1).strip() if match else None
101
100
  except Exception:
102
101
  return None
103
102
 
@@ -142,28 +141,13 @@ def get_export_path(resource: str, relative_to: Path | None = None) -> str:
142
141
  The string to be used in the 'filename' attribute.
143
142
  """
144
143
  # Preserve package:// URIs (never make relative)
145
- if resource.startswith("package://") or resource.startswith("package:/"):
144
+ if resource.startswith(("package://", "package:/")):
146
145
  return resource
147
146
 
148
- # Handle file:// URIs
149
- if resource.startswith("file://"):
150
- path = normalize_uri_to_path(resource)
151
- if relative_to and path.is_absolute():
152
- try:
153
- # Use .absolute() on relative_to just in case it's not
154
- rel = path.relative_to(relative_to.absolute())
155
- return str(rel)
156
- except ValueError:
157
- pass
158
- return resource
159
-
160
- # Handle standard paths
161
- path = Path(resource)
147
+ # Convert file:// URI or filesystem path to Path and relativize if possible
148
+ path = normalize_uri_to_path(resource)
162
149
  if relative_to and path.is_absolute():
163
- try:
164
- rel = path.relative_to(relative_to.absolute())
165
- return str(rel)
166
- except ValueError:
167
- pass
150
+ with contextlib.suppress(ValueError):
151
+ return str(path.relative_to(relative_to.absolute()))
168
152
 
169
153
  return resource
@@ -3,7 +3,6 @@
3
3
  import math
4
4
  import xml.etree.ElementTree as ET
5
5
  from collections.abc import Callable
6
- from datetime import datetime
7
6
  from typing import Any
8
7
 
9
8
  from ..constants import (
@@ -77,22 +76,22 @@ def validate_xml_depth(element: ET.Element, depth: int = 0) -> None:
77
76
  validate_xml_depth(child, depth + 1)
78
77
 
79
78
 
80
- def get_xml_header(element: ET.Element, version: str) -> str:
79
+ def get_xml_header(element: ET.Element, version: str | None = None) -> str:
81
80
  """Standardized header comment for XML files.
82
81
 
83
82
  Args:
84
83
  element: Root XML element (to extract robot name)
85
- version: LinkForge version
84
+ version: Optional LinkForge version
86
85
 
87
86
  Returns:
88
87
  Header string containing XML declaration and meta comment
89
88
  """
89
+ version_str = f" v{version}" if version else ""
90
90
  return f"""<?xml version="1.0"?>
91
91
  <!--
92
92
  Robot: {element.get("name", "robot")}
93
- Generated by: LinkForge v{version}
93
+ Generated by: LinkForge{version_str}
94
94
  https://github.com/arounamounchili/linkforge
95
- Date: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
96
95
  -->
97
96
  """
98
97
 
@@ -111,7 +110,7 @@ def indent_xml(element: ET.Element, space: str = " ", level: int = 0) -> None:
111
110
  def serialize_xml(
112
111
  element: ET.Element,
113
112
  pretty_print: bool = True,
114
- version: str = "unknown",
113
+ version: str | None = None,
115
114
  namespaces: dict[str, str] | None = None,
116
115
  ) -> str:
117
116
  """Convert XML element to standardized LinkForge string.
@@ -119,7 +118,7 @@ def serialize_xml(
119
118
  Args:
120
119
  element: XML element to serialize
121
120
  pretty_print: Whether to indent
122
- version: LinkForge version for header
121
+ version: Optional LinkForge version for header
123
122
  namespaces: Optional dict of {prefix: uri} to register
124
123
 
125
124
  Returns:
@@ -152,7 +151,6 @@ def parse_float(
152
151
  text: str | None,
153
152
  attribute_name: str = "value",
154
153
  default: float | None = None,
155
- check_name: str | None = None,
156
154
  ) -> float:
157
155
  """Parse float value from XML with comprehensive validation.
158
156
 
@@ -160,7 +158,6 @@ def parse_float(
160
158
  text: String to parse
161
159
  attribute_name: Context for error messages
162
160
  default: Default value if text is None
163
- check_name: Alias for attribute_name used in some parsers
164
161
 
165
162
  Returns:
166
163
  Parsed float
@@ -169,9 +166,6 @@ def parse_float(
169
166
  RobotMathError: If input is invalid
170
167
  RobotValidationError: If attribute is missing
171
168
  """
172
- # Alias check_name if provided
173
- report_name = check_name or attribute_name
174
-
175
169
  if text is not None and not text.strip():
176
170
  text = None
177
171
 
@@ -180,8 +174,8 @@ def parse_float(
180
174
  return default
181
175
  raise RobotValidationError(
182
176
  ValidationErrorCode.VALUE_EMPTY,
183
- f"Missing required attribute: {report_name}",
184
- target=report_name,
177
+ f"Missing required attribute: {attribute_name}",
178
+ target=attribute_name,
185
179
  )
186
180
 
187
181
  try:
@@ -189,8 +183,8 @@ def parse_float(
189
183
  if math.isnan(value) or math.isinf(value):
190
184
  raise RobotMathError(
191
185
  ValidationErrorCode.INVALID_VALUE,
192
- f"Non-finite float value '{value}' in {report_name}",
193
- target=report_name,
186
+ f"Non-finite float value '{value}' in {attribute_name}",
187
+ target=attribute_name,
194
188
  value=value,
195
189
  )
196
190
 
@@ -199,8 +193,8 @@ def parse_float(
199
193
  if not (-MAX_REASONABLE_FLOAT < value < MAX_REASONABLE_FLOAT):
200
194
  raise RobotMathError(
201
195
  ValidationErrorCode.OUT_OF_RANGE,
202
- f"Float value '{value}' in {report_name} is outside reasonable range",
203
- target=report_name,
196
+ f"Float value '{value}' in {attribute_name} is outside reasonable range",
197
+ target=attribute_name,
204
198
  value=value,
205
199
  )
206
200
 
@@ -208,8 +202,8 @@ def parse_float(
208
202
  except ValueError:
209
203
  raise RobotMathError(
210
204
  ValidationErrorCode.INVALID_VALUE,
211
- f"Invalid float format '{text}' in {report_name}",
212
- target=report_name,
205
+ f"Invalid float format '{text}' in {attribute_name}",
206
+ target=attribute_name,
213
207
  value=text,
214
208
  ) from None
215
209
 
@@ -218,7 +212,6 @@ def parse_int(
218
212
  text: str | None,
219
213
  attribute_name: str = "value",
220
214
  default: int | None = None,
221
- check_name: str | None = None,
222
215
  ) -> int:
223
216
  """Parse integer value from XML with comprehensive validation.
224
217
 
@@ -226,7 +219,6 @@ def parse_int(
226
219
  text: String to parse
227
220
  attribute_name: Context for error messages
228
221
  default: Default value if text is None
229
- check_name: Alias for attribute_name for consistent reporting
230
222
 
231
223
  Returns:
232
224
  Parsed integer
@@ -235,8 +227,6 @@ def parse_int(
235
227
  RobotMathError: If input format is invalid or value is out of range
236
228
  RobotValidationError: If attribute is missing and no default is provided
237
229
  """
238
- report_name = check_name or attribute_name
239
-
240
230
  if text is not None and not text.strip():
241
231
  text = None
242
232
 
@@ -245,8 +235,8 @@ def parse_int(
245
235
  return default
246
236
  raise RobotValidationError(
247
237
  ValidationErrorCode.VALUE_EMPTY,
248
- f"Missing required attribute: {report_name}",
249
- target=report_name,
238
+ f"Missing required attribute: {attribute_name}",
239
+ target=attribute_name,
250
240
  )
251
241
 
252
242
  try:
@@ -255,16 +245,16 @@ def parse_int(
255
245
  if not (-MAX_REASONABLE_INT < value < MAX_REASONABLE_INT):
256
246
  raise RobotMathError(
257
247
  ValidationErrorCode.OUT_OF_RANGE,
258
- f"Integer value '{value}' in {report_name} is outside reasonable range",
259
- target=report_name,
248
+ f"Integer value '{value}' in {attribute_name} is outside reasonable range",
249
+ target=attribute_name,
260
250
  value=value,
261
251
  )
262
252
  return value
263
253
  except ValueError:
264
254
  raise RobotMathError(
265
255
  ValidationErrorCode.INVALID_VALUE,
266
- f"Invalid integer format '{text}' in {report_name}",
267
- target=report_name,
256
+ f"Invalid integer format '{text}' in {attribute_name}",
257
+ target=attribute_name,
268
258
  value=text,
269
259
  ) from None
270
260
 
@@ -20,9 +20,7 @@ from ._utils.path_utils import normalize_uri_to_path, resolve_package_path
20
20
  from .exceptions import (
21
21
  LinkForgeError,
22
22
  RobotGeneratorError,
23
- RobotModelError,
24
- RobotParserError,
25
- XacroDetectedError,
23
+ RobotGeneratorUnsupportedTypeError,
26
24
  )
27
25
 
28
26
  if TYPE_CHECKING:
@@ -32,15 +30,10 @@ if TYPE_CHECKING:
32
30
  T = TypeVar("T")
33
31
 
34
32
  __all__ = [
33
+ "FileSystemResolver",
34
+ "IResourceResolver",
35
35
  "RobotGenerator",
36
36
  "RobotParser",
37
- "IResourceResolver",
38
- "FileSystemResolver",
39
- "LinkForgeError",
40
- "RobotGeneratorError",
41
- "RobotModelError",
42
- "RobotParserError",
43
- "XacroDetectedError",
44
37
  ]
45
38
 
46
39
 
@@ -98,7 +91,7 @@ class RobotGenerator(ABC, Generic[T]):
98
91
  elif isinstance(content, bytes):
99
92
  filepath.write_bytes(content)
100
93
  else:
101
- raise RobotGeneratorError(self.__class__.__name__, type(content))
94
+ raise RobotGeneratorUnsupportedTypeError(type(content), self.__class__.__name__)
102
95
 
103
96
 
104
97
  class RobotParser(ABC, Generic[T]):
@@ -5,7 +5,8 @@ by programmatically constructing links, joints, and semantic data.
5
5
  """
6
6
 
7
7
  from .helpers import box, cylinder, mesh, sphere
8
+ from .interfaces import IComposer
8
9
  from .link_builder import LinkBuilder
9
10
  from .robot_builder import RobotBuilder
10
11
 
11
- __all__ = ["RobotBuilder", "LinkBuilder", "box", "cylinder", "sphere", "mesh"]
12
+ __all__ = ["IComposer", "RobotBuilder", "LinkBuilder", "box", "cylinder", "sphere", "mesh"]
@@ -6,19 +6,18 @@ communicate without tight coupling.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from typing import TYPE_CHECKING, Protocol
9
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
10
10
 
11
11
  if TYPE_CHECKING:
12
12
  from ..models.robot import Robot
13
13
  from .link_builder import LinkBuilder
14
14
 
15
15
 
16
+ @runtime_checkable
16
17
  class IComposer(Protocol):
17
18
  """Interface for a builder that can contain and manage links."""
18
19
 
19
20
  robot: Robot
20
- _active_link_builders: list[LinkBuilder]
21
- _parent_stack: list[str]
22
21
 
23
22
  def link(
24
23
  self, name: str, parent: str | None = None, joint_name: str | None = None
@@ -35,3 +34,15 @@ class IComposer(Protocol):
35
34
  def build(self, validate: bool = True) -> Robot:
36
35
  """Finalize the assembly and return the completed Robot model."""
37
36
  ...
37
+
38
+ def register_link_builder(self, builder: LinkBuilder) -> None:
39
+ """Register an active LinkBuilder with this composer."""
40
+ ...
41
+
42
+ def push_parent(self, name: str) -> None:
43
+ """Push a parent link name onto the hierarchy context stack."""
44
+ ...
45
+
46
+ def pop_parent(self, name: str | None = None) -> None:
47
+ """Pop a parent link name from the hierarchy context stack."""
48
+ ...
@@ -11,7 +11,6 @@ Core Components:
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- import logging
15
14
  from dataclasses import dataclass, field, replace
16
15
  from typing import TYPE_CHECKING, Any
17
16
 
@@ -34,6 +33,7 @@ from ..constants import (
34
33
  HW_IF_EFFORT,
35
34
  )
36
35
  from ..exceptions import RobotValidationError, ValidationErrorCode
36
+ from ..logging_config import get_logger
37
37
  from ..models.gazebo import GazeboElement
38
38
  from ..models.geometry import Geometry, Transform, Vector3
39
39
  from ..models.joint import (
@@ -65,7 +65,7 @@ from ..physics.inertia import calculate_inertia
65
65
  if TYPE_CHECKING:
66
66
  from .interfaces import IComposer
67
67
 
68
- logger = logging.getLogger(__name__)
68
+ logger = get_logger(__name__)
69
69
 
70
70
 
71
71
  @dataclass
@@ -126,7 +126,7 @@ class LinkBuilder:
126
126
  self._committed = False
127
127
  self._in_context = False
128
128
 
129
- self._builder._active_link_builders.append(self)
129
+ self._builder.register_link_builder(self)
130
130
 
131
131
  def __enter__(self) -> LinkBuilder:
132
132
  """Enter the context of this link.
@@ -140,7 +140,7 @@ class LinkBuilder:
140
140
  skeletal_link = Link(name=self._link_name)
141
141
  self._builder.robot.add_link(skeletal_link, overwrite=True)
142
142
 
143
- self._builder._parent_stack.append(self._link_name)
143
+ self._builder.push_parent(self._link_name)
144
144
  return self
145
145
 
146
146
  def __exit__(
@@ -154,10 +154,7 @@ class LinkBuilder:
154
154
  Pops this link's name from the parent stack and automatically commits the
155
155
  link to flush its configured properties if no exception occurred.
156
156
  """
157
- if self._builder._parent_stack and self._builder._parent_stack[-1] == self._link_name:
158
- self._builder._parent_stack.pop()
159
- elif self._link_name in self._builder._parent_stack:
160
- self._builder._parent_stack.remove(self._link_name)
157
+ self._builder.pop_parent(self._link_name)
161
158
 
162
159
  if exc_type is None:
163
160
  self._commit()
@@ -59,6 +59,24 @@ class RobotBuilder:
59
59
  self._active_link_builders: list[LinkBuilder] = []
60
60
  self._parent_stack: list[str] = []
61
61
 
62
+ def register_link_builder(self, builder: LinkBuilder) -> None:
63
+ """Register an active LinkBuilder with this composer."""
64
+ self._active_link_builders.append(builder)
65
+
66
+ def push_parent(self, name: str) -> None:
67
+ """Push a parent link name onto the hierarchy context stack."""
68
+ self._parent_stack.append(name)
69
+
70
+ def pop_parent(self, name: str | None = None) -> None:
71
+ """Pop a parent link name from the hierarchy context stack."""
72
+ if name is not None:
73
+ if self._parent_stack and self._parent_stack[-1] == name:
74
+ self._parent_stack.pop()
75
+ elif name in self._parent_stack:
76
+ self._parent_stack.remove(name)
77
+ elif self._parent_stack:
78
+ self._parent_stack.pop()
79
+
62
80
  def link(
63
81
  self, name: str, parent: str | None = None, joint_name: str | None = None
64
82
  ) -> LinkBuilder: