diff --git a/.github/workflows/build-pypi-wheels.yml b/.github/workflows/build-pypi-wheels.yml new file mode 100644 index 00000000..5620b787 --- /dev/null +++ b/.github/workflows/build-pypi-wheels.yml @@ -0,0 +1,142 @@ +name: Build PyPI wheels + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +jobs: + check_version: + name: Check package version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.package.outputs.version }} + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + submodules: true + fetch-depth: 0 + persist-credentials: false + - name: Read package metadata + id: package + run: | + python -m pip install --upgrade "setuptools-scm>=8" + python - <<'PY' >> "$GITHUB_OUTPUT" + import tomllib + from pathlib import Path + + from setuptools_scm import get_version + + pyproject = tomllib.loads(Path("pyproject.toml").read_text()) + project = pyproject["project"] + if project["name"] != "roboplan": + raise SystemExit(f"Unexpected package name: {project['name']}") + if "version" in project: + raise SystemExit("pyproject.toml must use git-derived dynamic versioning") + if project.get("dynamic") != ["version"]: + raise SystemExit("pyproject.toml must declare dynamic = ['version']") + print(f"version={get_version(root='.', local_scheme='no-local-version')}") + PY + - name: Check tag matches package version + if: startsWith(github.ref, 'refs/tags/') + env: + PACKAGE_VERSION: ${{ steps.package.outputs.version }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + if ! [[ "$RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Tag $RELEASE_TAG must use N.N.N format" + exit 1 + fi + if [ "$RELEASE_TAG" != "$PACKAGE_VERSION" ]; then + echo "::error::Tag $RELEASE_TAG does not match package version $PACKAGE_VERSION" + exit 1 + fi + + build_sdist: + name: Build source distribution + runs-on: ubuntu-latest + needs: check_version + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + submodules: true + fetch-depth: 0 + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Build sdist + run: | + rm -rf dist + python -m pip install --upgrade build twine + python -m build --sdist + python -m twine check dist/*.tar.gz + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: python-distributions-sdist + path: dist/roboplan-*.tar.gz + if-no-files-found: error + + build_wheels: + name: Build ${{ matrix.name }} wheels + runs-on: ${{ matrix.os }} + needs: check_version + strategy: + fail-fast: false + matrix: + include: + - name: Linux x86_64 + os: ubuntu-latest + archs: x86_64 + artifact: linux-x86_64 + extra_environment: "" + - name: Linux aarch64 + os: ubuntu-24.04-arm + archs: aarch64 + artifact: linux-aarch64 + extra_environment: "" + - name: macOS arm64 + os: macos-14 + archs: arm64 + artifact: macos-arm64 + extra_environment: "MACOSX_DEPLOYMENT_TARGET=14.0" + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + submodules: true + fetch-depth: 0 + persist-credentials: false + - name: Build wheels + uses: pypa/cibuildwheel@v4.1.0 + env: + CIBW_ARCHS: ${{ matrix.archs }} + CIBW_BUILD: "cp310-* cp311-* cp312-* cp313-* cp314-*" + CIBW_SKIP: "pp* *-musllinux*" + CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_28 + CIBW_MANYLINUX_AARCH64_IMAGE: manylinux_2_28 + CIBW_ENVIRONMENT: >- + CMAKE_BUILD_PARALLEL_LEVEL=2 + MAKEFLAGS="-j2" + NINJAFLAGS="-j2" + ${{ matrix.extra_environment }} + CIBW_TEST_COMMAND: >- + python -c "import roboplan; import roboplan.core; import roboplan.filters; import roboplan.example_models; + import roboplan.simple_ik; import roboplan.optimal_ik; import roboplan.rrt; + import roboplan.toppra; import roboplan.cartesian_planning; print('roboplan imports ok')" + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: python-distributions-${{ matrix.artifact }} + path: wheelhouse/roboplan-*.whl + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..6e26cb6d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,43 @@ +name: Release + +on: + push: + tags: + - "[0-9]*.[0-9]*.[0-9]*" + workflow_dispatch: + +permissions: + contents: read + +jobs: + build_pypi_wheels: + name: Build PyPI wheels + uses: ./.github/workflows/build-pypi-wheels.yml + + publish_pypi: + name: Publish to PyPI + runs-on: ubuntu-latest + needs: build_pypi_wheels + if: startsWith(github.ref, 'refs/tags/') + environment: + name: pypi + url: https://pypi.org/project/roboplan/ + permissions: + contents: read + id-token: write + steps: + - name: Download distributions + uses: actions/download-artifact@v5 + with: + pattern: python-distributions-* + merge-multiple: true + path: dist + - name: Check distributions + run: | + python -m pip install --upgrade twine + ls -la dist + python -m twine check dist/* + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ diff --git a/.gitignore b/.gitignore index 17041894..184f5e2f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,9 @@ .pixi *.egg-info +# Python packaging artifacts +dist/ +wheelhouse/ + # Benchmarking results pytest_benchmarks.json diff --git a/README.md b/README.md index 4486c86c..136a7c79 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ The main folders found in this repo are as follows. - `roboplan_cartesian_planning` : A Cartesian planner for following task-space paths. - `roboplan_example_models` : Contains robot models used for testing and examples. - `roboplan_examples` : Basic examples with real robot models. +- `packaging` : Files to help package wheels for release on PyPi. --- diff --git a/packaging/COLCON_IGNORE b/packaging/COLCON_IGNORE new file mode 100644 index 00000000..e69de29b diff --git a/packaging/python/CMakeLists.txt b/packaging/python/CMakeLists.txt new file mode 100644 index 00000000..74164f41 --- /dev/null +++ b/packaging/python/CMakeLists.txt @@ -0,0 +1,29 @@ +cmake_minimum_required(VERSION 3.22) + +set(ROBOPLAN_PACKAGING_VERSION "0.0.0") +if(DEFINED SKBUILD_PROJECT_VERSION) + set(ROBOPLAN_PACKAGING_VERSION "${SKBUILD_PROJECT_VERSION}") +endif() +project(roboplan VERSION "${ROBOPLAN_PACKAGING_VERSION}") + +get_filename_component(ROBOPLAN_REPOSITORY_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) + +include(cmake/roboplan_python_packaging.cmake) + +if(SKBUILD) + roboplan_configure_scikit_build_prefix() +endif() + +roboplan_register_build_tree_packages() + +add_subdirectory("${ROBOPLAN_REPOSITORY_ROOT}/roboplan_example_models" "roboplan_example_models") +add_subdirectory("${ROBOPLAN_REPOSITORY_ROOT}/roboplan" "roboplan") +add_subdirectory("${ROBOPLAN_REPOSITORY_ROOT}/roboplan_simple_ik" "roboplan_simple_ik") +add_subdirectory("${ROBOPLAN_REPOSITORY_ROOT}/roboplan_oink" "roboplan_oink") +add_subdirectory("${ROBOPLAN_REPOSITORY_ROOT}/roboplan_rrt" "roboplan_rrt") +add_subdirectory("${ROBOPLAN_REPOSITORY_ROOT}/roboplan_toppra" "roboplan_toppra") +add_subdirectory("${ROBOPLAN_REPOSITORY_ROOT}/roboplan_cartesian_planning" "roboplan_cartesian_planning") + +if(SKBUILD) + roboplan_configure_unified_python_wheel() +endif() diff --git a/packaging/python/README.md b/packaging/python/README.md new file mode 100644 index 00000000..4e6c0763 --- /dev/null +++ b/packaging/python/README.md @@ -0,0 +1,68 @@ +# Python packaging + +This directory contains the PyPI/cibuildwheel-specific packaging helpers for RoboPlan. +The normal source-tree CMake build remains in the package directories. +`pyproject.toml` points `scikit-build-core` at this directory with `tool.scikit-build.cmake.source-dir`. + +The helper CMake files cover three packaging-only concerns: + +- expose `cmeel`'s isolated native dependency prefix to `scikit-build-core` builds; +- install the runtime native libraries that wheels need at import time; +- repair installed RPATHs with `patchelf` using checked-in CMake script templates rather than large embedded `install(CODE ...)` strings. + +Release wheels are built with `cibuildwheel` in the [`build-pypi-wheels.yml`](../../.github/workflows/build-pypi-wheels.yml) CI workflow, and are smoke-tested by importing the `roboplan` namespace and every compiled submodule. +That workflow runs on pull requests so wheel build breakage is caught before release. +The [`release.yml`](../../.github/workflows/release.yml) workflow invokes the same build workflow from the tagged commit, downloads those fresh artifacts, and publishes them through PyPI trusted publishing. + + +## Local checks + +Run commands from the repository root. Keep native parallelism capped on small machines, matching CI: + +```bash +export CMAKE_BUILD_PARALLEL_LEVEL=2 +export MAKEFLAGS=-j2 +export NINJAFLAGS=-j2 +``` + +Source-build and import-test the unified wheel path: + +```bash +uv venv --seed /tmp/roboplan-wheel-check +uv pip install -v --python /tmp/roboplan-wheel-check/bin/python --no-cache . +/tmp/roboplan-wheel-check/bin/python - <<'PY' +import roboplan +import roboplan.core +import roboplan.filters +import roboplan.example_models +import roboplan.simple_ik +import roboplan.optimal_ik +import roboplan.rrt +import roboplan.toppra +import roboplan.cartesian_planning +print("roboplan imports ok") +PY +``` + +Check the structural packaging contract: + +```bash +uvx pytest tests/unified_python_package_test.py -q +``` + +## Run an example against the wheel + +To prove the wheel is usable end-to-end, install it into a fresh environment and run one of the examples. +The examples are not part of the wheel, so they pull in a few extra runtime dependencies on top of `roboplan` itself. + +```bash +# 1. Create an isolated environment and install the wheel we just built. +uv venv --seed /tmp/roboplan-wheel-check +uv pip install --python /tmp/roboplan-wheel-check/bin/python --no-cache . + +# 2. Install the extra dependencies the examples need. +uv pip install --python /tmp/roboplan-wheel-check/bin/python pycollada tyro xacro viser + +# 3. Run an example. +/tmp/roboplan-wheel-check/bin/python roboplan_examples/python/example_ik.py +``` diff --git a/packaging/python/cmake/repair_unified_macos_rpaths.cmake.in b/packaging/python/cmake/repair_unified_macos_rpaths.cmake.in new file mode 100644 index 00000000..8bb289d6 --- /dev/null +++ b/packaging/python/cmake/repair_unified_macos_rpaths.cmake.in @@ -0,0 +1,55 @@ +function(_roboplan_resolve_macos_patch_file file result) + set(_patch_file "${file}") + if(IS_SYMLINK "${_patch_file}") + file(READ_SYMLINK "${_patch_file}" _symlink_target) + if(IS_ABSOLUTE "${_symlink_target}") + set(${result} "" PARENT_SCOPE) + return() + endif() + get_filename_component(_symlink_dir "${_patch_file}" DIRECTORY) + set(_patch_file "${_symlink_dir}/${_symlink_target}") + endif() + + set(${result} "${_patch_file}" PARENT_SCOPE) +endfunction() + +function(_roboplan_add_macos_rpath file rpath) + _roboplan_resolve_macos_patch_file("${file}" _patch_file) + if(NOT _patch_file OR NOT EXISTS "${_patch_file}") + return() + endif() + + execute_process( + COMMAND otool -l "${_patch_file}" + OUTPUT_VARIABLE _otool_output + ERROR_QUIET + RESULT_VARIABLE _otool_result + ) + if(NOT _otool_result EQUAL 0) + return() + endif() + + string(FIND "${_otool_output}" "path ${rpath} (offset" _existing_rpath) + if(_existing_rpath GREATER_EQUAL 0) + return() + endif() + + execute_process( + COMMAND chmod u+w "${_patch_file}" + COMMAND_ERROR_IS_FATAL ANY + ) + execute_process( + COMMAND "@ROBOPLAN_UNIFIED_INSTALL_NAME_TOOL@" -add_rpath "${rpath}" "${_patch_file}" + COMMAND_ERROR_IS_FATAL ANY + ) +endfunction() + +file(GLOB_RECURSE _roboplan_extensions "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/roboplan/*.so") +foreach(_file IN LISTS _roboplan_extensions) + _roboplan_add_macos_rpath("${_file}" "@loader_path/../../lib") +endforeach() + +file(GLOB _roboplan_libraries "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/lib/*.dylib") +foreach(_file IN LISTS _roboplan_libraries) + _roboplan_add_macos_rpath("${_file}" "@loader_path") +endforeach() diff --git a/packaging/python/cmake/repair_unified_rpaths.cmake.in b/packaging/python/cmake/repair_unified_rpaths.cmake.in new file mode 100644 index 00000000..dde72766 --- /dev/null +++ b/packaging/python/cmake/repair_unified_rpaths.cmake.in @@ -0,0 +1,35 @@ +function(_roboplan_patch_rpath file rpath) + set(_patch_file "${file}") + if(IS_SYMLINK "${_patch_file}") + file(READ_SYMLINK "${_patch_file}" _symlink_target) + if(IS_ABSOLUTE "${_symlink_target}") + return() + endif() + get_filename_component(_symlink_dir "${_patch_file}" DIRECTORY) + set(_patch_file "${_symlink_dir}/${_symlink_target}") + endif() + + if(EXISTS "${_patch_file}") + execute_process( + COMMAND chmod u+w "${_patch_file}" + COMMAND_ERROR_IS_FATAL ANY + ) + execute_process( + COMMAND "@ROBOPLAN_UNIFIED_PATCHELF@" --set-rpath "${rpath}" "${_patch_file}" + COMMAND_ERROR_IS_FATAL ANY + ) + endif() +endfunction() + +file(GLOB_RECURSE _roboplan_extensions "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/roboplan/*.so") +foreach(_file IN LISTS _roboplan_extensions) + _roboplan_patch_rpath("${_file}" "$ORIGIN/../../lib:$ORIGIN/../../cmeel.prefix/lib") +endforeach() + +file(GLOB _roboplan_libraries + "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/lib/libfilters.so*" + "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/lib/libroboplan*.so*" +) +foreach(_file IN LISTS _roboplan_libraries) + _roboplan_patch_rpath("${_file}" "$ORIGIN:$ORIGIN/../cmeel.prefix/lib") +endforeach() diff --git a/packaging/python/cmake/roboplan_python_packaging.cmake b/packaging/python/cmake/roboplan_python_packaging.cmake new file mode 100644 index 00000000..69db891d --- /dev/null +++ b/packaging/python/cmake/roboplan_python_packaging.cmake @@ -0,0 +1,215 @@ +function(roboplan_configure_scikit_build_prefix) + find_package(Python COMPONENTS Interpreter REQUIRED) + execute_process( + COMMAND + "${Python_EXECUTABLE}" + -c + "import pathlib, sysconfig; prefix = pathlib.Path(sysconfig.get_path('purelib')) / 'cmeel.prefix'; print(prefix if prefix.exists() else '')" + OUTPUT_VARIABLE ROBOPLAN_CMEEL_PREFIX + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + set(ROBOPLAN_CMEEL_PREFIX "${ROBOPLAN_CMEEL_PREFIX}" PARENT_SCOPE) + if(ROBOPLAN_CMEEL_PREFIX) + list(PREPEND CMAKE_PREFIX_PATH "${ROBOPLAN_CMEEL_PREFIX}") + set(CMAKE_PREFIX_PATH "${CMAKE_PREFIX_PATH}" PARENT_SCOPE) + endif() +endfunction() + +function(roboplan_register_build_tree_package package_name) + set(options) + set(one_value_args) + set(multi_value_args ALIASES) + cmake_parse_arguments(ARG "${options}" "${one_value_args}" "${multi_value_args}" ${ARGN}) + + set(_package_dir "${PROJECT_BINARY_DIR}/packaging-python-package-configs/${package_name}") + file(MAKE_DIRECTORY "${_package_dir}") + set(_config_file "${_package_dir}/${package_name}Config.cmake") + file(WRITE "${_config_file}" "# Generated for the packaging/python build tree.\n") + + foreach(alias_pair IN LISTS ARG_ALIASES) + string(REPLACE "=" ";" alias_parts "${alias_pair}") + list(GET alias_parts 0 namespaced_target) + list(GET alias_parts 1 local_target) + file(APPEND "${_config_file}" + "if(TARGET ${local_target} AND NOT TARGET ${namespaced_target})\n" + " add_library(${namespaced_target} ALIAS ${local_target})\n" + "endif()\n" + ) + endforeach() + + set(${package_name}_DIR "${_package_dir}" CACHE PATH "Build-tree ${package_name} package config" FORCE) +endfunction() + +function(roboplan_register_build_tree_packages) + roboplan_register_build_tree_package(roboplan_example_models + ALIASES roboplan_example_models::roboplan_example_models=roboplan_example_models) + roboplan_register_build_tree_package(roboplan + ALIASES roboplan::roboplan=roboplan roboplan::filters=filters) + roboplan_register_build_tree_package(roboplan_simple_ik + ALIASES roboplan_simple_ik::roboplan_simple_ik=roboplan_simple_ik) + roboplan_register_build_tree_package(roboplan_oink + ALIASES roboplan_oink::roboplan_oink=roboplan_oink) + roboplan_register_build_tree_package(roboplan_rrt + ALIASES roboplan_rrt::roboplan_rrt=roboplan_rrt) + roboplan_register_build_tree_package(roboplan_toppra + ALIASES roboplan_toppra::roboplan_toppra=roboplan_toppra) + roboplan_register_build_tree_package(roboplan_cartesian_planning + ALIASES roboplan_cartesian_planning::roboplan_cartesian_planning=roboplan_cartesian_planning) +endfunction() + +function(roboplan_install_matching_libraries pattern) + foreach(search_root IN ITEMS + "$ENV{CONDA_PREFIX}/lib" + "${ROBOPLAN_CMEEL_PREFIX}/lib") + if(search_root) + file(GLOB matched_libraries LIST_DIRECTORIES false "${search_root}/${pattern}") + foreach(matched_library IN LISTS matched_libraries) + install(FILES "${matched_library}" DESTINATION lib) + file(REAL_PATH "${matched_library}" matched_real_library) + install(FILES "${matched_real_library}" DESTINATION lib) + endforeach() + endif() + endforeach() + foreach(search_prefix IN LISTS CMAKE_PREFIX_PATH) + if(search_prefix) + file(GLOB matched_libraries LIST_DIRECTORIES false "${search_prefix}/lib/${pattern}") + foreach(matched_library IN LISTS matched_libraries) + install(FILES "${matched_library}" DESTINATION lib) + file(REAL_PATH "${matched_library}" matched_real_library) + install(FILES "${matched_real_library}" DESTINATION lib) + endforeach() + endif() + endforeach() +endfunction() + +function(roboplan_configure_unified_python_wheel) + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + find_program(ROBOPLAN_UNIFIED_PATCHELF patchelf REQUIRED) + elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") + find_program(ROBOPLAN_UNIFIED_INSTALL_NAME_TOOL install_name_tool REQUIRED) + endif() + + if(CMAKE_SYSTEM_NAME STREQUAL "Darwin") + set(ROBOPLAN_UNIFIED_LIBRARY_INSTALL_RPATH "@loader_path") + set(ROBOPLAN_UNIFIED_EXTENSION_INSTALL_RPATH "@loader_path/../../lib") + else() + set(ROBOPLAN_UNIFIED_LIBRARY_INSTALL_RPATH "$ORIGIN") + set(ROBOPLAN_UNIFIED_EXTENSION_INSTALL_RPATH "$ORIGIN/../../lib") + endif() + + foreach(target IN ITEMS + roboplan_example_models + roboplan + filters + roboplan_simple_ik + roboplan_oink + roboplan_rrt + roboplan_toppra + roboplan_cartesian_planning) + if(TARGET ${target}) + set_property(TARGET ${target} PROPERTY INSTALL_RPATH "${ROBOPLAN_UNIFIED_LIBRARY_INSTALL_RPATH}") + endif() + endforeach() + + foreach(target IN ITEMS + _example_models_ext + _core_ext + _filters_ext + _simple_ik_ext + _optimal_ik_ext + _rrt_ext + _toppra_ext + _cartesian_ext) + if(TARGET ${target}) + set_property(TARGET ${target} PROPERTY INSTALL_RPATH "${ROBOPLAN_UNIFIED_EXTENSION_INSTALL_RPATH}") + endif() + endforeach() + + foreach(library IN ITEMS + OsqpEigen + boost_atomic + boost_filesystem + boost_serialization + boost_system + coal + console_bridge + gz-math + gz-utils + octomap + octomath + osqp + pinocchio_collision + pinocchio_default + pinocchio_extra + pinocchio_parsers + qdldl + qhull_r + sdformat + tinyxml2 + toppra + urdfdom_model + urdfdom_sensor + urdfdom_world + yaml-cpp) + find_library(ROBOPLAN_UNIFIED_${library}_LIBRARY NAMES ${library}) + if(ROBOPLAN_UNIFIED_${library}_LIBRARY) + install(FILES "${ROBOPLAN_UNIFIED_${library}_LIBRARY}" DESTINATION lib) + file(REAL_PATH "${ROBOPLAN_UNIFIED_${library}_LIBRARY}" ROBOPLAN_UNIFIED_${library}_REAL_LIBRARY) + install(FILES "${ROBOPLAN_UNIFIED_${library}_REAL_LIBRARY}" DESTINATION lib) + endif() + endforeach() + + foreach(library_pattern IN ITEMS + "libassimp.so.*" + "libboost_atomic.so.*" + "libboost_filesystem.so.*" + "libboost_serialization.so.*" + "libboost_system.so.*" + "libconsole_bridge.so.*" + "libgz-math.so.*" + "libgz-utils.so.*" + "liboctomap.so.*" + "liboctomath.so.*" + "libqhull_r.so.*" + "libsdformat.so.*" + "libtinyxml2.so.*" + "liburdfdom_model.so.*" + "liburdfdom_sensor.so.*" + "liburdfdom_world.so.*" + "libyaml-cpp.so.*" + "libassimp.*.dylib" + "libboost_atomic.*.dylib" + "libboost_filesystem.*.dylib" + "libboost_serialization.*.dylib" + "libboost_system.*.dylib" + "libconsole_bridge.*.dylib" + "libgz-math.*.dylib" + "libgz-utils.*.dylib" + "liboctomap.*.dylib" + "liboctomath.*.dylib" + "libqhull_r.*.dylib" + "libsdformat.*.dylib" + "libtinyxml2.*.dylib" + "liburdfdom_model.*.dylib" + "liburdfdom_sensor.*.dylib" + "liburdfdom_world.*.dylib" + "libyaml-cpp.*.dylib") + roboplan_install_matching_libraries("${library_pattern}") + endforeach() + + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + configure_file( + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/repair_unified_rpaths.cmake.in" + "${PROJECT_BINARY_DIR}/roboplan_repair_unified_rpaths.cmake" + @ONLY + ) + install(SCRIPT "${PROJECT_BINARY_DIR}/roboplan_repair_unified_rpaths.cmake") + elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") + configure_file( + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/repair_unified_macos_rpaths.cmake.in" + "${PROJECT_BINARY_DIR}/roboplan_repair_unified_macos_rpaths.cmake" + @ONLY + ) + install(SCRIPT "${PROJECT_BINARY_DIR}/roboplan_repair_unified_macos_rpaths.cmake") + endif() +endfunction() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..cd8cfc23 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,54 @@ +[build-system] +requires = [ + "scikit-build-core >=0.10", + "setuptools-scm >=8", + "cmeel-eigen[build]", + "cmeel-yaml-cpp[build]", + "libpinocchio[build] == 4.0.0", + "nanobind >=1.3.2", + "patchelf; platform_system == 'Linux'", +] +build-backend = "scikit_build_core.build" + +[project] +name = "roboplan" +dynamic = ["version"] +description = "Modern robot motion planning library based on Pinocchio." +readme = "README.md" +requires-python = ">=3.10,<3.15" +authors = [ + { name = "Sebastian Castro", email = "sebas.a.castro@gmail.com" }, + { name = "Erik Holum", email = "eholum@gmail.com" }, + { name = "Sebastian Jahr", email = "sebastian.jahr@tuta.io" }, +] +classifiers = [ + "License :: OSI Approved :: MIT License", +] +dependencies = [ + "matplotlib", + "numpy", + "pin", +] + +[project.urls] +Homepage = "https://github.com/sea-bass/roboplan" + +[tool.scikit-build] +minimum-version = "build-system.requires" +metadata.version.provider = "scikit_build_core.metadata.setuptools_scm" +build-dir = "build/{wheel_tag}" +cmake.source-dir = "packaging/python" +cmake.define.BUILD_PYTHON_BINDINGS = "ON" +cmake.define.BUILD_TESTING = "OFF" +cmake.define.GENERATE_PYTHON_STUBS = "OFF" +cmake.define.CMAKE_POLICY_VERSION_MINIMUM = "3.5" +cmake.define.CMAKE_CXX_STANDARD = "17" +cmake.define.CMAKE_CXX_STANDARD_REQUIRED = "ON" +cmake.define.CMAKE_INSTALL_LIBDIR = "lib" +cmake.define.CMAKE_INSTALL_RPATH = "$ORIGIN/../../lib" +cmake.define.CMAKE_INSTALL_RPATH_USE_LINK_PATH = "FALSE" + +[tool.scikit-build.sdist] +exclude = [".omo/**"] + +[tool.setuptools_scm] diff --git a/tests/unified_python_package_test.py b/tests/unified_python_package_test.py new file mode 100644 index 00000000..2c46ef10 --- /dev/null +++ b/tests/unified_python_package_test.py @@ -0,0 +1,274 @@ +from collections.abc import Iterable +from pathlib import Path +import re +import tomllib +from typing import cast + +ROOT = Path(__file__).resolve().parents[1] +PYTHON_BINDING_PACKAGES = ( + "roboplan_example_models", + "roboplan", + "roboplan_simple_ik", + "roboplan_oink", + "roboplan_rrt", + "roboplan_toppra", + "roboplan_cartesian_planning", +) +DEPENDENT_PACKAGES = PYTHON_BINDING_PACKAGES[2:] +PACKAGES_WITH_EXAMPLE_MODEL_TEST_DEPENDENCY = ( + PYTHON_BINDING_PACKAGES[1], + *PYTHON_BINDING_PACKAGES[3:], +) + + +def _read(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def _read_toml(path: str) -> dict[str, object]: + return cast(dict[str, object], tomllib.loads(_read(path))) + + +def _assert_contains_all(source: str, expected: Iterable[str]) -> None: + for value in expected: + assert value in source, f"expected {value!r}" + + +def _assert_excludes_all(source: str, unexpected: Iterable[str]) -> None: + for value in unexpected: + assert value not in source, f"unexpected {value!r}" + + +def test_root_pyproject_defines_unified_roboplan_distribution() -> None: + data = _read_toml("pyproject.toml") + build_system = cast(dict[str, object], data["build-system"]) + build_requires = cast(list[str], build_system["requires"]) + project = cast(dict[str, object], data["project"]) + + assert project["name"] == "roboplan" + assert "version" not in project + assert project["dynamic"] == ["version"] + assert project["requires-python"] == ">=3.10,<3.15" + assert "setuptools-scm >=8" in build_requires + assert "cmeel-eigen[build]" in build_requires + assert "cmeel-yaml-cpp[build]" in build_requires + assert "libpinocchio[build] == 4.0.0" in build_requires + assert "patchelf; platform_system == 'Linux'" in build_requires + dependencies = cast(list[str], project["dependencies"]) + assert "roboplan-core" not in dependencies + assert "numpy" in dependencies + assert "pin" in dependencies + assert "matplotlib" in dependencies + + tool = cast(dict[str, object], data["tool"]) + scikit_build = cast(dict[str, object], tool["scikit-build"]) + metadata = cast(dict[str, object], scikit_build["metadata"]) + version_metadata = cast(dict[str, str], metadata["version"]) + assert version_metadata["provider"] == "scikit_build_core.metadata.setuptools_scm" + assert "setuptools_scm" in tool + + cmake = cast(dict[str, object], scikit_build["cmake"]) + assert cmake["source-dir"] == "packaging/python" + defines = cast(dict[str, str], cmake["define"]) + assert defines["CMAKE_POLICY_VERSION_MINIMUM"] == "3.5" + assert defines["CMAKE_CXX_STANDARD"] == "17" + assert defines["CMAKE_CXX_STANDARD_REQUIRED"] == "ON" + assert defines["CMAKE_INSTALL_LIBDIR"] == "lib" + assert defines["CMAKE_INSTALL_RPATH"] == "$ORIGIN/../../lib" + assert defines["CMAKE_INSTALL_RPATH_USE_LINK_PATH"] == "FALSE" + + sdist = cast(dict[str, list[str]], scikit_build["sdist"]) + assert ".omo/**" in sdist["exclude"] + + +def test_packaging_directory_is_ignored_by_colcon() -> None: + assert (ROOT / "packaging/COLCON_IGNORE").exists() + + +def test_packaging_cmake_uses_scikit_build_dependency_prefix_only() -> None: + packaging_cmake = _read("packaging/python/CMakeLists.txt") + helper = _read("packaging/python/cmake/roboplan_python_packaging.cmake") + + _assert_contains_all( + packaging_cmake, + [ + "include(cmake/roboplan_python_packaging.cmake)", + "roboplan_configure_scikit_build_prefix()", + ], + ) + _assert_excludes_all(packaging_cmake, ["roboplan_ensure_hpp_fcl_target"]) + _assert_excludes_all(helper, ["hpp-fcl", "/opt/ros"]) + + +def test_root_cmake_superbuild_adds_all_python_binding_packages_in_order() -> None: + source = _read("packaging/python/CMakeLists.txt") + helper_source = _read("packaging/python/cmake/roboplan_python_packaging.cmake") + package_order = re.findall( + r'add_subdirectory\("\$\{ROBOPLAN_REPOSITORY_ROOT\}/(roboplan(?:_[a-z_]+)?)"\s+"[^/]+"\)', + source, + ) + + repair_source = _read("packaging/python/cmake/repair_unified_rpaths.cmake.in") + macos_repair_source = _read( + "packaging/python/cmake/repair_unified_macos_rpaths.cmake.in" + ) + + _assert_contains_all( + source, + [ + "SKBUILD_PROJECT_VERSION", + "roboplan_configure_scikit_build_prefix()", + "roboplan_configure_unified_python_wheel()", + ], + ) + _assert_contains_all( + helper_source, + [ + "cmeel.prefix", + "list(PREPEND CMAKE_PREFIX_PATH", + "${ROBOPLAN_CMEEL_PREFIX}/lib", + "${search_prefix}/lib/${pattern}", + "boost_atomic", + "roboplan_install_matching_libraries", + "libboost_atomic.so.*", + "libboost_filesystem.so.*", + "liburdfdom_world.so.*", + "liburdfdom_world.*.dylib", + "liboctomap.*.dylib", + "install(SCRIPT", + "$ORIGIN/../../lib", + "install_name_tool", + "repair_unified_macos_rpaths.cmake", + "@loader_path/../../lib", + ], + ) + _assert_excludes_all(helper_source, [".so.1.90.0", ".so.0.11.0", "install(CODE"]) + assert "--set-rpath" in repair_source + _assert_contains_all( + macos_repair_source, + ["-add_rpath", "@loader_path/../../lib", "@loader_path"], + ) + assert package_order == list(PYTHON_BINDING_PACKAGES) + + +def test_packaging_entrypoint_provides_build_tree_package_configs() -> None: + helper = _read("packaging/python/cmake/roboplan_python_packaging.cmake") + packaging_cmake = _read("packaging/python/CMakeLists.txt") + + assert "roboplan_register_build_tree_packages()" in packaging_cmake + assert "roboplan_configure_cmeel_package" not in helper + _assert_contains_all( + helper, + [ + "function(roboplan_register_build_tree_package package_name)", + "${package_name}_DIR", + "roboplan::roboplan=roboplan", + "roboplan::filters=filters", + "roboplan_example_models::roboplan_example_models=roboplan_example_models", + "roboplan_cartesian_planning::roboplan_cartesian_planning=roboplan_cartesian_planning", + ], + ) + + +def test_dependent_packages_keep_upstream_find_package_shape() -> None: + for package in DEPENDENT_PACKAGES: + path = f"{package}/CMakeLists.txt" + source = _read(path) + + assert "find_package(roboplan REQUIRED)" in source, path + assert "if(NOT TARGET roboplan::roboplan)" not in source, path + + +def test_binding_packages_keep_upstream_nanobind_discovery() -> None: + for package in PYTHON_BINDING_PACKAGES: + path = f"{package}/bindings/CMakeLists.txt" + source = _read(path) + + assert "-m nanobind --cmake_dir" in source, path + assert "find_package(nanobind CONFIG REQUIRED)" in source, path + assert 'ROBOPLAN_VERSION="${PROJECT_VERSION}"' in source, path + assert "ROBOPLAN_NANOBIND_PYTHON_RESULT" not in source, path + + +def test_dependent_package_tests_keep_upstream_example_model_dependency() -> None: + for package in PACKAGES_WITH_EXAMPLE_MODEL_TEST_DEPENDENCY: + path = f"{package}/test/CMakeLists.txt" + source = _read(path) + + assert "find_package(roboplan_example_models REQUIRED)" in source, path + assert ( + "if(NOT TARGET roboplan_example_models::roboplan_example_models)" + not in source + ), path + + +def test_python_binding_workflow_builds_repaired_wheels_for_pr_ci() -> None: + workflow = _read(".github/workflows/build-pypi-wheels.yml") + + assert "pull_request:" in workflow + assert "push:" in workflow + assert "branches:" in workflow + assert "- main" in workflow + assert "workflow_call:" in workflow + assert "name: Build PyPI wheels" in workflow + assert "PACKAGE_NAME:" not in workflow + assert 'project["name"] != "roboplan"' in workflow + assert "fetch-depth: 0" in workflow + assert workflow.count("fetch-depth: 0") == 3 + assert "setuptools-scm>=8" in workflow + assert "get_version(root='.', local_scheme='no-local-version')" in workflow + assert 'project["version"]' not in workflow + assert "dynamic = ['version']" in workflow + assert 'python-version: "3.12"' in workflow + assert "RELEASE_TAG#v" not in workflow + assert "^[0-9]+\\.[0-9]+\\.[0-9]+$" in workflow + assert "pypa/cibuildwheel@" in workflow + assert "fail-fast: false" in workflow + assert "ubuntu-24.04-arm" in workflow + assert "macos-13" not in workflow + assert "macos-14" in workflow + assert "CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_28" in workflow + assert "CIBW_MANYLINUX_AARCH64_IMAGE: manylinux_2_28" in workflow + assert "CIBW_ARCHS: ${{ matrix.archs }}" in workflow + assert "archs: x86_64" in workflow + assert "archs: aarch64" in workflow + assert "archs: arm64" in workflow + assert "artifact: linux-x86_64" in workflow + assert "artifact: linux-aarch64" in workflow + assert "artifact: macos-x86_64" not in workflow + assert "artifact: macos-arm64" in workflow + assert 'extra_environment: "MACOSX_DEPLOYMENT_TARGET=13.0"' not in workflow + assert 'extra_environment: "MACOSX_DEPLOYMENT_TARGET=14.0"' in workflow + assert "${{ matrix.extra_environment }}" in workflow + assert 'CIBW_BUILD: "cp310-* cp311-* cp312-* cp313-* cp314-*"' in workflow + assert "cp315" not in workflow + assert "CIBW_ENABLE: cpython-prerelease" not in workflow + assert 'CIBW_SKIP: "pp* *-musllinux*"' in workflow + assert "CMAKE_BUILD_PARALLEL_LEVEL=2" in workflow + assert 'MAKEFLAGS="-j2"' in workflow + assert 'NINJAFLAGS="-j2"' in workflow + assert "import roboplan; import roboplan.core" in workflow + assert "roboplan.toppra" in workflow + assert "roboplan.cartesian_planning" in workflow + assert "dist/roboplan-*.tar.gz" in workflow + assert "python-distributions-${{ matrix.artifact }}" in workflow + assert "wheelhouse/roboplan-*.whl" in workflow + + +def test_release_workflow_reuses_python_binding_build_and_publishes() -> None: + workflow = _read(".github/workflows/release.yml") + + assert '"v*"' not in workflow + assert '"[0-9]*.[0-9]*.[0-9]*"' in workflow + assert "uses: ./.github/workflows/build-pypi-wheels.yml" in workflow + assert "pypa/cibuildwheel@" not in workflow + assert "pypa/gh-action-pypi-publish@release/v1" in workflow + assert workflow.count("pypa/gh-action-pypi-publish@release/v1") == 1 + assert "if: startsWith(github.ref, 'refs/tags/')" in workflow + assert "id-token: write" in workflow + assert "testpypi" not in workflow.lower() + assert "https://pypi.org/project/roboplan/" in workflow + assert "repository-url: https://test.pypi.org/legacy/" not in workflow + assert "pattern: python-distributions-*" in workflow + assert "TWINE_PASSWORD" not in workflow + assert "__token__" not in workflow