Skip to content
This repository was archived by the owner on Jan 20, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
579 changes: 354 additions & 225 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ pixi_build_type_conversions = { git = "https://github.com/prefix-dev/pixi", bran
#rattler_repodata_gateway = { path = "../rattler/crates/rattler_repodata_gateway" }
#simple_spawn_blocking = { path = "../rattler/crates/simple_spawn_blocking" }

#[patch."https://github.com/prefix-dev/rattler-build"]
#rattler-build = { path = "../rattler-build/" }

[profile.dev.package]
insta.opt-level = 3
similar.opt-level = 3
Expand Down
229 changes: 95 additions & 134 deletions backends/pixi-build-ros/pixi.lock

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions backends/pixi-build-ros/pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ hatchling = "*"
rosdistro = "*"
catkin_pkg = "*"
pyyaml = "*"
pixi-build-api-version = "1.*"
pixi-build-api-version = ">=2,<3"
# should be added to `py-pixi-build-backend`
typing-extensions = "*"
py-pixi-build-backend = "*"
#py-pixi-build-backend = "*"
py-pixi-build-backend = { path = "../../py-pixi-build-backend" }
9 changes: 8 additions & 1 deletion backends/pixi-build-ros/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
[project]
dependencies = ["rosdistro", "catkin_pkg", "pytest", "toml", "pyyaml"]
dependencies = [
"rosdistro",
"catkin_pkg",
"pytest",
"toml",
"pyyaml",
"py-pixi-build-backend",
]
name = "pixi-build-ros"
version = "0.1.1"

Expand Down
4 changes: 0 additions & 4 deletions backends/pixi-build-ros/src/pixi_build_ros/distro.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import os

from rosdistro import get_cached_distribution, get_index, get_index_url


Expand All @@ -13,8 +11,6 @@ def __init__(self, distro_name):
self._distribution_type = index.distributions[distro_name]["distribution_type"]
self._python_version = index.distributions[distro_name]["python_version"]

os.environ["ROS_VERSION"] = "1" if self.check_ros1() else "2"

@property
def name(self) -> str:
return self.distro_name
Expand Down
202 changes: 202 additions & 0 deletions backends/pixi-build-ros/src/pixi_build_ros/metadata_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
"""
ROS-specific metadata provider that extracts metadata from package.xml files.
"""

from typing import Optional, List

from pixi_build_backend.types import MetadataProvider
from pixi_build_ros.distro import Distro


class MaintainerInfo:
"""Container for maintainer information from package.xml."""

def __init__(self, name: str, email: str):
self.name = name
self.email = email


class PackageData:
"""Container for parsed package.xml data."""

def __init__(
self,
name: Optional[str] = None,
version: Optional[str] = None,
description: Optional[str] = None,
maintainers: Optional[List[MaintainerInfo]] = None,
licenses: Optional[List[str]] = None,
homepage: Optional[str] = None,
repository: Optional[str] = None,
):
self.name = name
self.version = version
self.description = description
self.maintainers = maintainers or []
self.licenses = licenses or []
self.homepage = homepage
self.repository = repository

class PackageXmlMetadataProvider(MetadataProvider):
"""
Metadata provider that extracts metadata from ROS package.xml files.

This provider reads ROS package.xml files and extracts package metadata
like name, version, description, maintainers, etc.
"""

def __init__(self, package_xml_path: str, *args, **kwargs):
"""
Initialize the metadata provider with a package.xml file path.

Args:
package_xml_path: Path to the package.xml file
"""
super().__init__(*args, **kwargs)
self.package_xml_path = package_xml_path
self._package_data: Optional[PackageData] = None

@property
def _package_xml_data(self) -> PackageData:
"""Load and parse the package.xml file."""
if self._package_data is not None:
return self._package_data

try:
import xml.etree.ElementTree as ET

tree = ET.parse(self.package_xml_path)
root = tree.getroot()

# Extract basic package information
name_elem = root.find('name')
version_elem = root.find('version')
description_elem = root.find('description')

# Extract maintainer and author information
maintainers: List[MaintainerInfo] = []
for maintainer in root.findall('maintainer'):
maintainer_info = MaintainerInfo(
name=maintainer.text.strip() if maintainer.text else '',
email=maintainer.get('email', '')
)
maintainers.append(maintainer_info)

# Extract license information
licenses = []
for license_elem in root.findall('license'):
if license_elem.text:
licenses.append(license_elem.text.strip())

# Extract URL information
homepage = None
repository = None
for url in root.findall('url'):
url_type = url.get('type', '')
if url_type == 'website' and not homepage:
homepage = url.text.strip() if url.text else None
elif url_type == 'repository' and not repository:
repository = url.text.strip() if url.text else None

self._package_data = PackageData(
name=name_elem.text.strip() if name_elem is not None and name_elem.text else None,
version=version_elem.text.strip() if version_elem is not None and version_elem.text else None,
description=description_elem.text.strip() if description_elem is not None and description_elem.text else None,
maintainers=maintainers,
licenses=licenses,
homepage=homepage,
repository=repository,
)

except Exception as e:
print(f"Warning: Failed to parse package.xml at {self.package_xml_path}: {e}")
self._package_data = PackageData()

return self._package_data

def name(self) -> Optional[str]:
"""Return the package name from package.xml."""
return self._package_xml_data.name

def version(self) -> Optional[str]:
"""Return the package version from package.xml."""
return self._package_xml_data.version

def homepage(self) -> Optional[str]:
"""Return the homepage URL from package.xml."""
return self._package_xml_data.homepage

def license(self) -> Optional[str]:
"""Return the license from package.xml."""
# TODO: Handle License parsing to conform to SPDX standards,
# ROS package.xml does not enforce SPDX as strictly as rattler-build
return None

def license_file(self) -> Optional[str]:
"""Return None as package.xml doesn't typically specify license files."""
return None

def summary(self) -> Optional[str]:
"""Return the description as summary from package.xml."""
description = self._package_xml_data.description
if description and len(description) > 100:
# Truncate long descriptions for summary
return description[:97] + "..."
return description

def description(self) -> Optional[str]:
"""Return the full description from package.xml."""
return self._package_xml_data.description

def documentation(self) -> Optional[str]:
"""Return None as package.xml doesn't typically specify documentation URLs separately."""
return None

def repository(self) -> Optional[str]:
"""Return the repository URL from package.xml."""
return self._package_xml_data.repository

def input_globs(self) -> List[str]:
"""Return input globs that affect this metadata provider."""
return [
"package.xml",
"CMakeLists.txt",
"setup.py",
"setup.cfg"
]


class ROSPackageXmlMetadataProvider(PackageXmlMetadataProvider):
"""
ROS-specific metadata provider that formats names according to ROS conventions.

This provider extends PackageXmlMetadataProvider to format package names
as 'ros-<distro>-<package_name>' according to ROS conda packaging conventions.
"""

def __init__(self, package_xml_path: str, distro: Optional[Distro] = None):
"""
Initialize the ROS metadata provider.

Args:
package_xml_path: Path to the package.xml file
distro: ROS distro. If None, will use the base package name without distro prefix.
"""
super().__init__(package_xml_path)
self._distro: Optional[Distro] = distro

def _get_distro(self) -> Optional[Distro]:
return self._distro

def name(self) -> Optional[str]:
"""Return the ROS-formatted package name (ros-<distro>-<name>)."""
base_name = super().name()
if base_name is None:
return None

distro = self._get_distro()
if distro:
# Convert underscores to hyphens per ROS conda naming conventions
formatted_name = base_name.replace('_', '-')
return f"ros-{distro.name}-{formatted_name}"
return base_name
29 changes: 13 additions & 16 deletions backends/pixi-build-ros/src/pixi_build_ros/ros_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
GenerateRecipeProtocol,
GeneratedRecipe,
)
from .metadata_provider import ROSPackageXmlMetadataProvider
from pixi_build_backend.types.intermediate_recipe import Script, ConditionalRequirements

