Skip to content
Open
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
175 changes: 159 additions & 16 deletions .github/workflows/wheels.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: Build

on:
on:
pull_request:
branches:
- main
Expand All @@ -9,44 +9,187 @@ on:
- main
tags:
- 'v*.*.*'


# Cancel superseded runs on PRs; never cancel runs on main or tags, so every
# push to main refreshes the nightly release and every tag gets published.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
contents: read

jobs:
# The manylinux build image (Dockerfile) lives in this repository's GHCR
# namespace, tagged by a hash of the Dockerfile. On pushes it is built and
# published when that tag does not exist yet, so a Dockerfile change takes
# effect in the same run and every fork bootstraps its own image. PRs never
# publish; build_wheels builds a missing image locally instead.
image:
name: Build image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
ref: ${{ steps.ref.outputs.ref }}
exists: ${{ steps.ref.outputs.exists }}

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Log in to GHCR
run: echo "${{ github.token }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin

- name: Resolve image reference
id: ref
run: |
image="ghcr.io/${GITHUB_REPOSITORY,,}/manylinux_cuda"
tag="$(sha256sum Dockerfile | cut -c1-16)"
echo "image=$image" >> "$GITHUB_OUTPUT"
echo "ref=$image:$tag" >> "$GITHUB_OUTPUT"
if docker manifest inspect "$image:$tag" >/dev/null 2>&1; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi

- name: Free disk space
if: github.event_name == 'push' && steps.ref.outputs.exists == 'false'
run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL

- name: Build and push image
if: github.event_name == 'push' && steps.ref.outputs.exists == 'false'
run: |
docker build \
--label "org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \
-t "${{ steps.ref.outputs.ref }}" -t "${{ steps.ref.outputs.image }}:latest" .
docker push "${{ steps.ref.outputs.ref }}"
docker push "${{ steps.ref.outputs.image }}:latest"

build_wheels:
name: Build wheels on ${{ matrix.os }}
needs: image
runs-on: ${{ matrix.os }}
permissions:
# IMPORTANT: this permission is mandatory for Trusted Publishing
id-token: write

contents: read
packages: read
strategy:
matrix:
os: [ubuntu-latest]
env:
CIBW_MANYLINUX_X86_64_IMAGE: ${{ needs.image.outputs.ref }}

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
submodules: recursive
persist-credentials: false
# setuptools-scm derives the version from tags and commit distance.
fetch-depth: 0
fetch-tags: true

# Used to host cibuildwheel
- uses: actions/setup-python@v6
- name: Log in to GHCR
run: echo "${{ github.token }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin

- name: Install cibuildwheel
run: python -m pip install cibuildwheel==4.0.0rc1
# PRs that change the Dockerfile have no published image yet.
- name: Build image locally
if: needs.image.outputs.exists == 'false' && github.event_name != 'push'
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
docker build -t "$CIBW_MANYLINUX_X86_64_IMAGE" .

