diff --git a/backends/pixi-build-ros/src/pixi_build_ros/utils.py b/backends/pixi-build-ros/src/pixi_build_ros/utils.py index 8ce43f38..e1b66aeb 100644 --- a/backends/pixi-build-ros/src/pixi_build_ros/utils.py +++ b/backends/pixi-build-ros/src/pixi_build_ros/utils.py @@ -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 @@ -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: @@ -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--` 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--` 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 @@ -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( @@ -156,21 +195,21 @@ 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)) @@ -178,9 +217,9 @@ def package_xml_to_conda_requirements( 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)) diff --git a/backends/pixi-build-ros/tests/test_package_xml.py b/backends/pixi-build-ros/tests/test_package_xml.py index b308df53..9ca1afa8 100644 --- a/backends/pixi-build-ros/tests/test_package_xml.py +++ b/backends/pixi-build-ros/tests/test_package_xml.py @@ -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 @@ -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}" @@ -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}" @@ -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}" @@ -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", @@ -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}" @@ -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 @@ -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, {})