Skip to content

Commit de5fd2a

Browse files
refactor: harden integration infrastructure and professionalize static analysis for v1.4.0
Signed-off-by: arounamounchili <patouossa.mounchili@gmail.com>
1 parent f544c76 commit de5fd2a

48 files changed

Lines changed: 2497 additions & 656 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Justfile

Lines changed: 57 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
# LinkForge Developer Commands
22
# Standardizes workflows across macOS, Linux, and Windows
33

4-
54
# Default: List available commands
65
default:
7-
@just --list
6+
@just --list
87

98
# --- Build ---
109

@@ -13,84 +12,107 @@ build: build-blender
1312

1413
# Build Blender Extension
1514
build-blender:
16-
uv run python platforms/blender/scripts/build.py
15+
uv run python platforms/blender/scripts/build.py
1716

1817
# Sync Blender dependencies (downloads platform-specific wheels)
1918
sync:
20-
uv run python platforms/blender/scripts/build.py sync
19+
uv run python platforms/blender/scripts/build.py sync
2120

2221
# Link Blender extension for development (Blender 4.2+)
2322
develop:
24-
uv run python platforms/blender/scripts/build.py develop
23+
uv run python platforms/blender/scripts/build.py develop
2524

2625
# --- Test ---
2726

2827
# Run all tests (Core + Blender)
2928
test: test-core test-blender-logic test-blender
3029

31-
# Run Core unit tests (platform-independent)
32-
test-core:
33-
uv run pytest tests/unit/core tests/integration/core
30+
# Run all unit tests (platform-independent + mock-blender)
31+
test-unit: test-unit-core test-blender-logic
32+
33+
# Run Core tests (unit + integration)
34+
test-core: test-unit-core test-integration-core
35+
36+
# Run Core unit tests
37+
test-unit-core:
38+
uv run pytest tests/unit/core
39+
40+
# Run Core integration tests
41+
test-integration-core:
42+
uv run pytest tests/integration/core
3443

35-
# Run Blender logic unit tests (Uses fake-bpy-module)
44+
# Run Blender logic unit tests (Uses mock_bpy_env)
3645
test-blender-logic:
37-
uv run pytest tests/unit/platforms/blender
46+
uv run pytest tests/unit/platforms/blender
3847

3948
# Run Blender integration tests (Requires real Blender)
4049
test-blender:
41-
uv run python blender_launcher.py -- --cov=linkforge --cov-append
50+
uv run python blender_launcher.py -- --cov=linkforge --cov-append
4251

4352
# Run tests with coverage
4453
coverage:
45-
rm -f .coverage .coverage.*
54+
@rm -f .coverage .coverage.*
4655
COVERAGE_FILE=.coverage.core uv run pytest tests/unit/core tests/integration/core --cov=linkforge_core
4756
COVERAGE_FILE=.coverage.blender uv run python blender_launcher.py -- --cov=linkforge --cov=linkforge_core
4857
uv run coverage combine
4958
uv run coverage html
5059
uv run coverage report
5160

52-
# Run all quality checks
53-
check: lint type-check
61+
# --- Quality ---
62+
63+
# Run all quality checks (format, lint, type)
64+
check: check-format lint type-check
65+
66+
# Check if code is formatted correctly
67+
check-format:
68+
uv run ruff format --check .
5469

5570
# Run linter (Ruff)
5671
lint:
57-
uv run ruff check .
72+
uv run ruff check .
5873

59-
# Fix linting issues automatically
74+
# Fix linting and formatting issues automatically
6075
fix:
61-
uv run ruff check . --fix
62-
uv run ruff format .
76+
uv run ruff check . --fix
77+
uv run ruff format .
78+
79+
# Run all type checkers (MyPy + Pyright)
80+
type-check: type-check-mypy type-check-pyright
81+
82+
# Run MyPy type checker
83+
type-check-mypy:
84+
uv run mypy core/src/linkforge_core platforms/blender/linkforge
6385

64-
# Run type checker (MyPy)
65-
type-check:
66-
uv run mypy core/src/linkforge_core platforms/blender/linkforge
86+
# Run Pyright type checker
87+
type-check-pyright:
88+
uv run pyright
6789

6890
# --- Documentation ---
6991

7092
# Build documentation (Sphinx)
7193
docs:
72-
cd docs && make html
73-
@echo "📖 Documentation built at docs/build/html/index.html"
94+
@cd docs && make html
95+
@echo "📖 Documentation built at docs/build/html/index.html"
7496

7597
# --- Maintenance ---
7698