- name: Build wheels
run: python -m cibuildwheel --output-dir dist
uses: pypa/cibuildwheel@adba99c781d3746d38b3a09b1cf797965bcb7fe6 # v4.3.0
with:
output-dir: dist

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }}
path: ./dist/*.whl
if-no-files-found: error

# Release builds: tags only, published together once every build has passed.
# The PyPI Trusted Publisher only trusts the upstream repository, so skip
# (rather than fail) this job on forks.
publish_pypi:
name: Publish to PyPI
needs: build_wheels
if: >-
github.repository == 'hz-b/rayx-python'
&& github.event_name == 'push'
&& startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/project/rayx/
permissions:
# IMPORTANT: this permission is mandatory for Trusted Publishing
id-token: write

steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: cibw-*
path: dist
merge-multiple: true

- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
verbose: true
if: github.event_name == 'push' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main')

- uses: actions/upload-artifact@v6
# Development builds: every push to main replaces the assets of the rolling
# "nightly" GitHub pre-release. The tag name must not contain a digit, so
# setuptools-scm (which only considers tags matching *[0-9]*) ignores it.
# This also runs on forks, giving each fork a nightly of its own main.
publish_nightly:
name: Update nightly release
needs: build_wheels
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: nightly

steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }}
path: ./dist/*.whl
pattern: cibw-*
path: dist
merge-multiple: true

- name: Move nightly tag to this commit
run: |
if gh api "repos/$GH_REPO/git/ref/tags/$TAG" --silent 2>/dev/null; then
gh api -X PATCH "repos/$GH_REPO/git/refs/tags/$TAG" \
-f sha="$GITHUB_SHA" -F force=true --silent
else
gh api -X POST "repos/$GH_REPO/git/refs" \
-f ref="refs/tags/$TAG" -f sha="$GITHUB_SHA" --silent
fi

- name: Replace release assets
run: |
notes="Development build of main at ${GITHUB_SHA}. Not a stable release.

Install with: pip install <wheel URL from the assets below>"
if gh release view "$TAG" >/dev/null 2>&1; then
gh release edit "$TAG" --prerelease --notes "$notes"
# Wheel filenames change with every commit (X.Y.Z.devN), so drop
# the previous build's assets rather than accumulating them.
gh release view "$TAG" --json assets --jq '.assets[].name' |
while read -r asset; do
gh release delete-asset "$TAG" "$asset" --yes
done
else
gh release create "$TAG" --prerelease --title "Nightly (main)" \
--notes "$notes" --verify-tag
fi
gh release upload "$TAG" dist/*.whl
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ test.py
wheelhouse
test.ipynb
*.whl
# Ignore generated stubs (e.g. the nanobind-generated _core.pyi), but keep the
# hand-written public stub, which is a tracked source file and ships in the wheel.
*.pyi
!src/rayx/__init__.pyi
venv/
.venv/
**.so
__pycache__/
rayx/_version.py
src/rayx/_version.py
3 changes: 2 additions & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
url = https://github.com/pybind/pybind11
[submodule "extern/rayx"]
path = extern/rayx
url = https://github.com/hz-b/rayx
url = https://github.com/Atraxus/rayx
branch = fix/ue112-rml-materials
[submodule "extern/nanobind"]
path = extern/nanobind
url = https://github.com/wjakob/nanobind
55 changes: 52 additions & 3 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,56 @@
cmake_minimum_required(VERSION 3.25.2)
# 3.26 is required for FindPython's Development.SABIModule component, which the
# STABLE_ABI extension build depends on (see nanobind_add_module below).
cmake_minimum_required(VERSION 3.26)
project(rayx-python LANGUAGES CXX)

project(rayx-python)
# rayx-core + nanobind. Provides the `rayx-core` target and the nanobind_add_*
# helpers (both stay available at this scope after the subdirectory is processed).
add_subdirectory(extern EXCLUDE_FROM_ALL)
add_subdirectory(rayx)

set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
if(alpaka_ACC_GPU_CUDA_ENABLE)
enable_language(CUDA)
set(CMAKE_CUDA_STANDARD 20)
set(CMAKE_CUDA_STANDARD_REQUIRED ON)
endif()
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION OFF)

# Development.SABIModule provides the Python::SABIModule target that nanobind's
# STABLE_ABI build requires; without it nanobind silently falls back to a
# version-specific extension.
find_package(Python 3.12 COMPONENTS Interpreter Development.Module Development.SABIModule REQUIRED)

# The compiled extension is implementation-private (`_core`); the Python package
# `src/rayx` owns the public API. Following nanobind's recommended layout, the
# binding source sits next to the package as `src/rayx_ext.cpp`.
#
# STABLE_ABI builds against the CPython 3.12+ limited API (abi3) so a single wheel
# covers every supported interpreter (3.12, 3.13, 3.14, ...) instead of one per
# minor version. Paired with `wheel.py-api = "cp312"` in pyproject.toml for tagging.
nanobind_add_module(_core STABLE_ABI src/rayx_ext.cpp)
nanobind_add_stub(
_core_stub
MODULE _core
OUTPUT _core.pyi
PYTHON_PATH $<TARGET_FILE_DIR:_core>
DEPENDS _core
)
target_link_libraries(_core PRIVATE rayx-core)
target_include_directories(_core PRIVATE
$<TARGET_PROPERTY:rayx-core,INTERFACE_INCLUDE_DIRECTORIES>)

# Bundle rayx-core's runtime Data into the wheel. At import time the module
# registers its own package directory as the resource lookup path.
add_custom_command(
TARGET _core POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${RAYX_SOURCE_DIR}/Data"
"${CMAKE_CURRENT_BINARY_DIR}/share/RAYX/Data"
)

# Install native artifacts into the Python package. scikit-build-core installs
# the Python sources from src/rayx independently (see pyproject wheel.packages).
install(TARGETS _core DESTINATION rayx)
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/_core.pyi" DESTINATION rayx)
install(DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/share/RAYX/Data" DESTINATION rayx/share/RAYX)
20 changes: 12 additions & 8 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
FROM quay.io/pypa/manylinux_2_28
# Build image for the manylinux wheels: the cibuildwheel default image plus the
# CUDA toolkit and the native dependencies of rayx. CI builds and publishes it
# to ghcr.io (see .github/workflows/wheels.yml), tagged by a hash of this file.
#
# The base is pinned to the digest cibuildwheel 4.3.0 uses for manylinux_2_28
# x86_64, so the image ships the same CPython interpreters cibuildwheel expects.
FROM quay.io/pypa/manylinux_2_28_x86_64@sha256:39df0042d5cc900b085aa25a0659368b42a0006c54c474299b785b44c1b4ff82

RUN dnf config-manager --add-repo https://developer.download.nvidia.com/compute/cuda/repos/rhel8/x86_64/cuda-rhel8.repo

RUN dnf install -y cuda-toolkit
RUN dnf install -y nvidia-gds
RUN dnf install -y hdf5-devel wget
RUN dnf install -y boost-devel
RUN dnf config-manager --add-repo https://developer.download.nvidia.com/compute/cuda/repos/rhel8/x86_64/cuda-rhel8.repo \
&& dnf install -y cuda-toolkit nvidia-gds hdf5-devel wget boost-devel \
&& dnf clean all \
&& rm -rf /var/cache/dnf

ENV CUDA_HOME="/usr/local/cuda"
ENV PATH="$PATH:$CUDA_HOME/bin"
ENV LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$CUDA_HOME/lib64"
ENV LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$CUDA_HOME/lib64"
40 changes: 25 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Python bindings for [RAYX](https://github.com/hz-b/rayx), the ray tracing engine
pip install rayx
```

> **Note:** The package includes a compiled C++ extension and is distributed as a pre-built wheel. Source builds are not supported via pip.
> **Note:** Standard installations use a pre-built wheel because the package includes a compiled C++ extension. The supported development source-build workflow is described below.

## Requirements

Expand All @@ -30,32 +30,42 @@ Make it permanent by adding that line to `~/.bashrc` (or `~/.zshrc`) and reloadi

## Development

The package can be built in two ways:

1. **CMake** — supports build caching; the result is usable immediately with no install step. See the [example notebook](./examples/metrix.ipynb).
2. **`uv build`** — builds the package as a wheel, which can then be installed into any Python environment.
Use an editable install for development; it builds the native extension and makes
the Python wrapper importable from its normal package location. `uv build` creates
a distributable wheel.

```bash
git submodule update --init --recursive

# Option 1: build in place with CMake (uv creates/manages the environment)
uv run cmake -S . -B build
uv run cmake --build build -j

# Option 2: editable install via uv
uv pip install -e .
uv sync --locked
uv pip install --no-build-isolation -e .
```

`uv run` puts the managed environment's Python first on `PATH`, so CMake's `find_package(Python)` resolves the right interpreter without pinning it manually.
Re-run the editable-install command after changing the native bindings. The
persistent `build/<wheel-tag>` directory and non-isolated development toolchain
retain CMake's build cache, so Ninja recompiles only affected targets in parallel.

`uv pip install -e .` only re-links the Python wrapper files. It does **not** rebuild the compiled bindings — if you change `rayx-core` or the nanobind glue in `rayxpy`, rerun the CMake commands (Option 1) to recompile, or the install will keep serving the stale extension.
Python-only changes are available to the next Python process. After changing
C++ or nanobind code, re-run the editable-install command above, then restart
the Python process or test run; extension modules cannot be safely replaced in
an already-running interpreter.

After the editable install has configured the build directory, `cmake --build
build/<wheel-tag> -j` remains available for a compile-only check. Re-run the
editable-install command to also copy the rebuilt extension into the active Python
environment.

A `tools/bootstrap.sh` helper script is also available, wrapping the steps above with CUDA on/off prompting.

### Running tests

Tests require a CMake build:
Tests require the editable install:

```bash
uv run pytest tests
```

## Releases

Release artifacts are built, tested, and published only by the wheel CI workflow.
It installs every produced wheel outside the checkout and runs the full test suite
before publication; do not publish a workstation-built wheel.
Loading
Loading