Skip to content

Commit 1a403b5

Browse files
authored
feat(ros): add inline package mappings (#369)
1 parent 697a211 commit 1a403b5

File tree

7 files changed

+292
-123
lines changed

7 files changed

+292
-123
lines changed

backends/pixi-build-ros/pixi.lock

Lines changed: 126 additions & 95 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backends/pixi-build-ros/pixi.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ test = "pytest tests"
2121
[feature.test.dependencies]
2222
pixi-pycharm = ">=0.0.8,<0.1"
2323
pytest = "*"
24-
py-pixi-build-backend = { path = "../../py-pixi-build-backend" }
24+
py-pixi-build-backend = "*"
25+
# Skipping as it's a long build, uncomment this when developing from source
26+
# py-pixi-build-backend = { path = "../../py-pixi-build-backend" }
2527

2628
[package.build.backend]
2729
name = "pixi-build-python"
@@ -42,5 +44,4 @@ pyyaml = "*"
4244
pixi-build-api-version = ">=2,<3"
4345
# should be added to `py-pixi-build-backend`
4446
typing-extensions = "*"
45-
#py-pixi-build-backend = "*"
46-
py-pixi-build-backend = { path = "../../py-pixi-build-backend" }
47+
py-pixi-build-backend = "*"

backends/pixi-build-ros/src/pixi_build_ros/ros_generator.py

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
convert_package_xml_to_catkin_package,
2929
get_package_xml_content,
3030
load_package_map_data,
31+
PackageMappingSource,
3132
)
3233

3334

@@ -43,7 +44,7 @@ def _parse_str_as_abs_path(value: str | Path, manifest_root: Path) -> Path:
4344
return value
4445

4546

46-
class ROSBackendConfig(pydantic.BaseModel, extra="forbid"):
47+
class ROSBackendConfig(pydantic.BaseModel, extra="forbid", arbitrary_types_allowed=True):
4748
"""ROS backend configuration."""
4849

4950
noarch: Optional[bool] = None
@@ -58,7 +59,9 @@ class ROSBackendConfig(pydantic.BaseModel, extra="forbid"):
5859
distro: Optional[str] = None
5960

6061
# Extra package mappings to use in the build
61-
extra_package_mappings: List[Path] = pydantic.Field(default_factory=list, alias="extra-package-mappings")
62+
extra_package_mappings: List[PackageMappingSource] = pydantic.Field(
63+
default_factory=list, alias="extra-package-mappings"
64+
)
6265