7799
# Clean build artifacts, caches, and OS junk
78100
clean:
79-
@rm -rf dist/ build/ *.egg-info
80-
@rm -rf .pytest_cache .mypy_cache .ruff_cache .codespell_cache
81-
@rm -rf htmlcov .coverage coverage.xml
82-
@find . -type d -name "__pycache__" -exec rm -rf {} +
83-
@find . -type f -name "*.py[co]" -delete
84-
@find . -name ".DS_Store" -delete
85-
@echo "✨ Project is clean."
101+
@rm -rf dist/ build/ *.egg-info
102+
@rm -rf .pytest_cache .mypy_cache .ruff_cache .codespell_cache
103+
@rm -rf htmlcov .coverage coverage.xml
104+
@find . -type d -name "__pycache__" -exec rm -rf {} +
105+
@find . -type f -name "*.py[co]" -delete
106+
@find . -name ".DS_Store" -delete
107+
@echo "✨ Project is clean."
86108

87109
# Deep clean: Includes virtual environment removal
88110
clean-all: clean
89-
@echo "⚠️ Removing virtual environment..."
90-
@rm -rf .venv/ venv/
91-
@echo "💀 Everything has been removed. Run 'just install' to recover."
111+
@echo "⚠️ Removing virtual environment..."
112+
@rm -rf .venv/ venv/
113+
@echo "💀 Everything has been removed. Run 'just install' to recover."
92114

93115
# Install/Sync dependencies
94116
install:
95-
uv sync --all-extras
96-
uv run pre-commit install
117+
uv sync --all-extras
118+
uv run pre-commit install || true

core/src/linkforge_core/models/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from .link import Collision, Inertial, InertiaTensor, Link, LinkPhysics, Visual
3131
from .material import Color, Material
3232
from .robot import Robot
33-
from .ros2_control import Ros2Control, Ros2ControlJoint
33+
from .ros2_control import Ros2Control, Ros2ControlJoint, Ros2ControlSensor
3434
from .sensor import (
3535
CameraInfo,
3636
ContactInfo,
@@ -95,6 +95,7 @@
9595
# ros2_control
9696
"Ros2Control",
9797
"Ros2ControlJoint",
98+
"Ros2ControlSensor",
9899
# Sensor
99100
"SensorType",
100101
"SensorNoise",

core/src/linkforge_core/models/ros2_control.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,40 @@ def normalized(self) -> Ros2ControlJoint:
5454
)
5555

5656

