Skip to content
Closed
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
87 changes: 63 additions & 24 deletions backends/pixi-build-ros/src/pixi_build_ros/utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import os
from itertools import chain
from pathlib import Path

from catkin_pkg.package import Package as CatkinPackage, parse_package_string
from typing import Any
from catkin_pkg.package import Package as CatkinPackage, parse_package_string, Dependency

from pixi_build_backend.types.intermediate_recipe import ConditionalRequirements
from pixi_build_backend.types.item import ItemPackageDependency
Expand Down Expand Up @@ -77,13 +77,50 @@ def load_package_map_data(package_map_sources: list[PackageMappingSource]) -> di
return result


def rosdep_to_conda_package_name(
dep_name: str,
def rosdep_nameless_matchspec(dep: Dependency) -> str:
right_ineq = [dep.version_lt, dep.version_lte]
left_ineq = [dep.version_gt, dep.version_gte]
eq = dep.version_eq

def not_none(p: Any) -> bool:
return p is not None

if all(map(not_none, right_ineq)):
raise ValueError(f"Dependency {dep.name} cannot be specified by both `<` and `<=`")
if all(map(not_none, left_ineq)):
raise ValueError(f"Dependency {dep.name} cannot be specified by both `>` and `>=`")

some_inequality = any(map(lambda p: p is not None, right_ineq + left_ineq))
if eq and some_inequality:
raise ValueError(f"Dependency {dep.name} cannot be specified by both `=` and some inequality")

if eq:
return f"=={eq}"

pair = []

if dep.version_gt:
pair.append(f">{dep.version_gt}")
if dep.version_gte:
pair.append(f">={dep.version_gte}")

if dep.version_lt:
pair.append(f"<{dep.version_lt}")
if dep.version_lte:
pair.append(f"<={dep.version_lte}")

res = ",".join(pair)

return " " + res if len(pair) > 0 else res


def rosdep_to_conda_package_spec(
dep: Dependency,
distro: Distro,
host_platform: Platform,
package_map_data: dict[str, PackageMapEntry],
) -> list[str]:
"""Convert a ROS dependency name to a conda package name."""
"""Convert a ROS dependency name to a conda package name with spec."""
if host_platform.is_linux:
target_platform = "linux"
elif host_platform.is_windows:
Expand All @@ -93,37 +130,39 @@ def rosdep_to_conda_package_name(
else:
raise RuntimeError(f"Unsupported platform: {host_platform}")

matchspec = rosdep_nameless_matchspec(dep)

# If dependency any of the following return custom name:
if dep_name in [
if dep.name in [
"ament_cmake",
"ament_python",
"rosidl_default_generators",
"ros_workspace",
]:
return [f"ros-{distro.name}-{dep_name.replace('_', '-')}"]
return [f"ros-{distro.name}-{dep.name.replace('_', '-')}{matchspec}"]

if dep_name not in package_map_data:
if dep.name not in package_map_data:
# If the dependency is not found in robostack.yaml, check the actual distro whether it exists
if distro.has_package(dep_name):
# This means that it is a ROS package, so we are going to assume has the `ros-<distro>-<dep_name>` format.
return [f"ros-{distro.name}-{dep_name.replace('_', '-')}"]
if distro.has_package(dep.name):
# This means that it is a ROS package, so we are going to assume has the `ros-<distro>-<dep.name>` format.
return [f"ros-{distro.name}-{dep.name.replace('_', '-')}{matchspec}"]
else:
# If the dependency is not found in robostack.yaml and not in the distro, return the dependency name as is.
return [dep_name]
return [dep.name + matchspec]

# Dependency found in package map

# Case 1: It's a custom ROS dependency
if "ros" in package_map_data[dep_name]:
return [f"ros-{distro.name}-{dep.replace('_', '-')}" for dep in package_map_data[dep_name]["ros"]]
if "ros" in package_map_data[dep.name]:
return [f"ros-{distro.name}-{dep.replace('_', '-')}{matchspec}" for dep in package_map_data[dep.name]["ros"]]

# Case 2: It's a custom package name
elif "conda" in package_map_data[dep_name] or "robostack" in package_map_data[dep_name]:
elif "conda" in package_map_data[dep.name] or "robostack" in package_map_data[dep.name]:
# determine key
key = "robostack" if "robostack" in package_map_data[dep_name] else "conda"
key = "robostack" if "robostack" in package_map_data[dep.name] else "conda"

# Get the conda packages for the dependency
conda_packages = package_map_data[dep_name].get(key, [])
conda_packages = package_map_data[dep.name].get(key, [])

if isinstance(conda_packages, dict):
# TODO: Handle different platforms
Expand All @@ -146,7 +185,7 @@ def rosdep_to_conda_package_name(

return conda_packages
else:
raise ValueError(f"Unknown package map entry: {dep_name}.")
raise ValueError(f"Unknown package map entry: {dep.name}.")


def package_xml_to_conda_requirements(
Expand All @@ -156,31 +195,31 @@ def package_xml_to_conda_requirements(
package_map_data: dict[str, PackageMapEntry],
) -> ConditionalRequirements:
"""Convert a CatkinPackage to ConditionalRequirements for conda."""

# All build related dependencies go into the build requirements
build_deps = pkg.buildtool_depends
# TODO: should the export dependencies be included here?
build_deps += pkg.buildtool_export_depends
build_deps += pkg.build_depends
build_deps += pkg.build_export_depends

# Also add test dependencies, because they might be needed during build (i.e. for pytest/catch2 etc in CMake macros)
build_deps += pkg.test_depends
build_deps = [d.name for d in build_deps if d.evaluated_condition]
build_deps = [d for d in build_deps if d.evaluated_condition]
# Add the ros_workspace dependency as a default build dependency for ros2 packages
if not distro.check_ros1():
build_deps += ["ros_workspace"]
build_deps += [Dependency(name="ros_workspace")]
conda_build_deps_chain = [
rosdep_to_conda_package_name(dep, distro, host_platform, package_map_data) for dep in build_deps
rosdep_to_conda_package_spec(dep, distro, host_platform, package_map_data) for dep in build_deps
]
conda_build_deps = list(chain.from_iterable(conda_build_deps_chain))

run_deps = pkg.run_depends
run_deps += pkg.exec_depends
run_deps += pkg.build_export_depends
run_deps += pkg.buildtool_export_depends
run_deps = [d.name for d in run_deps if d.evaluated_condition]
run_deps = [d for d in run_deps if d.evaluated_condition]
conda_run_deps_chain = [
rosdep_to_conda_package_name(dep, distro, host_platform, package_map_data) for dep in run_deps
rosdep_to_conda_package_spec(dep, distro, host_platform, package_map_data) for dep in run_deps
]
conda_run_deps = list(chain.from_iterable(conda_run_deps_chain))

Expand Down
89 changes: 76 additions & 13 deletions backends/pixi-build-ros/tests/test_package_xml.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import json
import tempfile
import pytest
from pathlib import Path
from catkin_pkg.package import Dependency

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,
rosdep_to_conda_package_name,
rosdep_to_conda_package_spec,
PackageMapEntry,
)
from pixi_build_backend.types.platform import Platform
Expand Down Expand Up @@ -196,7 +198,7 @@ def test_robostack_target_platform_linux(package_map: dict[str, PackageMapEntry]
linux_platform = Platform("linux-64")

# Test packages with platform-specific mappings
acl_packages = rosdep_to_conda_package_name("acl", distro, linux_platform, package_map)
acl_packages = rosdep_to_conda_package_spec(Dependency(name="acl"), distro, linux_platform, package_map)
assert acl_packages == ["libacl"], f"Expected ['libacl'] for acl on Linux, got {acl_packages}"


Expand All @@ -208,7 +210,7 @@ def test_robostack_target_platform_osx(package_map: dict[str, PackageMapEntry]):
osx_platform = Platform("osx-64")

# Test packages with platform-specific mappings
acl_packages = rosdep_to_conda_package_name("acl", distro, osx_platform, package_map)
acl_packages = rosdep_to_conda_package_spec(Dependency(name="acl"), distro, osx_platform, package_map)
assert acl_packages == [], f"Expected [] for acl on macOS, got {acl_packages}"


Expand All @@ -220,7 +222,7 @@ def test_robostack_target_platform_windows(package_map: dict[str, PackageMapEntr
win_platform = Platform("win-64")

# Test packages with platform-specific mappings
binutils_packages = rosdep_to_conda_package_name("binutils", distro, win_platform, package_map)
binutils_packages = rosdep_to_conda_package_spec(Dependency(name="binutils"), distro, win_platform, package_map)
assert binutils_packages == [], f"Expected [] for binutils on Windows, got {binutils_packages}"


Expand All @@ -235,9 +237,9 @@ def test_robostack_target_platform_cross_platform(
win_platform = Platform("win-64")

# libudev-dev has different packages for each platform
linux_udev = rosdep_to_conda_package_name("libudev-dev", distro, linux_platform, package_map)
osx_udev = rosdep_to_conda_package_name("libudev-dev", distro, osx_platform, package_map)
win_udev = rosdep_to_conda_package_name("libudev-dev", distro, win_platform, package_map)
linux_udev = rosdep_to_conda_package_spec(Dependency(name="libudev-dev"), distro, linux_platform, package_map)
osx_udev = rosdep_to_conda_package_spec(Dependency(name="libudev-dev"), distro, osx_platform, package_map)
win_udev = rosdep_to_conda_package_spec(Dependency(name="libudev-dev"), distro, win_platform, package_map)

assert linux_udev == [
"libusb",
Expand All @@ -247,9 +249,9 @@ def test_robostack_target_platform_cross_platform(
assert win_udev == ["libusb"], f"Expected ['libusb'] for libudev-dev on Windows, got {win_udev}"

# libomp-dev has different OpenMP implementations per platform
linux_omp = rosdep_to_conda_package_name("libomp-dev", distro, linux_platform, package_map)
osx_omp = rosdep_to_conda_package_name("libomp-dev", distro, osx_platform, package_map)
win_omp = rosdep_to_conda_package_name("libomp-dev", distro, win_platform, package_map)
linux_omp = rosdep_to_conda_package_spec(Dependency(name="libomp-dev"), distro, linux_platform, package_map)
osx_omp = rosdep_to_conda_package_spec(Dependency(name="libomp-dev"), distro, osx_platform, package_map)
win_omp = rosdep_to_conda_package_spec(Dependency(name="libomp-dev"), distro, win_platform, package_map)

assert linux_omp == ["libgomp"], f"Expected ['libgomp'] for libomp-dev on Linux, got {linux_omp}"
assert osx_omp == ["llvm-openmp"], f"Expected ['llvm-openmp'] for libomp-dev on macOS, got {osx_omp}"
Expand All @@ -265,9 +267,9 @@ def test_robostack_require_opengl_handling(package_map: dict[str, PackageMapEntr
win_platform = Platform("win-64")

# opengl package has REQUIRE_OPENGL handling
linux_opengl = rosdep_to_conda_package_name("opengl", distro, linux_platform, package_map)
osx_opengl = rosdep_to_conda_package_name("opengl", distro, osx_platform, package_map)
win_opengl = rosdep_to_conda_package_name("opengl", distro, win_platform, package_map)
linux_opengl = rosdep_to_conda_package_spec(Dependency(name="opengl"), distro, linux_platform, package_map)
osx_opengl = rosdep_to_conda_package_spec(Dependency(name="opengl"), distro, osx_platform, package_map)
win_opengl = rosdep_to_conda_package_spec(Dependency(name="opengl"), distro, win_platform, package_map)

# According to the code, REQUIRE_OPENGL should be replaced with actual packages on Linux
# and should add xorg packages for linux/osx/unix platforms
Expand All @@ -283,3 +285,64 @@ def test_robostack_require_opengl_handling(package_map: dict[str, PackageMapEntr

# Windows should have empty packages
assert win_opengl == [], f"Expected [] for opengl on Windows, got {win_opengl}"


def test_rosdep_to_conda_package_spec_adds_matchspec_for_special_rosdeps():
distro = Distro("jazzy")
host_platform = Platform("linux-64")
dep = Dependency(name="ament_cmake", version_eq="1.2.3")

packages = rosdep_to_conda_package_spec(dep, distro, host_platform, {})

assert packages == ["ros-jazzy-ament-cmake==1.2.3"]


def test_rosdep_to_conda_package_spec_adds_matchspec_for_distro_packages():
distro = Distro("jazzy")
host_platform = Platform("linux-64")
dep = Dependency(name="rclcpp", version_gte="18.0.0", version_lt="20.0.0")

packages = rosdep_to_conda_package_spec(dep, distro, host_platform, {})

assert packages == ["ros-jazzy-rclcpp >=18.0.0,<20.0.0"]


def test_rosdep_to_conda_package_spec_adds_matchspec_for_ros_package_map_entries():
distro = Distro("jazzy")
host_platform = Platform("linux-64")
package_map: dict[str, PackageMapEntry] = {"custom_ros_dep": {"ros": ["foo_util"]}}
dep = Dependency(name="custom_ros_dep", version_gte="3.1")

packages = rosdep_to_conda_package_spec(dep, distro, host_platform, package_map)

assert packages == ["ros-jazzy-foo-util >=3.1"]


def test_rosdep_to_conda_package_spec_dont_add_matchspec_for_conda_package_map_entries():
distro = Distro("jazzy")
host_platform = Platform("linux-64")
package_map: dict[str, PackageMapEntry] = {"libudev-dev": {"robostack": {"linux": ["libusb", "libudev"]}}}
dep = Dependency(name="libudev-dev", version_gte="200")

packages = rosdep_to_conda_package_spec(dep, distro, host_platform, package_map)

assert packages == ["libusb", "libudev"]


def test_rosdep_to_conda_package_spec_adds_matchspec_for_unknown_dependencies():
distro = Distro("jazzy")
host_platform = Platform("linux-64")
dep = Dependency(name="customlib", version_gt="1.0.0")

packages = rosdep_to_conda_package_spec(dep, distro, host_platform, {})

assert packages == ["customlib >1.0.0"]


def test_rosdep_to_conda_package_spec_exception():
distro = Distro("jazzy")
host_platform = Platform("linux-64")
dep = Dependency(name="rclcpp", version_gt="18.0.0", version_gte="20.0.0")

with pytest.raises(ValueError, match=".* `>` and `>=`"):
rosdep_to_conda_package_spec(dep, distro, host_platform, {})
Loading