6366
def is_noarch(self) -> bool:
6467
"""Whether to build a noarch package or a platform-specific package."""
@@ -77,18 +80,40 @@ def _parse_debug_dir(cls, value, info: pydantic.ValidationInfo) -> Optional[Path
7780

7881
@pydantic.field_validator("extra_package_mappings", mode="before")
7982
@classmethod
80-
def _parse_package_mappings(cls, input_value, info: pydantic.ValidationInfo) -> Optional[List[Path]]:
83+
def _parse_package_mappings(
84+
cls, input_value, info: pydantic.ValidationInfo
85+
) -> Optional[List[PackageMappingSource]]:
8186
"""Parse additional package mappings if set."""
8287
if input_value is None:
8388
return []
89+
8490
base_path = Path(os.getcwd())
8591
if info.context and "manifest_root" in info.context:
8692
base_path = Path(info.context["manifest_root"])
8793

88-
res = []
89-
for path_value in input_value:
90-
res.append(_parse_str_as_abs_path(path_value, base_path))
91-
return res
94+
result: List[PackageMappingSource] = []
95+
for raw_entry in input_value:
96+
# match for cases
97+
# it's already a package mapping source (usually for testing)
98+
if isinstance(raw_entry, PackageMappingSource):
99+
entry = raw_entry
100+
elif isinstance(raw_entry, dict):
101+
if "file" in raw_entry:
102+
file_value = raw_entry["file"]
103+
entry = PackageMappingSource.from_file(_parse_str_as_abs_path(file_value, base_path))
104+
elif "mapping" in raw_entry:
105+
mapping_value = raw_entry["mapping"]
106+
entry = PackageMappingSource.from_mapping(mapping_value)
107+
else:
108+
entry = PackageMappingSource.from_mapping(raw_entry)
109+
elif isinstance(raw_entry, str | Path):
110+
entry = PackageMappingSource.from_file(_parse_str_as_abs_path(raw_entry, base_path))
111+
else:
112+
raise ValueError(
113+
f"Unrecognized entry for extra-package-mappings: {raw_entry} of type {type(raw_entry)}."
114+
)
115+
result.append(entry)
116+
return result
92117

93118

94119
class ROSGenerator(GenerateRecipeProtocol):
@@ -131,7 +156,9 @@ def generate_recipe(
131156
if not robostack_file.is_file():
132157
robostack_file = Path(__file__).parent.parent.parent / "robostack.yaml"
133158

134-
package_map_data = load_package_map_data([robostack_file] + backend_config.extra_package_mappings)
159+
package_map_data = load_package_map_data(
160+
backend_config.extra_package_mappings + [PackageMappingSource.from_file(robostack_file)]
161+
)
135162

136163
# Get requirements from package.xml
137164
package_requirements = package_xml_to_conda_requirements(package_xml, distro, host_platform, package_map_data)

backends/pixi-build-ros/src/pixi_build_ros/utils.py

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import os
22
from itertools import chain
33
from pathlib import Path
4-
from typing import Any, List, Dict
4+
from typing import Any, Dict, List, Optional, Sequence, Union
55

66
import yaml
77
from catkin_pkg.package import Package as CatkinPackage, parse_package_string
@@ -15,6 +15,38 @@
1515
PackageMapEntry = Dict[str, List[str] | Dict[str, List[str]]]
1616

1717

18+
class PackageMappingSource:
19+
"""Describes where additional package mapping data comes from."""
20+
21+
def __init__(self, mapping: Dict[str, PackageMapEntry]):
22+
if mapping is None:
23+
raise ValueError("PackageMappingSource mapping cannot be null.")
24+
if not isinstance(mapping, dict):
25+
raise TypeError("PackageMappingSource mapping must be a dictionary.")
26+
# Copy to keep the source immutable for callers.
27+
self.mapping: Dict[str, PackageMapEntry] = dict(mapping)
28+
29+
@classmethod
30+
def from_mapping(cls, mapping: Dict[str, PackageMapEntry]) -> "PackageMappingSource":
31+
"""Create a source directly from a mapping dictionary."""
32+
return cls(mapping)
33+
34+
@classmethod
35+
def from_file(cls, file_path: Union[str, Path]) -> "PackageMappingSource":
36+
"""Create a source from a mapping file."""
37+
path = Path(file_path)
38+
if not path.exists():
39+
raise ValueError(f"Additional package map file '{path}' not found.")
40+
with open(path, "r") as f:
41+
data = yaml.safe_load(f) or {}
42+
if not isinstance(data, dict):
43+
raise TypeError("Expected package map file to contain a dictionary.")
44+
return cls(data)
45+
46+
def get_package_mapping(self) -> Dict[str, PackageMapEntry]:
47+
return dict(self.mapping)
48+
49+
1850
def get_build_input_globs(config: Any, editable: bool) -> List[str]:
1951
"""Get build input globs for ROS package."""
2052
base_globs = [
@@ -70,15 +102,12 @@ def convert_package_xml_to_catkin_package(package_xml_content: str) -> CatkinPac
70102
return package_xml
71103

72104

73-
def load_package_map_data(package_map_path: List[Path]) -> Dict[str, PackageMapEntry]:
74-
"""Load the package map data from the given path."""
75-
result = {}
76-
for path in package_map_path:
77-
if not path.exists():
78-
raise ValueError(f"Additional package map path '{path}' not found.")
79-
# this blindly overwrites the data, but that's ok for now'
80-
with open(path, "r") as f:
81-
result.update(yaml.safe_load(f))
105+
def load_package_map_data(package_map_sources: List[PackageMappingSource]) -> Dict[str, PackageMapEntry]:
106+
"""Load and merge package map data from files and inline mappings."""
107+
108+
result: Dict[str, PackageMapEntry] = {}
109+
for source in reversed(package_map_sources):
110+
result.update(source.get_package_mapping())
82111
return result
83112

84113

backends/pixi-build-ros/tests/conftest.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33

44
import pytest
55

6-
from pixi_build_ros.utils import PackageMapEntry, load_package_map_data
6+
from pixi_build_ros.utils import (
7+
PackageMapEntry,
8+
load_package_map_data,
9+
PackageMappingSource,
10+
)
711

812

913
@pytest.fixture
@@ -22,4 +26,4 @@ def package_xmls(test_data_dir) -> Path:
2226
def package_map() -> Dict[str, PackageMapEntry]:
2327
"""Load the package map"""
2428
robostack_file = Path(__file__).parent.parent / "robostack.yaml"
25-
return load_package_map_data([robostack_file])
29+
return load_package_map_data([PackageMappingSource.from_file(robostack_file)])

backends/pixi-build-ros/tests/test_package_map.py

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,52 @@
44
from pixi_build_backend.types.platform import Platform
55
from pixi_build_backend.types.project_model import ProjectModelV1
66

7-
from pixi_build_ros.ros_generator import ROSGenerator
8-
from pixi_build_ros.utils import load_package_map_data
7+
from pixi_build_ros.ros_generator import ROSGenerator, ROSBackendConfig
8+
from pixi_build_ros.utils import load_package_map_data, PackageMappingSource
99

1010

1111
def test_package_loading(test_data_dir: Path):
1212
"""Load the package map with overwrites."""
1313
robostack_file = Path(__file__).parent.parent / "robostack.yaml"
1414
other_package_map = test_data_dir / "other_package_map.yaml"
15-
result = load_package_map_data([robostack_file, other_package_map])
15+
result = load_package_map_data(
16+
[
17+
PackageMappingSource.from_file(other_package_map),
18+
PackageMappingSource.from_file(robostack_file),
19+
]
20+
)
1621
assert "new_package" in result
1722
assert result["new_package"]["conda"] == ["new-package"], "Should be added"
1823
assert result["alsa-oss"]["conda"] == ["other-alsa-oss"], "Should be overwritten"
24+
assert "robostack" not in result["alsa-oss"], "Should be overwritten due to priority of package maps"
1925
assert "zlib" in result, "Should still be present"
2026

2127

28+
def test_package_loading_with_inline_mappings(test_data_dir: Path):
29+
"""Load package map data from a mix of files and inline entries."""
30+
robostack_file = Path(__file__).parent.parent / "robostack.yaml"
31+
inline_entries = {
32+
"inline-package": {"conda": ["inline-conda"]},
33+
"inline-ros": {"ros": ["inline-ros"]},
34+
}
35+
# Create config for ROS backend
36+
config = {
37+
"distro": "noetic",
38+
"noarch": False,
39+
"extra-package-mappings": [
40+
{"file": str(test_data_dir / "other_package_map.yaml")},
41+
{"mapping": inline_entries},
42+
robostack_file,
43+
],
44+
}
45+
parsed_config = ROSBackendConfig.model_validate(config, context={"manifest_root": test_data_dir})
46+
result = load_package_map_data(parsed_config.extra_package_mappings)
47+
48+
assert result["inline-package"]["conda"] == ["inline-conda"]
49+
assert result["inline-ros"]["ros"] == ["inline-ros"]
50+
assert "zlib" in result, "Should still contain base entries"
51+
52+
2253
def test_generate_recipe_with_custom_ros(package_xmls: Path, test_data_dir: Path):
2354
"""Test the generate_recipe function of ROSGenerator."""
2455
# Create a temporary directory to simulate the package directory
@@ -62,6 +93,47 @@ def test_generate_recipe_with_custom_ros(package_xmls: Path, test_data_dir: Path
6293
assert "ros-noetic-ros-package-msgs" in req_string
6394

6495

96+
def test_generate_recipe_with_inline_package_mappings(package_xmls: Path, test_data_dir: Path):
97+
"""Inline entries should be merged into the package map."""
98+
99+
with tempfile.TemporaryDirectory() as temp_dir:
100+
temp_path = Path(temp_dir)
101+
102+
package_xml_source = package_xmls / "custom_ros.xml"
103+
package_xml_dest = temp_path / "package.xml"
104+
package_xml_dest.write_text(package_xml_source.read_text(encoding="utf-8"))
105+
106+
model = ProjectModelV1()
107+
108+
config = {
109+
"distro": "noetic",
110+
"noarch": False,
111+
"extra-package-mappings": [
112+
{
113+
"mapping": {
114+
"ros_package": {"ros": ["ros-custom2", "ros-custom2-msgs"]},
115+
}
116+
},
117+
{"file": str(test_data_dir / "other_package_map.yaml")},
118+
],
119+
}
120+
121+
host_platform = Platform.current()
122+
123+
generator = ROSGenerator()
124+
125+
generated_recipe = generator.generate_recipe(
126+
model=model,
127+
config=config,
128+
manifest_path=str(temp_path),
129+
host_platform=host_platform,
130+
)
131+
132+
req_string = list(str(req) for req in generated_recipe.recipe.requirements.run)
133+
assert "ros-noetic-ros-custom2" in req_string
134+
assert "ros-noetic-ros-custom2-msgs" in req_string
135+
136+
65137
def test_package_map_does_not_exist(package_xmls: Path, test_data_dir: Path):
66138
"""Test the generate_recipe function of ROSGenerator."""
67139
# Create a temporary directory to simulate the package directory
@@ -96,4 +168,4 @@ def test_package_map_does_not_exist(package_xmls: Path, test_data_dir: Path):
96168
manifest_path=str(temp_path),
97169
host_platform=host_platform,
98170
)
99-
assert "Additional package map path" in str(excinfo.value)
171+
assert "Additional package map file" in str(excinfo.value)

pixi.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ build-release = "cargo build --release"
1515
build-ci = "cargo build --profile ci --locked"
1616
nextest = "cargo nextest run --workspace --all-targets"
1717
doctest = "cargo test --doc"
18-
test = [{ task = "nextest" }, { task = "doctest" }]
18+
pytest-build-ros = "pixi run --manifest-path backends/pixi-build-ros test"
19+
test = [
20+
{ task = "nextest" },
21+
{ task = "doctest" },
22+
{ task = "pytest-build-ros" },
23+
]
1924
generate-matrix = "python scripts/generate-matrix.py"
2025

2126
install-pixi-build-python = { cmd = "cargo install --path crates/pixi-build-python --locked --force" }

0 commit comments

Comments
 (0)