Skip to content

Commit cd8c2d3

Browse files
daennyDaniel Claes
andauthored
feat(ros): add dependency specs and ros distro mutex (#367)
Co-authored-by: Daniel Claes <dclaes@smart-robotics.nl>
1 parent 6090b86 commit cd8c2d3

19 files changed

+1049
-169
lines changed

backends/pixi-build-ros/pixi.lock

Lines changed: 256 additions & 69 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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ preview = ["pixi-build"]
88

99
[dependencies]
1010
pydantic = ">=2.8.2,<3"
11+
py_rattler = ">=0.15.0,<0.16"
1112
pixi-build-ros = { path = "." }
1213

1314
[environments]
@@ -23,6 +24,7 @@ lint-mypy = "mypy"
2324
pixi-pycharm = ">=0.0.8,<0.1"
2425
pytest = "*"
2526
mypy = ">=1.18.2,<2"
27+
syrupy = ">=4.9.1,<5"
2628
types-pyyaml = "*"
2729
# You can skip this as it's a long build, uncomment this when developing only in this package
2830
#py-pixi-build-backend = "*"
@@ -55,6 +57,7 @@ python = ">=3.10"
5557
python = ">=3.10"
5658
rosdistro = "*"
5759
pydantic = ">=2.8.2,<3"
60+
py_rattler = ">=0.15.0,<0.16"
5861
catkin_pkg = "*"
5962
pyyaml = "*"
6063
pixi-build-api-version = ">=2,<3"

backends/pixi-build-ros/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ dependencies = [
66
"toml",
77
"pyyaml",
88
"pydantic",
9+
"py_rattler",
910
"py-pixi-build-backend",
1011
]
1112
name = "pixi-build-ros"

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from pathlib import Path
55
from typing import Any
66

7+
from pixi_build_ros.distro import Distro
8+
79

810
def _parse_str_as_abs_path(value: str | Path, manifest_root: Path) -> Path:
911
"""Parse a string as a Path."""
@@ -57,7 +59,7 @@ class ROSBackendConfig(pydantic.BaseModel, extra="forbid", arbitrary_types_allow
5759

5860
# ROS distribution to use, e.g., "foxy", "galactic", "humble"
5961
# TODO: This should be figured out in some other way, not from the config.
60-
distro: str
62+
distro: Distro
6163

6264
noarch: bool | None = None
6365
# Environment variables to set during the build
@@ -76,6 +78,14 @@ def is_noarch(self) -> bool:
7678
"""Whether to build a noarch package or a platform-specific package."""
7779
return self.noarch is None or self.noarch
7880

81+
@pydantic.field_validator("distro", mode="before")
82+
@classmethod
83+
def _parse_distro(cls, value: str | Distro) -> Distro:
84+
"""Parse a distro string."""
85+
if isinstance(value, str):
86+
return Distro(value)
87+
return value
88+
7989
@pydantic.field_validator("debug_dir", mode="before")
8090
@classmethod
8191
def _parse_debug_dir(cls, value: Any, info: pydantic.ValidationInfo) -> Path | None:

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ def name(self) -> str:
2222
def check_ros1(self) -> bool:
2323
return self._distribution_type == "ros1"
2424

25+
@property
26+
def ros_distro_mutex_name(self) -> str:
27+
return f"ros{'' if self.check_ros1() else '2'}-distro-mutex"
28+
2529
def get_python_version(self) -> str:
2630
return self._python_version
2731

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

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
"""
44

55
from pixi_build_backend.types import MetadataProvider
6-
from pixi_build_ros.distro import Distro
76

87

98
class MaintainerInfo:
@@ -30,6 +29,7 @@ def __init__(
3029
self.name = name
3130
self.version = version
3231
self.description = description
32+
# TODO: this is currently unused
3333
self.maintainers = maintainers or []
3434
self.licenses = licenses or []
3535
self.homepage = homepage
@@ -91,10 +91,10 @@ def _package_xml_data(self) -> PackageData:
9191
repository = None
9292
for url in root.findall("url"):
9393
url_type = url.get("type", "")
94-
if url_type == "website" and not homepage:
95-
homepage = url.text.strip() if url.text else None
96-
elif url_type == "repository" and not repository:
94+
if url_type == "repository" and not repository:
9795
repository = url.text.strip() if url.text else None
96+
if url_type == "website" or not homepage:
97+
homepage = url.text.strip() if url.text else None
9898

9999
self._package_data = PackageData(
100100
name=name_elem.text.strip() if name_elem is not None and name_elem.text else None,
@@ -128,13 +128,16 @@ def homepage(self) -> str | None:
128128

129129
def license(self) -> str | None:
130130
"""Return the license from package.xml."""
131-
# TODO: Handle License parsing to conform to SPDX standards,
132131
# ROS package.xml does not enforce SPDX as strictly as rattler-build
132+
# So use LicenseRef for now
133+
if len(self._package_xml_data.licenses) == 1:
134+
return f"LicenseRef-{self._package_xml_data.licenses[0]}"
135+
# TODO: Handle License parsing to conform to SPDX standards,
133136
return None
134137

135138
def license_file(self) -> str | None:
136-
"""Return None as package.xml doesn't typically specify license files."""
137-
return None
139+
"""Return package.xml as the license files."""
140+
return "package.xml"
138141

139142
def summary(self) -> str | None:
140143
"""Return the description as summary from package.xml."""
@@ -169,7 +172,7 @@ class ROSPackageXmlMetadataProvider(PackageXmlMetadataProvider):
169172
as 'ros-<distro>-<package_name>' according to ROS conda packaging conventions.
170173
"""
171174

172-
def __init__(self, package_xml_path: str, distro: Distro | None = None):
175+
def __init__(self, package_xml_path: str, distro_name: str | None = None):
173176
"""
174177
Initialize the ROS metadata provider.
175178
@@ -178,20 +181,16 @@ def __init__(self, package_xml_path: str, distro: Distro | None = None):
178181
distro: ROS distro. If None, will use the base package name without distro prefix.
179182
"""
180183
super().__init__(package_xml_path)
181-
self._distro: Distro | None = distro
182-
183-
def _get_distro(self) -> Distro | None:
184-
return self._distro
184+
self._distro_name: str | None = distro_name
185185

186186
def name(self) -> str | None:
187187
"""Return the ROS-formatted package name (ros-<distro>-<name>)."""
188188
base_name = super().name()
189189
if base_name is None:
190190
return None
191191

192-
distro = self._get_distro()
193-
if distro:
192+
if self._distro_name:
194193
# Convert underscores to hyphens per ROS conda naming conventions
195194
formatted_name = base_name.replace("_", "-")
196-
return f"ros-{distro.name}-{formatted_name}"
195+
return f"ros-{self._distro_name}-{formatted_name}"
197196
return base_name

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

Lines changed: 63 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,12 @@
1313
from .metadata_provider import ROSPackageXmlMetadataProvider
1414
from pixi_build_backend.types.intermediate_recipe import Script, ConditionalRequirements
1515

16-
from pixi_build_backend.types.item import ItemPackageDependency
16+
from pixi_build_backend.types.item import ItemPackageDependency, VecItemPackageDependency
1717
from pixi_build_backend.types.platform import Platform
1818
from pixi_build_backend.types.project_model import ProjectModelV1
1919
from pixi_build_backend.types.python_params import PythonParams
2020

2121
from .build_script import BuildScriptContext, BuildPlatform
22-
from .distro import Distro
2322
from .utils import (
2423
get_build_input_globs,
2524
package_xml_to_conda_requirements,
@@ -46,13 +45,9 @@ def generate_recipe(
4645
backend_config: ROSBackendConfig = ROSBackendConfig.model_validate(
4746
config, context={"manifest_root": manifest_root}
4847
)
49-
50-
# Setup ROS distro first
51-
distro = Distro(backend_config.distro)
52-
5348
# Create metadata provider for package.xml
5449
package_xml_path = manifest_root / "package.xml"
55-
metadata_provider = ROSPackageXmlMetadataProvider(str(package_xml_path), distro)
50+
metadata_provider = ROSPackageXmlMetadataProvider(str(package_xml_path), backend_config.distro.name)
5651

5752
# Create base recipe from model with metadata provider
5853
generated_recipe = GeneratedRecipe.from_model(model, metadata_provider)
@@ -75,7 +70,9 @@ def generate_recipe(
7570
)
7671

7772
# Get requirements from package.xml
78-
package_requirements = package_xml_to_conda_requirements(package_xml, distro, host_platform, package_map_data)
73+
package_requirements = package_xml_to_conda_requirements(
74+
package_xml, backend_config.distro, host_platform, package_map_data
75+
)
7976

8077
# Add standard dependencies
8178
build_deps = [
@@ -106,6 +103,10 @@ def generate_recipe(
106103
for dep in host_deps:
107104
package_requirements.host.append(ItemPackageDependency(name=dep))
108105

106+
# add a simple default host and run dependency on the ros{2}-distro-mutex
107+
package_requirements.host.append(ItemPackageDependency(name=backend_config.distro.ros_distro_mutex_name))
108+
package_requirements.run.append(ItemPackageDependency(name=backend_config.distro.ros_distro_mutex_name))
109+
109110
# Merge package requirements into the model requirements
110111
requirements = merge_requirements(generated_recipe.recipe.requirements, package_requirements)
111112
generated_recipe.recipe.requirements = requirements
@@ -114,7 +115,9 @@ def generate_recipe(
114115
build_platform = BuildPlatform.current()
115116

116117
# Generate build script
117-
build_script_context = BuildScriptContext.load_from_template(package_xml, build_platform, manifest_root, distro)
118+
build_script_context = BuildScriptContext.load_from_template(
119+
package_xml, build_platform, manifest_root, backend_config.distro
120+
)
118121
build_script_lines = build_script_context.render()
119122

120123
generated_recipe.recipe.build.script = Script(
@@ -156,27 +159,60 @@ def merge_requirements(
156159
"""Merge two sets of requirements."""
157160
merged = ConditionalRequirements()
158161

159-
# The model requirements are the base, coming from the pixi manifest
160-
# We need to only add the names for non-existing dependencies
161-
def merge_unique_items(
162-
model: list[ItemPackageDependency],
163-
package: list[ItemPackageDependency],
164-
) -> list[ItemPackageDependency]:
165-
"""Merge unique items from source into target."""
166-
result = model
167-
168-
for item in package:
169-
package_names = [i.concrete.package_name for i in model if i.concrete]
170-
171-
if item.concrete is not None and item.concrete.package_name not in package_names:
172-
result.append(item)
173-
if str(item.template) not in [str(i.template) for i in model]:
174-
result.append(item)
175-
return result
176-
177162
merged.host = merge_unique_items(model_requirements.host, package_requirements.host)
178163
merged.build = merge_unique_items(model_requirements.build, package_requirements.build)
179164
merged.run = merge_unique_items(model_requirements.run, package_requirements.run)
180165

181166
# If the dependency is of type Source in one of the requirements, we need to set them to Source for all variants
182167
return merged
168+
169+
170+
def merge_unique_items(
171+
model: list[ItemPackageDependency] | VecItemPackageDependency,
172+
package: list[ItemPackageDependency] | VecItemPackageDependency,
173+
) -> list[ItemPackageDependency]:
174+
"""Merge unique items from source into target."""
175+
176+
def _find_matching(list_to_find: list[ItemPackageDependency], name: str) -> ItemPackageDependency | None:
177+
for dep in list_to_find:
178+
if dep.concrete.package_name == name:
179+
return dep
180+
else:
181+
return None
182+
183+
def _merge_specs(spec1: str, spec2: str, package_name: str) -> str:
184+
# remove the package name
185+
version_spec1 = spec1.removeprefix(package_name).strip()
186+
version_spec2 = spec2.removeprefix(package_name).strip()
187+
188+
if " " in version_spec1 or " " in version_spec2:
189+
raise ValueError(f"{version_spec1}, or {version_spec2} contains spaces, cannot merge specifiers.")
190+
191+
# early out with *, empty or ==
192+
if version_spec1 in ["*", ""] or "==" in version_spec2 or version_spec1 == version_spec2:
193+
return spec2
194+
if version_spec2 in ["*", ""] or "==" in version_spec1:
195+
return spec1
196+
return package_name + " " + ",".join([version_spec1, version_spec2])
197+
198+
result: list[ItemPackageDependency] = []
199+
templates_in_model = [str(i.template) for i in model]
200+
for item in list(model) + list(package):
201+
# It's concrete (i.e. no template)
202+
if item.concrete is not None:
203+
# It does not exist yet in model
204+
item_in_result = _find_matching(result, item.concrete.package_name)
205+
if not item_in_result:
206+
result.append(item)
207+
else:
208+
new_dep = ItemPackageDependency(
209+
name=_merge_specs(
210+
item_in_result.concrete.binary_spec, item.concrete.binary_spec, item.concrete.package_name
211+
)
212+
)
213+
result.remove(item_in_result)
214+
result.append(new_dep)
215+
216+
elif str(item.template) not in templates_in_model:
217+
result.append(item)
218+
return result

0 commit comments

Comments
 (0)