57+
@dataclass(frozen=True)
58+
class Ros2ControlSensor:
59+
"""Sensor configuration in ros2_control block.
60+
61+
Represents a sensor's state interfaces and parameters.
62+
"""
63+
64+
name: str
65+
state_interfaces: Sequence[str] = field(default_factory=tuple)
66+
parameters: dict[str, str] = field(default_factory=dict)
67+
68+
def __post_init__(self) -> None:
69+
"""Validate sensor configuration."""
70+
if not self.name:
71+
raise RobotValidationError(
72+
ValidationErrorCode.NAME_EMPTY,
73+
"ROS2 control sensor name cannot be empty",
74+
target="SensorName",
75+
value=self.name,
76+
)
77+
object.__setattr__(self, "state_interfaces", tuple(self.state_interfaces))
78+
79+
def with_prefix(self, prefix: str) -> Ros2ControlSensor:
80+
"""Create a new control sensor with a prefixed name."""
81+
return replace(self, name=f"{prefix}{self.name}")
82+
83+
def normalized(self) -> Ros2ControlSensor:
84+
"""Return a new control sensor with sorted interfaces."""
85+
return replace(
86+
self,
87+
state_interfaces=tuple(sorted(self.state_interfaces)),
88+
)
89+
90+
5791
@dataclass(frozen=True)
5892
class Ros2Control:
5993
"""ros2_control configuration block.
@@ -66,6 +100,7 @@ class Ros2Control:
66100
type: str = "system" # "system", "actuator", or "sensor"
67101
hardware_plugin: str = ""
68102
joints: Sequence[Ros2ControlJoint] = field(default_factory=tuple)
103+
sensors: Sequence[Ros2ControlSensor] = field(default_factory=tuple)
69104
parameters: dict[str, str] = field(default_factory=dict)
70105

71106
def __post_init__(self) -> None:
@@ -101,6 +136,15 @@ def __post_init__(self) -> None:
101136
target="Ros2ControlJoints",
102137
)
103138

139+
# Ensure all sensors have unique names
140+
sensor_names = [s.name for s in self.sensors]
141+
if len(sensor_names) != len(set(sensor_names)):
142+
raise RobotValidationError(
143+
ValidationErrorCode.DUPLICATE_NAME,
144+
f"Duplicate sensor names found in ROS2 control system '{self.name}'",
145+
target="Ros2ControlSensors",
146+
)
147+
104148
# Hardware sensors are read-only and do not accept command interfaces
105149
if self.type == "sensor":
106150
for joint in self.joints:
@@ -121,18 +165,21 @@ def __post_init__(self) -> None:
121165
value=len(self.joints),
122166
)
123167
object.__setattr__(self, "joints", tuple(self.joints))
168+
object.__setattr__(self, "sensors", tuple(self.sensors))
124169

125170
def with_prefix(self, prefix: str) -> Ros2Control:
126171
"""Create a new control block with prefixed name and joints."""
127172
return replace(
128173
self,
129174
name=f"{prefix}{self.name}",
130175
joints=tuple(j.with_prefix(prefix) for j in self.joints),
176+
sensors=tuple(s.with_prefix(prefix) for s in self.sensors),
131177
)
132178

133179
def normalized(self) -> Ros2Control:
134-
"""Return a new control block with sorted joints for comparison."""
180+
"""Return a new control block with sorted joints and sensors for comparison."""
135181
return replace(
136182
self,
137183
joints=tuple(sorted([j.normalized() for j in self.joints], key=lambda x: x.name)),
184+
sensors=tuple(sorted([s.normalized() for s in self.sensors], key=lambda x: x.name)),
138185
)

core/src/linkforge_core/parsers/urdf_parser.py

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
Robot,
5858
Ros2Control,
5959
Ros2ControlJoint,
60+
Ros2ControlSensor,
6061
Sensor,
6162
SensorNoise,
6263
SensorType,
@@ -482,17 +483,40 @@ def _parse_ros2_control(self, rc_elem: ET.Element) -> Ros2Control | None:
482483
)
483484
)
484485

486+
sensors: list[Ros2ControlSensor] = []
487+
for sensor_elem in rc_elem.findall("{*}sensor"):
488+
sensor_name = sensor_elem.get("name", "")
489+
state_interfaces = [
490+
self._normalize_hardware_interface(state.get("name", "position"))
491+
for state in sensor_elem.findall("{*}state_interface")
492+
]
493+
sensor_params: dict[str, str] = {
494+
str(param.get("name")): param.text.strip()
495+
for param in sensor_elem.findall("{*}param")
496+
if param.get("name") and param.text
497+
}
498+
499+
if state_interfaces or sensor_params:
500+
sensors.append(
501+
Ros2ControlSensor(
502+
name=sensor_name,
503+
state_interfaces=state_interfaces,
504+
parameters=sensor_params,
505+
)
506+
)
507+
485508
# Catch-all for top-level parameters not inside <hardware>
486509
for child in rc_elem:
487-
if child.tag not in ("hardware", "joint") and child.text:
488-
parameters[child.tag] = child.text.strip()
510+
if strip_xml_namespace(child.tag) not in ("hardware", "joint", "sensor") and child.text:
511+
parameters[strip_xml_namespace(child.tag)] = child.text.strip()
489512

490513
try:
491514
return Ros2Control(
492515
name=name,
493516
type=rc_type,
494517
hardware_plugin=hardware_plugin,
495518
joints=joints,
519+
sensors=sensors,
496520
parameters=parameters,
497521
)
498522
except RobotModelError as e:
@@ -1007,26 +1031,26 @@ def _parse_from_context(
10071031

10081032
elif tag == "gazebo":
10091033
try:
1034+
# 1. Try to extract a sensor if present
10101035
sensor = self._parse_sensor_from_gazebo(elem)
10111036
if sensor:
10121037
delayed_sensors.append(sensor)
1013-
else:
1014-
# Extract physics fields before they are lost
1015-
physics_data = {
1016-
"mu": parse_optional_float(elem, "mu1", default=None),
1017-
"mu2": parse_optional_float(elem, "mu2", default=None),
1018-
"kp": parse_optional_float(elem, "kp", default=None),
1019-
"kd": parse_optional_float(elem, "kd", default=None),
1020-
"self_collide": parse_optional_bool(elem, "selfCollide"),
1021-
"gravity": parse_optional_bool(elem, "gravity"),
1022-
}
1023-
# Filter out None values
1024-
physics_data = {
1025-
k: v for k, v in physics_data.items() if v is not None
1026-
}
1027-
delayed_gazebo_elements.append(
1028-
(self._parse_gazebo_element(elem), physics_data)
1029-
)
1038+
1039+
# 2. Extract physics fields (regardless of sensor presence)
1040+
physics_data = {
1041+
"mu": parse_optional_float(elem, "mu1", default=None),
1042+
"mu2": parse_optional_float(elem, "mu2", default=None),
1043+
"kp": parse_optional_float(elem, "kp", default=None),
1044+
"kd": parse_optional_float(elem, "kd", default=None),
1045+
"self_collide": parse_optional_bool(elem, "selfCollide"),
1046+
"gravity": parse_optional_bool(elem, "gravity"),
1047+
}
1048+
# Filter out None values
1049+
physics_data = {k: v for k, v in physics_data.items() if v is not None}
1050+
1051+
# 3. Extract other Gazebo metadata (plugins, material, properties)
1052+
gazebo_elem = self._parse_gazebo_element(elem)
1053+
delayed_gazebo_elements.append((gazebo_elem, physics_data))
10301054
except (RobotModelError, ValueError, Exception) as e:
10311055
logger.warning(
10321056
f"Skipping invalid gazebo element '{elem.get('name') or elem.get('reference')}': {e}"

0 commit comments

Comments
 (0)