from pixi_build_backend.types.item import ItemPackageDependency
Expand Down Expand Up @@ -69,24 +70,19 @@ def generate_recipe(

manifest_root = Path(manifest_path)

# Create base recipe from model
generated_recipe = GeneratedRecipe.from_model(model)

# Read package.xml
package_xml_str = get_package_xml_content(manifest_root)
package_xml = convert_package_xml_to_catkin_package(package_xml_str)

# Setup ROS distro
# Setup ROS distro first
distro = Distro(backend_config.distro)

# Create metadata provider for package.xml
package_xml_path = manifest_root / "package.xml"
metadata_provider = ROSPackageXmlMetadataProvider(str(package_xml_path), distro)

package = generated_recipe.recipe.package
# Create base recipe from model with metadata provider
generated_recipe = GeneratedRecipe.from_model(model, metadata_provider)

# Modify the name and version of the package based on the ROS distro and package.xml
if package.name.get_concrete() == "undefined":
package.name = f"ros-{distro.name}-{package_xml.name.replace('_', '-')}"

if package.version == "0.0.0":
package.version = package_xml.version
# Read package.xml for dependency extraction
package_xml_str = get_package_xml_content(manifest_root)
package_xml = convert_package_xml_to_catkin_package(package_xml_str)

# Get requirements from package.xml
package_requirements = package_xml_to_conda_requirements(package_xml, distro)
Expand Down Expand Up @@ -132,7 +128,8 @@ def generate_recipe(
debug_dir = backend_config.get_debug_dir()
if debug_dir:
recipe = generated_recipe.recipe.to_yaml()
debug_file_path = debug_dir / f"{package.name}-{package.version}-recipe.yaml"
package = generated_recipe.recipe.package
debug_file_path = debug_dir / f"{package.name.get_concrete()}-{package.version}-recipe.yaml"
debug_file_path.parent.mkdir(parents=True, exist_ok=True)
with open(debug_file_path, 'w') as debug_file:
debug_file.write(recipe)
Expand Down
1 change: 0 additions & 1 deletion backends/pixi-build-ros/src/pixi_build_ros/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import os
import sys
from itertools import chain
from pathlib import Path
from typing import Any, List
Expand Down
64 changes: 64 additions & 0 deletions backends/pixi-build-ros/tests/test_package_xml.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from pathlib import Path
import tempfile

from pixi_build_ros.distro import Distro
from pixi_build_ros.ros_generator import ROSGenerator
from pixi_build_ros.utils import convert_package_xml_to_catkin_package, package_xml_to_conda_requirements
from pixi_build_backend.types.platform import Platform
from pixi_build_backend.types.project_model import ProjectModelV1

def test_package_xml_to_recipe_config(package_xmls: Path):
# Read content from the file in the test data directory
Expand Down Expand Up @@ -45,3 +49,63 @@ def test_ament_cmake_package_xml_to_recipe_config(package_xmls: Path):

assert requirements.build[0].concrete.package_name == "ros-noetic-ament-cmake"

def test_generate_recipe(package_xmls: Path):
"""Test the generate_recipe function of ROSGenerator."""
# Create a temporary directory to simulate the package directory
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)

# Copy the test package.xml to the temp directory
package_xml_source = package_xmls / "demo_nodes_cpp.xml"
package_xml_dest = temp_path / "package.xml"
package_xml_dest.write_text(package_xml_source.read_text(encoding='utf-8'))

# Create a minimal ProjectModelV1 instance
model = ProjectModelV1()

# Create config for ROS backend
config = {
"distro": "jazzy",
"noarch": False
}

# Create host platform
host_platform = Platform.current()

# Create ROSGenerator instance
generator = ROSGenerator()

# Generate the recipe
generated_recipe = generator.generate_recipe(
model=model,
config=config,
manifest_path=str(temp_path),
host_platform=host_platform
)

# Verify the generated recipe has expected properties
assert generated_recipe.recipe.package.name.get_concrete() == "ros-jazzy-demo-nodes-cpp"
assert generated_recipe.recipe.package.version.get_concrete() == "0.37.1"

# Verify build script is generated
assert generated_recipe.recipe.build.script is not None
assert generated_recipe.recipe.build.script.content is not None

# Verify ROS dependencies are included in build requirements
build_deps = [dep.concrete.package_name for dep in generated_recipe.recipe.requirements.build if dep.concrete]
expected_ros_deps = ["ros-jazzy-ament-cmake", "ros-jazzy-example-interfaces", "ros-jazzy-rclcpp"]

for expected_dep in expected_ros_deps:
assert expected_dep in build_deps, f"Expected dependency {expected_dep} not found in build deps: {build_deps}"

# Verify standard build tools are included
expected_build_tools = ["ninja", "python", "setuptools", "cmake"]
for tool in expected_build_tools:
assert tool in build_deps, f"Expected build tool {tool} not found in build deps: {build_deps}"

# Verify run dependencies
run_deps = [dep.concrete.package_name for dep in generated_recipe.recipe.requirements.run if dep.concrete]
expected_run_deps = ["ros-jazzy-example-interfaces", "ros-jazzy-rclcpp", "ros-jazzy-launch-ros"]

for expected_dep in expected_run_deps:
assert expected_dep in run_deps, f"Expected runtime dependency {expected_dep} not found in run deps: {run_deps}"
Loading
Loading