diff --git a/.github/workflows/build-python-fcl.yml b/.github/workflows/build-python-fcl.yml new file mode 100644 index 000000000..ea9d9e27a --- /dev/null +++ b/.github/workflows/build-python-fcl.yml @@ -0,0 +1,111 @@ +# SPDX-FileCopyrightText: 2026 The RISE Project +# SPDX-License-Identifier: MIT +--- +# This workflow is based on: https://github.com/berkeleyautomation/python-fcl/blob/v0.7.0.11/.github/workflows/push.yml +name: Build python-fcl wheels (riscv64) + +on: + workflow_dispatch: + inputs: + version: + description: 'python-fcl version to build (git tag without leading v, e.g. 0.7.0.11)' + required: true + default: '0.7.0.11' + pull_request: + paths: + - '.github/workflows/build-python-fcl.yml' + - 'patches/python-fcl/**' + +concurrency: + group: ${{ github.workflow }}-${{ inputs.version || '0.7.0.11' }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read # to fetch code (actions/checkout) + +env: + # `inputs.version` is empty on pull_request events; default to 0.7.0.11 there. + PYTHON_FCL_VERSION: ${{ inputs.version || '0.7.0.11' }} + MANYLINUX_RISCV64_IMAGE: quay.io/pypa/manylinux_2_39_riscv64 + +jobs: + setup: + uses: $/.github/workflows/_setup.yml + + build_wheels: + needs: [setup] + name: Build python-fcl ${{ inputs.version || '0.7.0.11' }} ${{ matrix.python }}-manylinux_riscv64 + runs-on: ubuntu-24.04-riscv + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + python: + - "cp312" + - "cp313" + - "cp314" + - "cp314t" + + steps: + - name: Checkout python-fcl v${{ env.PYTHON_FCL_VERSION }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: berkeleyautomation/python-fcl + ref: v${{ env.PYTHON_FCL_VERSION }} + persist-credentials: false + + - name: Checkout python-wheels + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: python-wheels + persist-credentials: false + + - name: Apply patches + run: git apply -v python-wheels/patches/python-fcl/${{ env.PYTHON_FCL_VERSION }}/*.patch + + - name: Build wheels + uses: pypa/cibuildwheel@1828c10ab37f080699c7b81cea34097c684a7074 # v4.2.0 + with: + output-dir: wheelhouse/ + only: ${{ matrix.python }}-manylinux_riscv64 + env: + CIBW_MANYLINUX_RISCV64_IMAGE: ${{ env.MANYLINUX_RISCV64_IMAGE }} + # build_dependencies/install_linux.sh builds eigen/libccd/octomap/fcl from + # source; the license copies are appended here since CIBW_BEFORE_ALL_LINUX + # fully replaces pyproject.toml's own before-all rather than extending it. + # -DBUILD_TESTING=OFF skips fcl's own test-binary build (not shipped in the + # wheel either way, and it otherwise adds ~25min per interpreter). + CIBW_BEFORE_ALL_LINUX: >- + sed -i '/^cd fcl$/,/^cd \.\.$/ s/^\$CMAKE \.[[:space:]]*$/$CMAKE . -DBUILD_TESTING=OFF/' build_dependencies/install_linux.sh && + bash build_dependencies/install_linux.sh && + cp deps/fcl/LICENSE /project/LICENSE.fcl && + cp deps/libccd/BSD-LICENSE /project/LICENSE.libccd && + cp deps/octomap/octomap/LICENSE.txt /project/LICENSE.octomap + # numpy: our registry is one patch release behind PyPI's latest (gotcha 84). + # cmake: install_linux.sh pip-installs an exact-pinned cmake==3.31.6, which + # our registry also carries; PIP_ONLY_BINARY keeps it off a source bootstrap. + CIBW_ENVIRONMENT: >- + PIP_EXTRA_INDEX_URL=https://pypi.riseproject.dev/simple/ + PIP_ONLY_BINARY=cmake,numpy + CIBW_TEST_REQUIRES: pytest + CIBW_TEST_COMMAND: >- + python -c "import importlib.metadata as m; + n={p.name for p in m.files('python-fcl') if '.dist-info/licenses/' in str(p)}; + assert n == {'LICENSE', 'LICENSE.fcl', 'LICENSE.libccd', 'LICENSE.octomap'}, n" && + pytest {project}/tests + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: python-fcl-${{ env.PYTHON_FCL_VERSION }}-${{ matrix.python }}-manylinux_riscv64 + path: wheelhouse/*.whl + if-no-files-found: error + + publish: + name: Publish python-fcl ${{ inputs.version || '0.7.0.11' }} + needs: [setup, build_wheels] + permissions: + contents: write + pull-requests: write + uses: $/.github/workflows/_publish-wheel.yml + with: + artifact-pattern: python-fcl-${{ inputs.version || '0.7.0.11' }}-*-manylinux_riscv64 diff --git a/patches/python-fcl/0.7.0.11/0001-Widen-license-files-to-cover-the-vendored-fcl-libcc.patch b/patches/python-fcl/0.7.0.11/0001-Widen-license-files-to-cover-the-vendored-fcl-libcc.patch new file mode 100644 index 000000000..4c57f7f64 --- /dev/null +++ b/patches/python-fcl/0.7.0.11/0001-Widen-license-files-to-cover-the-vendored-fcl-libcc.patch @@ -0,0 +1,39 @@ +From: Ludovic Henry +Date: Fri, 11 Sep 2026 10:00:00 +0000 +Subject: [PATCH] Widen license-files to cover the vendored fcl/libccd/octomap licences + +pyproject.toml's explicit license-files list only names LICENSE, so +setuptools' default LICEN[CS]E* glob never applies here and a dropped-in +LICENSE. is silently ignored. build_dependencies/install_linux.sh +builds fcl, libccd and octomap from source and auditwheel vendors their +shared libraries into the wheel (confirmed on the official +python_fcl-0.7.0.11 manylinux_x86_64 wheel on PyPI, which ships +libfcl/libccd/liboctomap/liboctomath but none of their licence texts), +so every platform's wheel is missing them, not just riscv64's. + +Widening the list to LICENSE.* lets LICENSE.fcl, LICENSE.libccd and +LICENSE.octomap, staged at the project root during the build, reach +dist-info/licenses/ with no other packaging change. + +Upstream-Status: To upstream [not yet submitted; the same gap exists in every python-fcl wheel on PyPI regardless of architecture, so this needs a maintainer discussion rather than a drive-by PR] + +Signed-off-by: Ludovic Henry +--- + pyproject.toml | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/pyproject.toml b/pyproject.toml +index 30750c6..b46aa56 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -55,7 +55,7 @@ Homepage = "https://github.com/berkeleyautomation/python-fcl" + [tool.setuptools] + include-package-data = true + package-dir = {"" = "src"} +-license-files = ["LICENSE"] ++license-files = ["LICENSE", "LICENSE.*"] + + [tool.setuptools.package-data] + "*" = ["*.pyx", "*.pxd", "*.dll"] +-- +2.50.1 diff --git a/patches/python-fcl/0.7.0.11/0002-Guard-against-an-empty-but-present-LD_LIBRARY_PATH-.patch b/patches/python-fcl/0.7.0.11/0002-Guard-against-an-empty-but-present-LD_LIBRARY_PATH-.patch new file mode 100644 index 000000000..ffae1bac9 --- /dev/null +++ b/patches/python-fcl/0.7.0.11/0002-Guard-against-an-empty-but-present-LD_LIBRARY_PATH-.patch @@ -0,0 +1,68 @@ +From: Ludovic Henry +Date: Fri, 11 Sep 2026 14:20:00 +0000 +Subject: [PATCH] Guard against an empty-but-present LD_LIBRARY_PATH/CPATH in setup.py + +get_libraries_dir() checks `"LD_LIBRARY_PATH" in os.environ` and, if +true, appends `os.environ["LD_LIBRARY_PATH"].split(":")` to +library_dirs regardless of whether the value is actually empty. Our +build container exports LD_LIBRARY_PATH="" (present, empty), so +"".split(":") yields [''], and that empty string becomes a bare `-L` +with no path argument on the link command line: + + g++ ... -L/usr/lib -L/usr/local/lib -L/usr/lib64 -L/usr/local/lib64 -L -lfcl -loctomap -o fcl.cpython-312-riscv64-linux-gnu.so + +`-L` with no following value swallows the *next* argv as its path +(ld/gcc treat "-L -lfcl" as one directory-search option whose value is +the literal string "-lfcl"), so -lfcl is consumed there instead of +being parsed as a request to link libfcl. -loctomap, being the last +argument, is unaffected and links normally - which is exactly the +asymmetry seen in the wheel's dynamic section (liboctomap present, +libfcl entirely absent from NEEDED, confirmed via readelf -d), and +why the built extension carries unresolved references into libfcl.so +(e.g. typeinfo for fcl::CollisionGeometry) that nothing ever +supplies, surfacing as an ImportError: undefined symbol at import +time. Confirmed to reproduce identically with -Wl,--no-as-needed +forced on - the flag has nothing to act on, since -lfcl was never +parsed as a library request in the first place. + +get_include_dirs()'s CPATH check has the identical pattern; guarded +the same way for consistency, though our environment did not exhibit +a matching CPATH-triggered bug. + +Upstream-Status: To upstream [not yet submitted; only reproduces when LD_LIBRARY_PATH is exported empty, which our manylinux_riscv64 build container does but upstream's own CI apparently does not] + +Signed-off-by: Ludovic Henry +--- + setup.py | 6 ++++-- + 1 file changed, 4 insertions(+), 2 deletions(-) + +diff --git a/setup.py b/setup.py +index 9ebe01f..9d52455 100644 +--- a/setup.py ++++ b/setup.py +@@ -23,8 +23,9 @@ def get_include_dirs(): + "/usr/local/include/eigen3", + ] + +- if "CPATH" in os.environ: +- include_dirs += os.environ["CPATH"].split(":") ++ cpath = os.environ.get("CPATH", "") ++ if cpath: ++ include_dirs += [p for p in cpath.split(":") if p] + + elif sys.platform == "win32": + include_dirs = [ +@@ -45,8 +46,9 @@ def get_libraries_dir(): + if is_nix_platform(sys.platform): + lib_dirs = ["/usr/lib", "/usr/local/lib", "/usr/lib64", "/usr/local/lib64"] + +- if "LD_LIBRARY_PATH" in os.environ: +- lib_dirs += os.environ["LD_LIBRARY_PATH"].split(":") ++ ld_library_path = os.environ.get("LD_LIBRARY_PATH", "") ++ if ld_library_path: ++ lib_dirs += [p for p in ld_library_path.split(":") if p] + return lib_dirs + if sys.platform == "win32": + return [f"{INSTALL_PREFIX_WIN}\\lib"] +-- +2.50.1